gpumon-ocr-api — OCR producer HTTP API
Why this exists / when to use what
gpumon-ocr-apiis the HTTP producer for the federationocr.jobsqueue. Drop a PDF (multipart upload or raw bytes), it does three things atomically: pins the bytes to IPFS, opens a row inocr_jobsvia the writer, and publishes an envelope to the broker. A pool ofgpumon-ocr-workerreplicas 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— seeenvelope.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'smaxRequestBodySize).- 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