Token & Cost Ledger
GET /v1/admin/usage (v0.9.12+) — durable per-role token and dollar accounting, what it measures exactly, what it only bounds, and what it refuses to price.
From v0.9.12 every LLM and embedding call the server makes is recorded in a durable usage
ledger at <data_dir>/usage/ledger.json, one bucket per UTC day, pipeline role, tenant and model.
The counter that existed before was process memory and reset on every restart; historical spend from
before the upgrade cannot be reconstructed, and the ledger does not pretend otherwise. Since
v0.9.13 the same data is on the Admin Console's Observability
page ("Tokens & cost") and as a month-to-date strip on Overview. Verified live on v0.9.13.
The route
GET /v1/admin/usage?from=2026-09-01&to=2026-09-30 # inclusive days, YYYY-MM-DD
GET /v1/admin/usage?period=mtd # month to date; or period=all (since inception). Used only when from/to are absent
GET /v1/admin/usage?role=answer&tenant=org:acme
GET /v1/admin/usage?format=csv # flat per-bucket export for a billing pipelineAn unknown period or role is a 400 (INVALID_PERIOD / INVALID_ROLE). Requires operator access: the
ledger is process-global and spans every tenant on the node.
{
"range": { "from": null, "to": null, "observed_from": null },
"durable": true,
"total": { "calls", "failed_calls", "cached_calls", "incomplete_calls", "unpriced_calls",
"tokens_input", "tokens_output", "tokens_total", "tokens_unsplit",
"cost_usd", "latency_ms_total", "latency_ms_mean" },
"by_day": [ { "day": "2026-09-18", ...same counters } ],
"by_role": [ { "role": "embedding", ... } ],
"by_role_model": [ { "role": "embedding", "model": "text-embedding-3-small", ... } ],
"by_tenant": [ { "tenant": "org:acme", ... } ],
"cost_basis": { "tokens": "measured", "cost_usd": "router_reported", "recompute_from": "by_role_model", "note": "..." },
"instrumentation": { "by_role": [ { "role", "instrumented", "coverage": "full" | "partial" | "none", "covers" } ] },
"enrichment_coverage": { "events_enriched", "events_with_facts", "facts_total", "facts_per_event", "events_failed", ... },
"enrichment_backlog": { "jobs_pending", "jobs_queued_total", "jobs_completed_total" }
}The CSV columns are day, role, tenant, model, calls, failed_calls, cached_calls, incomplete_calls, unpriced_calls, tokens_input, tokens_output, tokens_unsplit, cost_usd, latency_ms. A tenant that
cannot be attributed is written as __unattributed__.
Roles: embedding, entity_extraction, enrichment, enrichment_qa, recall, answer,
verifier, reranker, plus the background lanes (layer_build and the maintenance tick).
entity_extraction and recall are two uses of the one CORTEX_LLM_* router.
Read the tokens, then the dollars
by_role_model carries exact token counts beside the model name. That is the durable record;
cost_usd is a view over it computed from the server's price book, so you can recompute at a corrected
rate, or at your own rate if you bring your own model, without re-running anything. Set
CORTEX_MODEL_PRICES to give an unlisted model a rate.
Pricing before v0.9.12 was wrong; re-read anything you quoted
The enrichment lane billed every model at the cheapest model's rate (roughly seventeen times understated
if you ran gpt-4o there), anything whose name contained opus was billed at the 4.0 rate (roughly
three times overstated for claude-opus-4-6), and an unrecognised model silently took Sonnet's price.
One price book now serves every lane, and an unknown model is reported rather than guessed.
Three marks so the report cannot flatter itself
| Field | Meaning |
|---|---|
unpriced_calls | This build has no rate for that model. The tokens are exact; the dollars are zero because unmeasured, not because free. The UI shows a hatched "unpriced" chip, never $0.00. |
tokens_unsplit | The provider reported one combined number instead of an input/output split (the entity-extraction lane does this). Kept separate on purpose: pricing it at an output rate would overstate that lane several-fold. |
incomplete_calls | While non-zero, the token fields are a floor ("≥ n" in amber in the UI). |
And two roles are declared uninstrumented rather than read as zero: instrumentation.by_role
reports coverage: full | partial | none per role. The reranker records nothing (its interface carries
no token or cost data and its provider bills per search unit), and four small fact-join calls inside
the answer path discard their usage before it can be recorded, so the answer lane is partial.
A window with no answer calls therefore reads "this lane was idle", not "no dollars will ever be
recorded"; v0.9.13 fixed a UI bug that conflated the two. Lanes pointed at an operator-controlled
endpoint (CORTEX_RERANKER_URL, a local model) report 0.0 cost by design.
Enrichment coverage
The same payload carries enrichment_coverage (events enriched, events with facts, facts per event,
failed) and enrichment_backlog (a live gauge of pending jobs), so "is enrichment keeping up?" and
"what does it cost?" are one call. See Self-hosting defaults for what
enrichment is.
Related
- LLM & Answer Generation — the lanes these roles map to.
- Admin Console — Observability → Tokens & cost.
- Benchmarking — latency measurement.