How gpumon sees model traffic
Every dashboard chart, badge, livestream row, and NIM-card rpm gauge is sourced from a single table — llm_requests in ParadeDB (Postgres) — populated by exactly one upstream pipeline:
Two write paths populate llm_requests, both via gpumon-writer:
- ingress-direct —
gpumon-ingressposts to/llm-callson every chat/embed proxy call (sync, fire-and-forget). Carries x-gpumon-* headers verbatim → caller_service, project_id, workflow_stage, doc_id all attributed correctly. - litellm-callback —
services/litellm-callback/gpumon_callback.pyis loaded inside LiteLLM. Every call LiteLLM serves (whether through gpumon-ingress or not) fires the callback, which posts to/llm-calls. This catches anything that hits LiteLLM directly (legacy callers, the watchdog probe set, etc.) and tags itcaller_service=unknownif the gpumon headers were not present.
Bypass paths to watch for — any caller that hits a vLLM / Ollama / NIM endpoint without going through LiteLLM and without going through gpumon-ingress will be invisible on the dashboard. As of 2026-05-12 the only deliberate bypass is fraud-watchdog's /health?model=… probes (LiteLLM-specific route, not OpenAI-compat — cannot be proxied by gpumon-ingress).
Per-pool routing recap:
pool-ocr/pool-vision→nim-passthroughsidecar (spark-1:14000) → NVIDIA NIM hosted API. LiteLLM is skipped here because itsnvidia_nimprovider stripsimage_urlcontent./v1/embeddings→ doc-skills MCP at192.168.1.86:2280/mcp→ ModernBERT on spark-1. Single-backend pool; onlyx-gpumon-client-idis required.- All other pools (
pool-fraud-*,pool-summary-*,pool-qwen-*, etc.) → LiteLLM → upstream GPU node (spark-1/spark-2 vLLM, nvidia-one/two/four vLLM or llama.cpp). Per-key RPM tracked by ingress; cooldown applied on 429.
Hooking up a new client
Clients make LLM calls by enqueuing to the gpumon.dispatch AMQP queue via DispatchClient; the scheduler paces NIM/LAN admission, records every call (best_effort), applies ctx-fit, and returns the result.
- Copy
lib/dispatch-client.tsinto your service (peer deps:amqplib+ nativefetch). - Set
FEDERATION_AMQP_URL(required) andGPUMON_WRITER_BASE(required for poll mode, e.g.http://gpumon-writer:2289). - Construct a
DispatchClientand callenqueueAndAwait(pool, body, attribution, {mode}). - Pick a
poolfrom the Task-Specific Pools table (shows on dashboard + gets ctx-fit + failover).
import { DispatchClient } from "./lib/dispatch-client"; // FEDERATION_AMQP_URL env supplies the broker URL automatically. // writerBase falls back to GPUMON_WRITER_BASE env (needed for poll mode only). const dispatch = new DispatchClient({ defaultMode: "reply" }); const result = await dispatch.enqueueAndAwait( "pool-qwen-large", { messages: [{ role: "user", content: "Summarise this document." }], max_tokens: 512 }, { project_id: "my-service", caller_service: "my-service", workflow_stage: "summary-pass1", doc_id: "doc:edgar:12345", }, { mode: "reply", timeoutMs: 30_000 }, ); // result.status (200 = ok) result.content (assistant text) // result.upstream_model result.api_key_alias result.latency_ms
| Mode | When to use | Durability |
|---|---|---|
poll (default) | Batch / anything that can survive a restart; envelope + llm_jobs row persist across scheduler restarts | Durable — resume by job id |
reply | Interactive / RPC-style; lowest latency via exclusive reply queue | Ephemeral — lost if client dies waiting |
doc-skills/src/providers/gpumon-llm.ts — reply mode MCP provider.
fraud-heuristics/enrich-worker/src/index.ts — reply mode batch enrichment.
Routing control & throughput tuning
The dashboard Routing Control popup owns the tier cascade that gpumon-scheduler
walks for every pooled call. Each pool resolves to an ordered list of tiers —
fast (NIM + haiku/litellm) →
steady (LAN GPU hosts) →
failover. Within a tier, backends are weight-shuffled; a 2xx wins, a
4xx (non-429) is a terminal client error, and 429 / 5xx / network failover to the next backend, then the
next tier. When every tier is exhausted the request is saturated (429). One helper —
walkAndDispatch() in services/scheduler/src/cascade.ts — is the single source of
truth: the production /v1/chat/completions path and the load-test engine call it, so
measured numbers are exactly what production does. No drift.
Throughput / tuning drives sustained load through that same cascade to find the highest
usable (success-bounded) RPM, then proposes the knobs that lock it in.
Dry mode walks the gates and picks the winning backend but skips the upstream call —
zero GPU / quota cost, validates routing + distribution only. Real mode issues actual
completions. include_nim defaults off so a load test cannot burn the 14-key
NVIDIA quota unless explicitly opted in. Auto-tune ramps the offered RPM (coarse ×1.5,
then binary-search the breakpoint) until the success bound breaks — success_rate ≥ success_min
and err_429_rate ≤ err_429_max — converges on the knee, and emits an Apply-able
routing.json patch (chiefly nim.rpm_target = observed NIM-tier RPM at the knee).
Every run reports lifecycle to the progress board as workflow gpumon:loadtest.
Scheduler endpoints (port 2293), surfaced to the dashboard popup through nginx as /api/routing/*.
One run at a time — a second start returns 409 busy. Hard caps: duration_s ≤ 120,
max_concurrency ≤ 64.
| Method | Path | Params / Body | Response | Notes |
|---|---|---|---|---|
| GET | /v1/routing | — | { routing, nim_knobs, nim_window_rpm } | Current routing.json + live NIM window RPM |
| POST | /v1/routing | RoutingFile | { ok, error? } | Validate + persist routing.json; scheduler hot-reloads (no restart) |
| POST | /v1/routing/test | { pool, prompt?, approx_tokens?, dispatch? } | RoutingTestResult | Single-envelope dry-run of the tier-walk for one pool |
| POST | /v1/routing/loadtest | { pool, mode:"dry"|"real", include_nim, target_rpm, duration_s, max_concurrency, prompt?, max_tokens? } | { ok, run_id, cfg } | Start a fixed-RPM load window; async (409 if busy) |
| GET | /v1/routing/loadtest | — | LoadTestSnapshot | Live metrics: achieved_rpm, success_rate, err_429_rate, p95_ms, nim_rpm, by_tier, by_backend, phases[], recommendation |
| POST | /v1/routing/loadtest/stop | — | { ok, run_id } | Abort the active run (drains in-flight) |
| POST | /v1/routing/tune | { pool, mode, include_nim, rpm_start, rpm_max, window_s, success_min, err_429_max } | { ok, run_id, cfg } | Auto-tune knee-finder; shares the loadtest snapshot endpoint |
Writer HTTP API
| Method | Path | Params / Body | Response | Notes |
|---|---|---|---|---|
| POST | /samples | { samples: GpuSample[] } | { ok, n } | Upserts gpu_samples_5s; broadcasts SSE samples event (SSH-pull collector) |
| POST | /api/gpu/report | { samples: GpuSample[] } hdr x-gpumon-report-token | { ok, n } | Host-push ingest: GPU hosts self-report card data; token-guarded; same upsert + SSE as /samples |
| POST | /llm-calls | { project_id, model, pool, upstream_host, status, latency_ms, ttft_ms, prompt_tokens, completion_tokens, cost_usd, prompt_preview, response_preview, workflow_stage, api_key_alias, workload_id, … } | { ok, id } | Inserts llm_requests; optionally stores full body (72h TTL); broadcasts SSE llm event; bumps project last_seen_at |
| POST | /workloads | { client_id, workload, expected_calls, started_at, ttl_s, stages[] } | { workload_id } | Creates a new workload tracking record |
| POST | /workloads/:id/complete | — | { ok } | Marks workload completed_at = now |
| PATCH | /workloads/:id | { expected_calls } | { ok } | Updates expected_calls after registration |
| POST | /cooldown/:alias | { until, reason?, backoff_s? } | { ok } | Sets key cooldown; broadcasts SSE key-cooldown event |
| POST | /api/projects/register | { project_id, label?, color?, owner?, auth_token? } | { ok, project_id } | Upsert project; preserves registered_at on re-register |
| GET | /api/hosts | — | Host[] | All GPU+LLM hosts from hosts table |
| GET | /api/gpu/latest | — | GpuLatest[] | Most recent sample per host |
| GET | /api/gpu/series | ?host=&since=&bucket= | Series[] | Bucketed util/mem/pwr for one host |
| GET | /api/gpu/series-all | ?since=&bucket= | GpuSeriesAll[] | Bucketed util/mem/pwr/temp for all hosts |
| GET | /api/llm/recent | ?limit= | LlmRow[] | Most recent LLM calls, max 500 |
| GET | /api/llm/top-models | ?since= | ModelAgg[] | call count + token totals by model |
| GET | /api/llm/projects | ?since= | ProjectAgg[] | call count + tokens + avg_lat by project |
| GET | /api/llm/top-callers | ?since= | CallerAgg[] | call count by caller_service + caller_host |
| GET | /api/llm/series | ?since=&bucket= | LlmSeries[] | Bucketed calls + avg_lat fleet-wide |
| GET | /api/llm/series-by-host | ?since=&bucket= | LlmSeriesByHost[] | Bucketed calls + avg_lat per upstream host |
| GET | /api/nvidia/models | — | NvidiaModel[] | NIM RPM state; tumbling wall-clock-minute window |
| GET | /api/nvidia/series | ?since=&bucket= | NvidiaSeries[] | Bucketed calls + avg_lat per NIM model_name |
| GET | /api/nvidia/ctx | ?since= | NvidiaCtxRow[] | Avg/max prompt + completion tokens per NIM model |
| GET | /api/projects | — | Project[] | All registered projects |
| GET | /api/workloads | — | Workload[] | Active (incomplete, not expired) workloads |
| GET | /api/cooldowns | — | Cooldown[] | Active (not yet expired) key cooldowns |
| GET | /api/health-summary | — | HealthSummary | 5xx count (1h), rate-limit count (5m), WAL size |
| GET | /stream | — | text/event-stream | SSE: events llm, samples, nvidia-rpm, key-cooldown |
| GET | /health | — | { ok, port, sse_clients } | Liveness check |
| GET | /docs | — | text/html | This page |
| GET | /ux-spec | — | text/html | Dashboard design-system reference |
NIM Model Catalog
| model_name | upstream_id | vendor | ctx_window | rpm/key | keys | total_rpm_cap | vision | notes |
|---|---|---|---|---|---|---|---|---|
| nvidia / gpt-oss-120b | nvidia_nim/openai/gpt-oss-120b | openai | 131,072 | 40 | 14 | 560 | no | OpenAI-shape 120B fast generalist; lowest latency of the NIMs; emits reasoning_content |
| nvidia / llama-3.1-nemotron-nano-vl-8b | nvidia_nim/nvidia/llama-3.1-nemotron-nano-vl-8b-v1 | nvidia | 32,768 | 40 | 14 | 560 | yes | curated 8B vision-capable, ~378ms; primary in pool-ocr/pool-vision and primary tier vision slot |
| nvidia / llama-3.2-90b-vision | nvidia_nim/meta/llama-3.2-90b-vision-instruct | meta | 131,072 | 40 | 14 | 560 | yes | legacy vision (90B) — still in some pool definitions; demoted in favor of nemotron-nano-vl-8b |
| nvidia / llama-3.3-70b-instruct | nvidia_nim/meta/llama-3.3-70b-instruct | meta | 131,072 | 40 | 14 | 560 | no | workhorse 70B text — primary in _primary_nim_default since 2026-05-25; weight 25 |
| nvidia / llama-4-maverick-17b | nvidia_nim/meta/llama-4-maverick-17b-128e-instruct | meta | 131,072 | 40 | 14 | 560 | no | 128-expert MoE, 17B active params, ~588ms first-call |
| nvidia / nemotron-mini-4b-instruct | nvidia_nim/nvidia/nemotron-mini-4b-instruct | nvidia | 4,096 | 40 | 14 | 560 | no | fastest NIM, ~169ms — 4B, 4096-tok TOTAL context (small inputs only; removed from pool-summary-short 2026-05-27 after large-input 400s) |
Task-Specific Pools
| Pool | total_rpm_cap | total_weight | primary upstream | members | class | notes / purpose |
|---|---|---|---|---|---|---|
| pool-fraud-triples | 2050 | 465 | qwen3-next-80b-NIM | 9 | NIM-heavy | Fraud heuristics triples extraction. 97% NIM-weighted. |
| edgar-microtask-pool | 600 | 485 | mixed | 9 | BALANCED | Edgar small targeted tasks across multiple model classes. |
| edgar-summarizer-pool | 2070 | 747 | gpt-oss-120b-NIM | 11 | NIM-heavy | Edgar heavyweight summarization. 80% gpt-oss weighted. |
| pool-qwen-14b | 2030 | 459 | qwen3-next-80b-NIM | 7 | NIM-heavy | Misnamed — routes to qwen3-next-80b NIM, not qwen3-14b. |
| pool-qwen-large | 3560 | 918 | qwen3-next-80b-NIM | 13 | NIM-heavy | Heaviest pool. 8 SUNNYPANTS keys. |
| pool-vision | 2500 | 600 | llama-3.2-90b-vision | 5 | PURE-NIM | Pure llama-vision NIM, 5 keys equal weight. |
| pool-gemma | 1030 | 309 | llama-3.2-90b-vision | 5 | NIM-heavy | Misnamed — routes mostly to vision NIM, gemma local minor fallback. |
| pool-ocr | 110 | 15 | Qwen2.5-VL-7B-local | 2 | LOCAL-biased | Local-biased OCR; 1 NIM vision key as fallback. |
| pool-load-balance | 180 | 0 | mixed-local | 3 | PURE-LOCAL | Pure local round-robin. Used by claude-mem. |
Direct model_names
| model_name | class | rpm / capacity | notes |
|---|---|---|---|
| nvidia / qwen3-next-80b-instruct | NIM | 5 keys × 50 rpm = 230 rpm | direct addressable |
| nvidia / llama-3.2-90b-vision | NIM vision | 5 keys × 50 rpm = 230 rpm | direct addressable |
| nvidia / gpt-oss-120b | NIM | 5 keys × 50 rpm = 230 rpm | direct addressable |
| spark-1 / gemma4-26b | LOCAL | — | local vLLM |
| spark-1 / diffusiongemma | LOCAL | — | NVFP4 block-diffusion, vLLM :11437, experimental |
| spark-2 / qwen3-30b | LOCAL | — | local vLLM |
| spark-2 / qwen3.5-35b | LOCAL | — | local vLLM |
| spark-2 / qwen2.5-vl-7b | LOCAL | — | local vLLM |
| spark-2 / gemma4-31b | LOCAL | — | local vLLM |
| nvidia-1 / qwen3.6-27b | LOCAL | — | local |
| nvidia-2 / qwen3-14b | LOCAL | — | local |
| nvidia-4 / qwen3.6-35b | LOCAL | — | local |
| mepmbp2022 / glm-4.7-flash | LOCAL | — | local |
| anthropic / haiku-4-5 | CLOUD | — | direct cloud |
| inception / mercury-2 | CLOUD | — | direct cloud |
SSE Event Reference
| event | payload shape | description |
|---|---|---|
| samples | { n: number } | Ping when GPU samples arrive; n = count of rows inserted |
| llm | LlmRow (full inserted record) | Fired after every POST /llm-calls; dashboard uses for per-card optimistic RPM bump |
| nvidia-rpm | NvidiaModel[] | Broadcast every 2s; reconciles RPM counters for all NIM key rows |
| key-cooldown | { alias, until, reason, backoff_s } | Fired after POST /cooldown/:alias; dashboard dims the key badge |
Client Setup
Three steps for any new caller: (1) register your project on the writer, (2) optionally register a long-running workload, (3) call /v1/chat/completions on the ingress with attribution headers.
| Caller | Base URL | Notes |
|---|---|---|
Docker Swarm task on gpumon-net overlay | http://gpumon-ingress:4001 | internal port |
| LAN host / laptop on Wi-Fi | http://192.168.1.211:4010 | swarm-published port |
| Local mepstudio dev | http://localhost:4010 | local compose |
Authorization: Bearer sk-litellm-master-cmem-2026 Content-Type: application/json x-gpumon-pool: <pool-name OR same as model> x-gpumon-client-id: <my-service-name> # caller_service on dashboard x-gpumon-project: <my-registered-project-id> # shown in projects card
| Header | What | Example |
|---|---|---|
| x-gpumon-workflow-stage | Pipeline pass/stage | summary-pass1 · embed · triples · ocr |
| x-gpumon-workload-id | Long-running batch grouping | wl_2026_05_13_edgar_sweep |
| x-gpumon-doc-id | Per-document traceability | doc:edgar:12345 |
| x-gpumon-session-id | Claude session / shell session that triggered the job | cc_2026_05_13_abc123 |
| x-gpumon-caller-host | Override hostname behind a load balancer | node-eighteen |
const INGRESS = process.env.GPUMON_INGRESS_BASE ?? "http://192.168.1.211:4010"; const KEY = process.env.LITELLM_KEY ?? "sk-litellm-master-cmem-2026"; async function chat(pool: string, messages: any[], opts: { doc_id?: string; workflow?: string; workload?: string; max_tokens?: number; } = {}) { const headers: Record<string,string> = { "content-type": "application/json", "authorization": `Bearer ${KEY}`, "x-gpumon-pool": pool, "x-gpumon-client-id": "my-pipeline", "x-gpumon-project": "my-pipeline", }; if (opts.doc_id) headers["x-gpumon-doc-id"] = opts.doc_id; if (opts.workflow) headers["x-gpumon-workflow-stage"] = opts.workflow; if (opts.workload) headers["x-gpumon-workload-id"] = opts.workload; const r = await fetch(`${INGRESS}/v1/chat/completions`, { method: "POST", headers, body: JSON.stringify({ model: pool, messages, max_tokens: opts.max_tokens ?? 1024, temperature: 0.1 }), }); if (!r.ok) throw new Error(`gpumon ${r.status}: ${await r.text()}`); return (await r.json()).choices[0].message.content; }
import os, requests INGRESS = os.environ.get("GPUMON_INGRESS_BASE", "http://192.168.1.211:4010") KEY = os.environ.get("LITELLM_KEY", "sk-litellm-master-cmem-2026") def chat(pool, messages, *, doc_id=None, workflow=None, workload=None, max_tokens=1024): headers = { "content-type": "application/json", "authorization": f"Bearer {KEY}", "x-gpumon-pool": pool, "x-gpumon-client-id": "my-pipeline", "x-gpumon-project": "my-pipeline", } if doc_id: headers["x-gpumon-doc-id"] = str(doc_id) if workflow: headers["x-gpumon-workflow-stage"] = workflow if workload: headers["x-gpumon-workload-id"] = workload r = requests.post( f"{INGRESS}/v1/chat/completions", headers=headers, json={"model": pool, "messages": messages, "max_tokens": max_tokens, "temperature": 0.1}, timeout=120, ) r.raise_for_status() return r.json()["choices"][0]["message"]["content"]
curl -s -X POST http://192.168.1.211:4010/v1/chat/completions \ -H "authorization: Bearer sk-litellm-master-cmem-2026" \ -H "content-type: application/json" \ -H "x-gpumon-pool: pool-qwen-large" \ -H "x-gpumon-client-id: smoke-test" \ -H "x-gpumon-project: smoke-test" \ -d '{"model":"pool-qwen-large","messages":[{"role":"user","content":"ok"}],"max_tokens":4}' | jq
from openai import OpenAI client = OpenAI( base_url="http://192.168.1.211:4010/v1", api_key="sk-litellm-master-cmem-2026", default_headers={ "x-gpumon-client-id": "my-pipeline", "x-gpumon-project": "my-pipeline", "x-gpumon-pool": "pool-qwen-large", }, ) client.chat.completions.create( model="pool-qwen-large", messages=[{"role":"user","content":"…"}], extra_headers={"x-gpumon-workflow-stage": "summary-pass1"}, )
Register Project & Workload
Projects are the dashboard's top-level grouping. Register once per logical pipeline — the registration is idempotent. Workloads are optional sub-groupings for long-running batches (EDGAR sweeps, fraud-heuristics passes); use them when you want a single chart row that shows live progress across thousands of calls.
curl -s -X POST http://192.168.1.211:2289/api/projects/register \ -H "content-type: application/json" \ -d '{ "project_id":"my-pipeline", "label": "EDGAR ingest pipeline", "owner": "M.P.", "color": "#80deea" }'
Returns { ok: true, project_id, auth_token }. The auth_token is saved server-side and will gate write endpoints when token enforcement lands. Future calls send it as x-gpumon-project-token.
curl -s -X POST http://192.168.1.211:4010/v1/workloads \ -H "authorization: Bearer sk-litellm-master-cmem-2026" \ -H "content-type: application/json" \ -H "x-gpumon-client-id: edgar-indexer" \ -H "x-gpumon-project: edgar-indexer" \ -d '{ "workload_id": "wl_2026_05_13_edgar_sweep", "description": "Sweep 8k 10-K filings, summarize + extract triples", "expected_calls": 24000, "expected_pools": ["edgar-summarizer-pool","pool-fraud-triples"] }' # When the batch finishes: curl -s -X POST http://192.168.1.211:4010/v1/workloads/wl_2026_05_13_edgar_sweep/complete
Then send x-gpumon-workload-id: wl_2026_05_13_edgar_sweep on every chat call in that batch. The dashboard groups them under a single live row with progress, ttft histogram, and cost-to-date.
Scaling Patterns
The ingress can sustain 5,000+ RPM combined across all NIM pools. Here's how to actually hit that from the caller side.
| Pool | RPM ceiling | Recommended in-flight workers | Use for |
|---|---|---|---|
| pool-qwen-large | 700 | 8–12 | large summarization, structured output |
| pool-qwen-14b | 700 | 8–12 | smaller / faster qwen3-next-80b path |
| pool-vision | 1400 | 16–20 | PDF/image content with accuracy |
| pool-ocr | 700 | 8–12 | cheap nemotron-nano-vl OCR |
| pool-gemma | 2100 | 24–32 | mixed (maverick + gpt-oss + llama-vision) |
| edgar-summarizer-pool | 2400 | 24–32 | edgar heavyweight passes |
| edgar-microtask-pool | 3200 | 32–40 | edgar small one-shots |
// Keep <=8 in flight; let the pool's cooldown logic drop hot keys for you. const N = 8; const queue = docs.slice(); const results: any[] = []; await Promise.all(Array.from({length: N}, async () => { while (queue.length) { const doc = queue.shift()!; results.push(await chat("pool-qwen-large", [...], { doc_id: doc.id, workflow: "summary" })); } }));
- Routing every input through one pool. Vision/OCR pools sit idle while pool-qwen-large piles up 80% timeouts. Use the right tool — see the table above.
- Skipping
x-gpumon-pool. The ingress requires it on chat calls; 400 if missing. - Calling LiteLLM (
:4000) directly. Bypasses cooldown + attribution. Always go through gpumon-ingress. - Vision through
pool-qwen-large. LiteLLM'snvidia_nimprovider stripsimage_url. Usepool-visionorpool-ocr(those hit the nim-passthrough sidecar). - Tight retry loops on 408/429. Every retry burns a key's RPM. Back off exponentially; fall through to a sibling pool after 2 attempts.
- Hardcoding a direct host alias. Local nodes drop in and out. Pools auto-skip cooled members; direct aliases don't.
Recipe: enrich a folder of documents (no DB)
This is a recipe Claude follows to write a one-shot script — not a CLI you install. The script walks a folder, OCRs scanned PDFs through the ingress, summarizes each file, and writes a sidecar JSON next to every document. No queue, no SQLite, no embeddings. Re-running re-processes (or you can add a sidecar-exists short-circuit).
<original>.gpumon.jsonnext to each input file — contains{ title, bluebook, sentence_sum, paragraph_sum, ocr_text, model_used, ms_taken }enrichment.mdin the folder — a single markdown table indexing all sidecars (rebuilt every run)- Nothing else. Original files untouched.
Save as enrich.ts, run with bun enrich.ts <folder> --project <id>. Claude can adapt this template — change pools, swap output format, add/remove enrichment stages — without changing the call shape.
#!/usr/bin/env bun // enrich.ts — walk a folder, OCR + summarize each doc via gpumon-ingress. // Usage: bun enrich.ts <folder> --project <id> [--workers 4] import { readdir, stat, readFile, writeFile } from "node:fs/promises"; import { join, basename, extname } from "node:path"; import { randomUUID } from "node:crypto"; const INGRESS = process.env.GPUMON_INGRESS_BASE ?? "http://192.168.1.211:4010"; const KEY = process.env.LITELLM_KEY ?? "sk-litellm-master-cmem-2026"; const SESSION = randomUUID(); // one session-id per run const [folder, ...args] = process.argv.slice(2); const project = args[args.indexOf("--project") + 1]; const workers = Number(args[args.indexOf("--workers") + 1]) || 4; if (!folder || !project) { console.error("usage: bun enrich.ts <folder> --project <id>"); process.exit(1); } async function chat(pool: string, messages: any[], stage: string, doc_id: string, max_tokens = 1024) { const r = await fetch(`${INGRESS}/v1/chat/completions`, { method: "POST", headers: { "content-type": "application/json", "authorization": `Bearer ${KEY}`, "x-gpumon-pool": pool, "x-gpumon-client-id": "folder-enrich", "x-gpumon-project": project, "x-gpumon-workflow-stage": stage, "x-gpumon-session-id": SESSION, "x-gpumon-doc-id": doc_id, }, body: JSON.stringify({ model: pool, messages, max_tokens, temperature: 0.1 }), }); if (!r.ok) throw new Error(`gpumon ${r.status} ${stage}: ${await r.text()}`); return (await r.json()).choices[0].message.content; } // crude PDF-vs-image detection. For real OCR you'd run pdftotext first; // here we route all PDFs through pool-vision (NIM llama-3.2-90b-vision) // which handles both text and scanned content. async function ocrIfScanned(path: string, doc_id: string): Promise<string> { const ext = extname(path).toLowerCase(); if (![".pdf", ".png", ".jpg", ".jpeg", ".tiff"].includes(ext)) { return await readFile(path, "utf8"); // plain text input } const buf = await readFile(path); const dataUrl = `data:application/${ext.slice(1)};base64,${buf.toString("base64")}`; return await chat("pool-vision", [ { role: "user", content: [ { type: "text", text: "Extract all readable text from this document. Output text only, no commentary." }, { type: "image_url", image_url: { url: dataUrl } }, ]}, ], "ocr", doc_id, 4096); } async function enrichOne(path: string) { const sidecar = `${path}.gpumon.json`; const doc_id = `doc:${basename(path)}`; const t0 = Date.now(); // 1. OCR / text extraction const text = await ocrIfScanned(path, doc_id); const excerpt = text.slice(0, 12_000); // keep prompts cheap // 2. Title + citation in one shot (small model is fine) const meta = JSON.parse(await chat("pool-qwen-14b", [ { role: "system", content: "Return strict JSON. No prose." }, { role: "user", content: `Filename: ${basename(path)}\n\nFirst 12k chars:\n${excerpt}\n\nReturn JSON: { "title": string, "bluebook": string }\nIf the filename follows YYYY-MM-DD-citation.pdf, prefer that citation.` }, ], "title-citation", doc_id, 300)); // 3. Sentence summary const sentence_sum = await chat("pool-qwen-large", [ { role: "user", content: `Summarize this document in one sentence:\n\n${excerpt}` }, ], "sentence", doc_id, 80); // 4. Paragraph summary const paragraph_sum = await chat("pool-qwen-large", [ { role: "user", content: `Summarize this document in one paragraph (max 5 sentences):\n\n${excerpt}` }, ], "paragraph", doc_id, 280); const out = { path, title: meta.title, bluebook: meta.bluebook, sentence_sum: sentence_sum.trim(), paragraph_sum: paragraph_sum.trim(), ocr_text: text, enriched_at: new Date().toISOString(), ms_taken: Date.now() - t0, }; await writeFile(sidecar, JSON.stringify(out, null, 2)); console.log(` ✓ ${basename(path)} ${out.ms_taken}ms`); } // Walk + bounded-concurrency runner async function walk(dir: string): Promise<string[]> { const ents = await readdir(dir, { withFileTypes: true }); const out: string[] = []; for (const e of ents) { const p = join(dir, e.name); if (e.isDirectory()) out.push(...await walk(p)); else if (!e.name.endsWith(".gpumon.json") && !e.name.endsWith(".md")) out.push(p); } return out; } const files = (await walk(folder)).filter(p => [".pdf",".png",".jpg",".jpeg",".tiff",".txt"].includes(extname(p).toLowerCase())); console.log(`Found ${files.length} files; enriching with ${workers} concurrent workers (session=${SESSION.slice(0,8)})`); const queue = files.slice(); await Promise.all(Array.from({ length: workers }, async () => { while (queue.length) { const f = queue.shift()!; try { await enrichOne(f); } catch (e) { console.error(` ✗ ${basename(f)}: ${(e as Error).message}`); } } })); // Single index table for the folder const rows: string[] = ["| File | Title | Citation | Summary |", "|---|---|---|---|"]; for (const f of files) { try { const j = JSON.parse(await readFile(`${f}.gpumon.json`, "utf8")); rows.push(`| ${basename(f)} | ${j.title} | ${j.bluebook} | ${j.sentence_sum} |`); } catch {} } await writeFile(join(folder, "enrichment.md"), rows.join("\n") + "\n"); console.log(`Done. Index: ${join(folder, "enrichment.md")}`);
# prerequisite: project registered once curl -s -X POST http://192.168.1.211:2289/api/projects/register \ -H "content-type: application/json" \ -d '{"project_id":"case-docs","label":"Case docs","owner":"M.P."}' # then run the enrichment bun enrich.ts ~/Documents/case-files --project case-docs --workers 4
- Different pools per stage: swap
pool-qwen-largeforedgar-summarizer-poolif you want NIM-only with more headroom; swappool-visionforpool-ocrif you want cheap nemotron-nano-vl instead of llama-vision. - Skip-if-already-enriched: add a
stat(sidecar)check at the top ofenrichOne— if sidecar exists and is newer than the source file, return early. - More stages: drop in additional
chat()calls (entity extraction, classification, translation) and add the fields to the sidecar JSON. - Different output: replace the sidecar JSON with a single CSV row per file, or a JSONL stream — the call shape is unchanged.
- Cost-aware fallback: wrap
chat()in a try/catch that retries onanthropic / haiku-4-5after 2 failures on the NIM pool. - Resumability: read the existing sidecar JSON files at startup to skip already-completed work — no DB needed.
v1 of this recipe deliberately uses per-file sidecars: each .gpumon.json sits next to its source, makes the work fully visible in ls, survives rsync, and re-running is a no-op if you add the sidecar-exists check. A central SQLite (with FTS5, sqlite_vec, queue, leases) is a separate v1.5 design — see docs/gpumon-v1-design.md for that path. Most folder enrichment tasks don't need it.
Claude Skill: how to install & use
The full operational reference lives in the gpumon Claude skill at ~/.claude/skills/gpumon/SKILL.md. Loading it gives Claude the trigger phrases, pool selection logic, header conventions, scaling patterns, and anti-patterns above — without having to read this whole page.
# mepstudio (this host) — already installed ls ~/.claude/skills/gpumon/SKILL.md # Federation hosts — rsync from mepstudio rsync -a ~/.claude/skills/gpumon/ rooot@spark-1:~/.claude/skills/gpumon/ rsync -a ~/.claude/skills/gpumon/ rooot@spark-2:~/.claude/skills/gpumon/ rsync -a ~/.claude/skills/gpumon/ rooot@node-eleven:~/.claude/skills/gpumon/
/gpumon- "call an LLM" · "litellm" · "ingress" · "x-gpumon-*"
- "pool-vision" · "pool-qwen-*" · "edgar-summarizer-pool" · "register a project"
- How to pick a pool vs a direct alias for a given workload
- The full header contract (required + attribution + session)
- Three-step setup: register project → register workload → make calls
- Concurrency budgets per pool (matching the table above)
- Anti-patterns and how to recover from per-key cooldown / pool exhaustion
- How to add a new local endpoint or a new pool to the system