gpumon · integration API

broker · writer · ingress · ocr-api

gpumon-ocr-api — OCR producer HTTP API

Why this exists / when to use what

gpumon-ocr-api is the HTTP producer for the federation ocr.jobs queue. Drop a PDF (multipart upload or raw bytes), it does three things atomically: pins the bytes to IPFS, opens a row in ocr_jobs via the writer, and publishes an envelope to the broker. A pool of gpumon-ocr-worker replicas drains the queue and writes results back through the writer.

Use this surface when you have a PDF (or image) and want OCR'd text + markdown out, with the result persisted, IPFS-pinned, and survivable across worker crashes. For programmatic cross-domain submission (e.g. edgar-filing-ingester finding a scanned exhibit), skip the HTTP API and publish directly to ocr.jobs — see envelope.md.

Addresses

Audience URL
In-swarm callers http://gpumon-ocr-api:2295
LAN callers http://node-eleven:2295
Dashboard proxy /ocr-api/* (the dashboard nginx forwards to the in-swarm name)

Service runs on host-mode port 2295 on node-eleven so the dashboard proxy and external CLI clients hit the same place.

Authentication

Basic-auth or 180-day cookie. The cookie is HMAC-signed by OCR_AUTH_SECRET (rotate the secret to invalidate all live cookies).

Env var Default Purpose
OCR_AUTH_PASSWORD gpu The password the dashboard prompts for. Username is ignored.
OCR_AUTH_SECRET gpumon-default-secret-change-me HMAC salt for the 180-day cookie. Set this in prod.
OCR_AUTH_REALM gpumon-ocr Realm string for WWW-Authenticate.

Cookie name: gpumon-ocr-auth. Max-Age=180d; HttpOnly; SameSite=Lax.

Login flow

POST /login + Basic auth ──► 200 + Set-Cookie: gpumon-ocr-auth=...; Max-Age=15552000
GET  /auth-status         ──► {"authed": true|false}
POST /logout              ──► 200 + Set-Cookie: ...; Max-Age=0

Any authenticated request also gets a freshly-minted cookie if you only had Basic auth — single sign-in, then cookie for 6 months.

Endpoints

Public (no auth)

Method Path Body Purpose
GET /health {ok, exchange, routing_key}
GET /version {name, version}
GET /auth-status {authed: bool}
POST /login Basic-auth header Mints cookie.
POST /logout Clears cookie.

Gated (auth required)

Method Path Body Purpose
POST /jobs multipart file=<pdf> or raw application/pdf body Submit a job. Returns {id, ipfs_cid, status}. See validation rules below.
GET /jobs?limit= Proxies to writer:/api/ocr/jobs. Returns {running, queued, total, jobs[]}.
GET /jobs/:id Single job record.
GET /jobs/:id/result.txt Plain text. 425 if not yet terminal, 404 if unknown.
GET /jobs/:id/result.md Markdown.
GET /jobs/:id/result.json Per-page timings + exhausted_pages.
POST /jobs/:id/retry Re-publish the same envelope with attempt=1 (manual re-drive).
GET /ipfs/:cid?filename= Auth-gated IPFS gateway proxy. Streams from local kubo nodes (192.168.1.76:8080, 127.0.0.1:8080).

Submission validation

POST /jobs enforces:

  • MAX_UPLOAD_BYTES (default 200 MiB, capped at 256 MiB by the Bun server's maxRequestBodySize).
  • Content sniff: PDF magic (%PDF-). Non-PDF uploads return 400.
  • Fail-open IPFS: if all kubo nodes are unreachable, returns 502 rather than queuing a CID-less envelope.

curl examples

Submit a PDF

# Login once, save cookie jar:
curl -c ~/.gpumon-ocr.cookies -X POST -u gpu:gpu http://node-eleven:2295/login

# Multipart upload (recommended — preserves filename):
curl -b ~/.gpumon-ocr.cookies -X POST \
  -F "file=@/path/to/exhibit-12.pdf" \
  http://node-eleven:2295/jobs

# Or raw bytes (filename defaults to "upload.pdf"):
curl -b ~/.gpumon-ocr.cookies -X POST \
  --data-binary @/path/to/exhibit-12.pdf \
  -H 'content-type: application/pdf' \
  http://node-eleven:2295/jobs

Response:

{ "id": "ocr_2026-05-19_abc123", "ipfs_cid": "bafybei...", "status": "queued" }

Poll until done

ID=ocr_2026-05-19_abc123
while true; do
  STATUS=$(curl -s -b ~/.gpumon-ocr.cookies http://node-eleven:2295/jobs/$ID | jq -r .status)
  echo "status=$STATUS"
  case "$STATUS" in done|exhausted|error) break ;; esac
  sleep 5
done

curl -b ~/.gpumon-ocr.cookies http://node-eleven:2295/jobs/$ID/result.md > out.md

Bun fetch example

const form = new FormData();
form.append("file", new Blob([pdfBytes]), "exhibit-12.pdf");

const sub = await fetch("http://node-eleven:2295/jobs", {
  method: "POST",
  headers: { "authorization": "Basic " + btoa("gpu:gpu") },
  body: form,
});
if (!sub.ok) throw new Error(`submit ${sub.status}: ${await sub.text()}`);
const { id } = await sub.json();

// Poll
for (;;) {
  const r = await fetch(`http://node-eleven:2295/jobs/${id}`, {
    headers: { "authorization": "Basic " + btoa("gpu:gpu") },
  });
  const row = await r.json();
  if (row.status === "done" || row.status === "exhausted") break;
  if (row.status === "error") throw new Error(`OCR failed: ${row.error_msg}`);
  await new Promise(r => setTimeout(r, 5000));
}

const md = await fetch(`http://node-eleven:2295/jobs/${id}/result.md`, {
  headers: { "authorization": "Basic " + btoa("gpu:gpu") },
});
console.log(await md.text());

End-to-end walkthrough

The "I want to OCR a PDF" path, spanning three subsystems:

caller ─► ocr-api  POST /jobs (multipart pdf)
                   │ 1. sniff PDF magic
                   │ 2. uploadToIpfs(buf, filename) ─► kubo cluster
                   │    ◀── {cid}
                   │ 3. writer POST /api/ocr/jobs/start
                   │    {id, filename, ipfs_cid, sha1, bytes, status:"queued"}
                   │    ◀── {ok:true}
                   │ 4. broker publish federation.work / ocr.jobs
                   │    envelope: {version:1, id, ts, attempt:1,
                   │               payload:{kind:"ocr", job_id:id, ipfs_cid, ...}}
                   ▼
caller ◀──ocr-api  200 {id, ipfs_cid, status:"queued"}

                   ─────── (some seconds later) ───────

worker ◀── broker  consume ocr.jobs envelope
       │ 1. fetchFromIpfs(cid) ─► local gateways
       │ 2. runPipeline(buf, filename) ─►
       │    pdftoppm → vision OCR (pool-ocr via ingress) →
       │    chunked cleanup (pool-fraud-cleanup via ingress) →
       │    assembled text + markdown
       │ 3. writer POST /api/ocr/jobs/finish
       │    {id, status:"done", result_txt, result_md, result_json,
       │     total_pages, pages_done, peak_mem_mb}
       │    ◀── {ok:true}
       │ 4. ack the AMQP message
       ▼
       (worker idle, ready for next envelope)

                   ─────── (caller polls / waits for SSE) ───────

caller ─► ocr-api  GET /jobs/:id            ◀── row with status:"done"
caller ─► ocr-api  GET /jobs/:id/result.md  ◀── 200, text/markdown

Each call returns from the caller's perspective in milliseconds. The actual OCR work happens asynchronously across gpumon-ocr-worker replicas; scale them by bumping replicas: in the swarm stack.

Cross-references

  • Worker-side ledger endpoints: writer.md
  • Envelope payload schema: envelope.md
  • Broker topology + auth: broker.md
  • Producer skeleton (the code behind POST /jobs): services/ocr-api/src/amqp.ts
  • Consumer skeleton (the code behind the worker): services/ocr-worker/src/amqp.ts
  • Generic producer + consumer pattern: client-pattern.md