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 sole SQLite writer for gpumon.db. 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. Read paths (the /api/* GETs) are read against the same SQLite file via 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 rule). All instances across the swarm hit the same backing file 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