Skip to content

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 on GET /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-summary stays tenant-blind (never leak tenant structure externally).
  • Backfill display — a pre-keystone (header-less) TOKEN_USAGE event coalesces to invotek for display, so ?tenantId=invotek includes legacy dashecorp spend. This reuses MartenEventStore.ReadTenantId.
  • All-time per-tenant usage — GET /api/usage with a tenantId and no days scans raw events (not the stored TokenUsageProjection, 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 all token_usage events with no event-type index (same shape as the existing windowed summary/daily scans) — 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=unknown is 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 — HeadersEnabled was 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 event Sequence (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:

["claude-new-model"] = new(inputPer1M, outputPer1M, cacheReadPer1M, cacheCreatePer1M),

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.