gpumon-writer — telemetry & ledger HTTP API
Why this exists / when to use what
gpumon-writeris the sole SQLite writer forgpumon.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
- LLM call originators:
ingress.md - OCR job producer:
ocr-api.md - Queue-driven workers that ledger via this writer:
client-pattern.md