gpumon · integration API

broker · writer · ingress · ocr-api

gpumon-writer — telemetry & ledger HTTP API

Why this exists / when to use what

gpumon-writer is the single writer service for federation state. Everything that needs durable state — GPU samples, LLM call records, OCR job rows, workload registrations, project metadata, cooldowns, quality flags — goes through this one process. Its primary backend is ParadeDB (Postgres) via PG_DSN: heavy aggregations run as ordinary async SQL, so Bun's single thread never stalls ~8s on a multi-GB scan (the old SQLite behavior that drove 137 crash-loops). When PG_DSN is unset it falls back to legacy single-writer bun:sqlite (gpumon.db) — a one-line-env rollback; both backends expose the same call surface. Read paths (the /api/* GETs) hit the same backend through this same service.

Call the writer when: you need to persist something (telemetry, OCR job state, workload), or when you need to read aggregated state for a dashboard or audit. If you want to make an LLM call go through ingress, not the writer. If you want to enqueue work across machines, talk to the broker.

Addresses

Audience URL
In-swarm callers http://gpumon-writer:2289
LAN callers http://node-eleven:2289
Same-node fallback http://127.0.0.1:2289

The writer is pinned to node-eleven (single writer service). All callers across the swarm reach the same process — and therefore the same ParadeDB backend — via the published swarm-ingress port. Auth is none on LAN.

Endpoint table

GPU telemetry

Method Path Body Purpose
POST /samples {ts, hostname, gpu_index, util_pct, mem_used_mb, mem_total_mb, power_w, temp_c, sm_clock_mhz, cpu_temp_c?} One GPU sample (5s granularity). Collector calls this.
GET /api/hosts — All known hostnames.
GET /api/gpu/latest — Latest sample per host/GPU.
GET /api/gpu/series?host=&gpu=&window= — Time series for a single GPU.
GET /api/gpu/series-all — Series for all GPUs (dashboard).

LLM call telemetry

The two-phase pattern (/start → /finish) is what ingress uses internally; you only need to call these directly if you're routing around ingress (e.g. calling Anthropic) and still want the call to appear on the dashboard.

Method Path Body Purpose
POST /llm-calls/start {ts?, project_id, caller_service, caller_host?, model, pool?, upstream_host?, prompt?, prompt_preview?, workflow_stage?, doc_id?, api_key_alias?, workload_id?} Insert in_flight row. Returns {request_id}. Stashes full prompt in llm_request_bodies (72h TTL).
POST /llm-calls/finish {request_id, status, latency_ms, ttft_ms?, prompt_tokens?, completion_tokens?, total_tokens?, cost_usd?, response?, response_preview?, upstream_model?, upstream_host?, api_key_alias?, quality_flag?, quality_score?} Close the row with outcome. Updates upstream_model only if non-empty (so pool → actual-model mapping resolves after routing).
POST /llm-calls Single-shot legacy: all fields at once Fallback path for callers that can't do two-phase.
GET /api/llm/recent?limit= — Most recent N calls (dashboard live stream tail).
GET /api/llm/series?window= — LLM calls/min over time.
GET /api/llm/series-by-host — Same, grouped by upstream_host.
GET /api/llm/top-models — Most-called models.
GET /api/llm/top-callers — Most-active caller_service.
GET /api/llm/projects — Per-project rollup.
GET /api/llm/body/:request_id — Full prompt+response (72h TTL). Used by the dashboard modal on dblclick.
GET /api/llm/empty-payloads?since= — Audit: calls with status=ok but no body.

Workloads

A workload groups N LLM calls into one logical "job" (e.g. "summarize this 10-K" = 1 workload, ~12 LLM calls). Register at the start, complete at the end; the dashboard renders an in-flight tile per active workload.

Method Path Body Purpose
POST /workloads {client_id, workload, started_at, ttl_s, expected_calls?, stages?[]} Register a workload. Returns {workload_id: "wl_xxxxxxxx"}. Send that id back as x-gpumon-workload-id to ingress.
POST /workloads/:id/complete — Mark complete. Idempotent.
PATCH /workloads/:id {expected_calls?} Adjust expected total mid-flight (rare).
GET /api/workloads — Active workloads (not-yet-completed and not-yet-expired).

Projects

Method Path Body Purpose
POST /api/projects/register {project_id, display_name?, color?, owner?, notes?} Idempotent upsert. Surfaces the project on the dashboard.
GET /api/projects — All registered projects (with last-seen timestamps).

OCR jobs (wave-1 federation queue)

These are the writer-side ledger for OCR jobs. The producer (gpumon-ocr-api) calls /start on POST /jobs. The worker (gpumon-ocr-worker) calls /finish on success / partial-success or /fail after MAX_ATTEMPTS exhausts. All three are idempotent so the dual-write window during a redeploy doesn't corrupt state, and terminal states (done, exhausted, error) are sticky — a retried /start cannot un-finish a job.

