Cost Attribution¶
rig-conductor tracks per-agent, per-repo LLM costs by accumulating TOKEN_USAGE events emitted by agents after each API call.
Endpoints¶
| Endpoint | Time scope | Source |
|---|---|---|
GET /api/usage (no days) |
All time | Marten projection |
GET /api/usage?days=N |
Rolling N days | Raw events |
GET /api/costs/summary?days=N |
Rolling N days | Raw events |
GET /api/costs/daily?days=N |
Rolling N days, per date and agent | Raw events |
GET /api/costs/daily?days=N&by=repo |
Rolling N days, per date, repo and agent | Raw events |
Rule: to compare /api/usage with /api/costs/summary, always supply the same ?days=N to both. Without it /api/usage returns all-time totals which are naturally larger than any windowed summary.
by=repo adds a repo field to each daily entry and splits each agent's daily cost by repository. Omitting by preserves the existing response shape. /public/cost-summary continues to expose totals per date only, with no repo names.
Multi-tenancy (rc#1460)¶
Cost is attributed per tenant from each event's server-resolved tenant_id header (the multi-tenancy keystone, rc#1459). Tenant #0 is invotek.
?tenantId=filter — optional onGET /api/costs/summary,/api/costs/issue,/api/costs/daily, and/api/usage. It restricts results to events whose display tenant (header, coalesced) equals the value, case-insensitively. Omitting it is byte-for-byte the pre-1460 behavior./public/cost-summarystays tenant-blind (never leak tenant structure externally).- Backfill display — a pre-keystone (header-less)
TOKEN_USAGEevent coalesces toinvotekfor display, so?tenantId=invotekincludes legacy dashecorp spend. This reusesMartenEventStore.ReadTenantId. - All-time per-tenant usage —
GET /api/usagewith atenantIdand nodaysscans raw events (not the storedTokenUsageProjection, which is tenant-blind in P0). The no-tenant all-time path still uses the fast projection. ⚠️ Perf: this is an O(N) type-filtered scan of alltoken_usageevents with no event-type index (same shape as the existing windowedsummary/dailyscans) — fine at P0's modest volume, but prefer a?days=window for large ranges. Phase 1 gives the projection a tenant dimension so all-time per-tenant reads stay on the fast path. tenant=unknownis NOT a queryable tenant. Genuinely unattributable spend surfaces via the alarm below, not via?tenantId=unknown(avoids implying "unknown" is a real tenant).
tenant=unknown alarm (TenantUnknownCostAlerter)¶
A background service posts to Discord #admin (DISCORD_ADMIN_WEBHOOK_URL) when a new TOKEN_USAGE event has real cost but no attributable tenant — i.e. its tenant_id header is absent/empty (TenantAttribution.IsUnknown), which is the inverse of the display coalesce. A literal "invotek" header is attributed and never alarms; only an absent/blank header does. This guards against unattributable spend silently becoming margin leak.
- Skip-to-tip on cold start —
HeadersEnabledwas only turned on at rc#1459, so every pre-keystone event is header-less; the alerter starts at the current event tip so it only ever alarms on new (post-keystone) unattributed events. Post-keystone every append stamps the header, so an absent header means a real bug/bypass. - Gated on
EffectiveCost > 0(zero-cost idle events never alarm) and deduped per eventSequence(Valkey 24h key + in-memory fallback). No-op when the webhook env is unset.
Cost Formula¶
For each TOKEN_USAGE event, the effective cost is computed by MartenCostQuery.EffectiveCost:
if all token counts = 0:
cost = $0 # no LLM call occurred
elif model is known AND (cacheReadTokens > 0 OR cacheCreationTokens > 0):
cost = AnthropicPricing.ComputeCost(model, input, output, cacheRead, cacheCreate)
else:
cost = event.CostUsd # trust agent-reported value (backward compat)
Anthropic Pricing Table¶
Maintained in ConductorE.Core/UseCases/AnthropicPricing.cs. Prices effective 2026-10-21, from Anthropic's pricing documentation, in USD/M tokens:
| Model family | Input | Output | Cache read | Cache create (5 min) |
|---|---|---|---|---|
| claude-fable-5-1 | $10 | $50 | $0.25 | $12.50 |
| claude-fable-5 | $10 | $50 | $1 | $12.50 |
| claude-opus-5-5 | $4 | $20 | $0.20 | $5 |
| claude-opus-5 / claude-opus-4-8 / claude-opus-4-7 / claude-opus-4-6 / claude-opus-4-5 | $5 | $25 | $0.50 | $6.25 |
| claude-opus-4-1 / claude-opus-4 | $15 | $75 | $1.50 | $18.75 |
| claude-sonnet-5-5 | $2 | $10 | $0.10 | $2.50 |
| claude-sonnet-5 | $2 | $10 | $0.20 | $2.50 |
| claude-sonnet-4-6 / claude-sonnet-4-5 / claude-sonnet-4 | $3 | $15 | $0.30 | $3.75 |
| claude-haiku-4-5 | $1 | $5 | $0.10 | $1.25 |
| claude-3-7-sonnet / claude-3-5-sonnet / claude-3-sonnet | $3 | $15 | $0.30 | $3.75 |
| claude-3-5-haiku | $0.80 | $4 | $0.08 | $1 |
| claude-3-opus | $15 | $75 | $1.50 | $18.75 |
| claude-3-haiku | $0.25 | $1.25 | $0.03 | $0.30 |
A model id matches its longest key when what follows the key is nothing, a release date (-20251001) or a variant tag ([1m], @20250929). A newer model in a known family (claude-opus-5-6) is unknown, and so is every non-Anthropic model: its reported CostUsd stands. Claude Haiku 5.5 is not in the table on purpose. It is priced per request by prompt length (over 100,000 tokens costs more), and a TOKEN_USAGE event sums a whole run of requests, so its tier cannot be known: its reported cost stands too.
Unknown models and non-Anthropic models (gpt-*, qwen*, etc.) retain the agent's costUsd, even with cache tokens. TryResolve returns false for unknown models; there is no Sonnet fallback. The zero-token rule still takes precedence.
TOKEN_USAGE Event Fields¶
{
"type": "TOKEN_USAGE",
"agentId": "dev-e-dotnet",
"repo": "dashecorp/rig-conductor",
"issueNumber": 148,
"model": "claude-sonnet-4-5",
"inputTokens": 10,
"outputTokens": 4994,
"cacheReadTokens": 160855,
"cacheCreationTokens": 28927,
"costUsd": 0.242194,
"category": "work"
}
cacheReadTokens and cacheCreationTokens are optional (default 0). When present for a known model, the conductor recomputes cost from the price table, overriding costUsd.
category is one of "work" (default), "idle", or "overhead". It controls which bucket the cost appears in on /api/costs/summary.
Three Bugs Fixed in #148¶
Bug 1 — Endpoint disagreement¶
Before: /api/usage read from an all-time Marten projection; /api/costs/summary queried raw events filtered by the days window. Same agent, different totals.
After: /api/usage?days=N queries the same raw event stream as /api/costs/summary?days=N. Both produce identical totals for the same window.
Bug 2 — Cache tokens ignored¶
Before: Agent-reported costUsd excluded cache token costs. A review with 160 k cache-read tokens was reported as $0.24 when the true cost was ~$0.56.
After: For known models, when cacheReadTokens > 0 || cacheCreationTokens > 0, the conductor recomputes cost using AnthropicPricing.ComputeCost, which adds cache pricing on top of input/output.
Bug 3 — Phantom idle cost¶
Before: TOKEN_USAGE events with inputTokens=0, outputTokens=0 but non-zero costUsd (heartbeat overhead attributed to agents) contributed to idleCostUsd.
After: Any event where all four token counts are zero contributes $0, regardless of the reported costUsd.
Adding a New Model¶
Edit AnthropicPricing.cs and add an entry to the Prices dictionary:
The TryResolve method matches the longest prefix, so dated variants such as "claude-new-model-20270101" are also covered.
Sizing cost levers — the stats endpoint¶
GET /api/execution-logs/stats (see API Reference) is the read-side instrument for the Review-E cost-reduction decision. It returns the turn distribution (p50/p90/p95/max, nearest-rank) and cost/token sums for an agent over a window.
The load-bearing use is sizing --max-turns: a CLI-subprocess agent re-sends its transcript plus BRAIN.md on every turn, so cost compounds with turn count. Set the cap just above the observed p95 of real completed runs — high enough not to truncate legitimate work, low enough to bound a runaway. Read the p95 from this endpoint rather than guessing; a typo'd status or non-positive days returns 400 precisely so a zero p95 can't be mistaken for "low turn usage" and drive the cap down.