Method Path Body Purpose
POST /api/ocr/jobs/start {id, filename, bytes?, ipfs_cid?, sha1?, status?, created_at?, total_pages?, retry_count?} Upsert (idempotent on id). Default status="queued".
POST /api/ocr/jobs/finish {id, status: "done"|"exhausted", result_txt?, result_md?, result_json?, total_pages?, pages_done?, peak_mem_mb?} Close successfully. No-op if already terminal.
POST /api/ocr/jobs/fail {id, error_msg?, retry_count?} Close with status="error" after final give-up.
GET /api/ocr/jobs?limit= — List up to 500 rows. Returns {running, queued, total, jobs[]}.
GET /api/ocr/jobs/:id — Single job record.
GET /api/ocr/jobs/:id/result.txt — Plain-text result. 425 if not yet terminal, 404 if unknown.
GET /api/ocr/jobs/:id/result.md — Markdown result.
GET /api/ocr/jobs/:id/result.json — Per-page timings + exhausted_pages diagnostics.

Most integrators use gpumon-ocr-api (see ocr-api.md) rather than these endpoints directly. Talk to the writer's OCR endpoints directly only if you're writing a new producer / worker pair.

Cooldowns

Method Path Body Purpose
POST /cooldown/:key {until_ts, reason?} Set a cooldown (e.g. on a NIM key after 429).
GET /api/cooldowns — All active cooldowns.

Quality / corruption

Method Path Body Purpose
GET /api/quality/recent — Recent calls flagged as low-quality / corrupt.
GET /api/quality/by-doc?doc_id= — Per-doc quality grid.
GET /api/quality/by-model — Per-model corruption rate.
POST /api/quality/mark-recovered {request_id, note?} Clear a flag after manual review.

Health / observability

Method Path Body Purpose
GET /health — {ok, port, sse_clients}
GET /api/health-summary — Aggregated health JSON (cooldowns, NIM ctx, in-flight workloads).
GET /api/nvidia/models — Live NIM model registry (ctx, RPM/key, vision flag).
GET /api/nvidia/series — NIM usage over time.
GET /api/nvidia/ctx — NIM context-window saturation.

SSE live stream

Method Path Body Purpose
GET /stream — text/event-stream of every write the writer accepts. First event is hello\ndata: {}\n\n.

Event shape: event: <kind>\ndata: <json>\n\n. Kinds include hello, gpu-sample, llm-call, ocr-job, workload-complete. The dashboard consumes this; any client that wants to live-tail can too.

curl examples

Open a workload, call ingress, close the workload

# 1. Open
WORKLOAD=$(curl -s http://node-eleven:2289/workloads \
  -H 'content-type: application/json' \
  -d "{\"client_id\":\"edgar-summarizer\",\"workload\":\"10K-summary\",
        \"started_at\":$(date +%s),\"ttl_s\":3600,
        \"expected_calls\":12,\"stages\":[\"chunk\",\"summarize\",\"verify\"]}" \
  | jq -r '.workload_id')

# 2. Use $WORKLOAD as x-gpumon-workload-id on every ingress call
#    (see ingress.md)

# 3. Close
curl -X POST http://node-eleven:2289/workloads/$WORKLOAD/complete

List recent OCR jobs

curl 'http://node-eleven:2289/api/ocr/jobs?limit=50' | jq '.jobs[] | {id,filename,status}'

Tail the live stream

curl -N http://node-eleven:2289/stream

Bun fetch example

// Record an LLM call we made directly (didn't go through ingress).
const t0 = Date.now();
const start = await fetch("http://node-eleven:2289/llm-calls/start", {
  method: "POST",
  headers: {"content-type":"application/json"},
  body: JSON.stringify({
    project_id: "claude-mem",
    caller_service: "observation-extractor",
    model: "claude-opus-4-7",
    pool: "anthropic-direct",
    upstream_host: "api.anthropic.com",
    prompt: "Summarize this transcript ...",
    workflow_stage: "extract",
  }),
});
const {request_id} = await start.json();

// ... actually call the LLM ...
const response = "...";

await fetch("http://node-eleven:2289/llm-calls/finish", {
  method: "POST",
  headers: {"content-type":"application/json"},
  body: JSON.stringify({
    request_id,
    status: "ok",
    latency_ms: Date.now() - t0,
    prompt_tokens: 1024, completion_tokens: 256, total_tokens: 1280,
    cost_usd: 0.012,
    response,
    upstream_model: "claude-opus-4-7",
  }),
});

End-to-end walkthrough — record an LLM call's outcome

This is the path ingress itself uses; you only do it manually when routing around ingress.

caller ─► ingress  POST /v1/chat/completions
                   │  parses x-gpumon-* headers
                   ▼
caller ◀──ingress  ───► writer  POST /llm-calls/start
                                ◀── {request_id}
        (response)               ▲
                                 │ writer SSE: event: llm-call
ingress  ◀── upstream LLM        │  data: {request_id, status:"in_flight", ...}
         ─► writer  POST /llm-calls/finish
                    ◀── {ok:true}
                    ▲
                    │ writer SSE: event: llm-call
                    │  data: {request_id, status:"ok", latency_ms, ...}
                    ▼
                  dashboard renders the row

The dashboard's live stream subscribes to GET /stream and re-renders on every event. Two-phase write keeps the row visible while in flight — useful for spotting hangs.

Cross-references