docs/book/src/ops/cost-tracking.md
ZeroClaw records every priced API call to an append-only ledger,
attributes spend to the originating agent, enforces daily / monthly
budgets, and surfaces the rollup on the dashboard Cost tab. The
pricing rules live in config so operators can edit them without a
rebuild.
This page describes the schema, the lookup pipeline, and the operator
surfaces. The code lives in crates/zeroclaw-config/src/cost/ and
crates/zeroclaw-runtime/src/agent/cost.rs.
Two related sections own the surface. cost covers budget enforcement and recording behavior. cost.rates.*
is the operator-managed rate sheet; every subsection's dotted path mirrors
the matching providers.* path with the trailing <alias> segment
replaced by the upstream resource being priced.
A [providers.models.anthropic.<alias>] entry is keyed by an operator-chosen
alias (glados, production) that follows the alias validator: lowercase
ASCII, single underscores, no hyphens. A [cost.rates.providers.models.anthropic.<resource>]
entry is keyed by the upstream model id as it appears in usage telemetry
(claude-opus-4-7, gpt-4o-mini, whisper-1): those id strings come from
the provider's namespace and almost always contain hyphens.
The schema marks every rate-sheet HashMap with #[resource_key] (in
crates/zeroclaw-macros/src/lib.rs). That attribute opts the field out of
validate_alias_key in create_map_key / rename_map_key, so the
gateway's POST /api/config/map-key accepts hyphenated ids. Without it,
create_map_key rejects every realistic model id and the rate-sheet UI
falls flat. Aliases and resource ids share the on-disk structure
(HashMap<String, T>) but they're different naming systems with different
validators.
The per-provider-type slots under [cost.rates.providers.models.<type>],
[cost.rates.providers.tts.<type>], and [cost.rates.providers.transcription.<type>]
expand from the same macros that drive the [providers.*] slot wrappers:
// crates/zeroclaw-config/src/providers.rs
for_each_model_provider_slot!(emit_model_cost_rates_struct);
for_each_tts_provider_slot!(emit_tts_cost_rates_struct, super::schema::TtsCostRates);
for_each_transcription_provider_slot!(emit_transcription_cost_rates_struct, super::schema::TranscriptionCostRates);
Adding a new model provider type is one row in for_each_model_provider_slot!;
the rate-sheet slot, the provider config slot, and the dashboard dropdowns
all expand from it. No hand-typed dispatch tables, no parallel string lists
on the frontend.
The pipeline from [cost.rates.*] to a recorded cost_usd value is:
Orchestrator startup builds the pricing map. When the channels
supervisor instantiates a runtime context for an agent it walks
config.cost.rates.providers.models.iter_entries() and merges the
rates into a HashMap<provider_type, HashMap<key, f64>> where key
is "<model_id>.input", "<model_id>.output", or
"<model_id>.cached_input". The legacy per-alias
[providers.models.<type>.<alias>].pricing table is merged in too;
[cost.rates.*] wins on conflict because it's the forward-looking
surface.
(See crates/zeroclaw-channels/src/orchestrator/mod.rs,
the closure under cost_tracking: CostTracker::get_or_init_global(...).map(|tracker| ...).)
Recording inside the agent loop. Every successful LLM response
reaches record_tool_loop_cost_usage(provider_name, model, usage)
in crates/zeroclaw-runtime/src/agent/cost.rs. The function pulls
the pricing map slot for provider_name, calls resolve_rates(map, model), multiplies by token counts, and stores a CostRecord via
the global CostTracker.
resolve_rates_opt tries the model id first, then the path-suffix
form for provider/model strings (so anthropic/claude-opus-4-7
degrades to claude-opus-4-7 if the operator stored only the
short form). It returns one Option per dimension so any dimension
the operator left unconfigured can be filled from the live-pricing
fallback (see below) before billing. Only when both config and the
live fallback leave input and output at 0.0 does the one-shot
missing_pricing warn fire, so genuine "we couldn't price this"
records still surface in logs.
CostTracker is a process-global singleton (OnceLock in
crates/zeroclaw-config/src/cost/tracker.rs). Reload applies the
latest CostConfig to the existing tracker, and if cost tracking
was disabled at boot, a later reload with cost.enabled = true
constructs the tracker on demand. The orchestrator's pricing map is
also rebuilt on every daemon reload from the live config, so rate
edits take effect on the next request after reload.
Operators don't have to hand-maintain a rate for every model. A provider can
opt into pulling token prices straight from its own gateway by setting
live_pricing = true on that provider block (alongside its existing api_key
and model settings); the prices come from the gateway's own /models listing.
Behavior:
/models endpoint
(the same one onboarding uses to list models) is parsed for per-model pricing.
Gateways that publish prices there report them as per-token decimal strings
(OpenRouter's and Kilo's pricing{prompt,completion,...}), which are scaled
to USD per 1M tokens. A gateway whose /models listing carries no pricing at
all (such as opencode zen, which lists model ids only) is covered by the
models.dev fallback below. No second copy of the endpoint URL or credentials:
they are read from the provider's existing config./models listing at all, such as a subprocess gateway like
kilocli) falls back to the public models.dev
catalog (api.json), keyed by the family's models.dev name (see
catalog_source_for in crates/zeroclaw-providers/src/catalog.rs). The
fallback catalog is fetched fresh on each refresh cycle, so both sources
track upstream price changes on the same hourly cadence.[cost.rates] / pricing entry for. A configured rate (including a
deliberate 0.0) is never overridden. This is a gap-filler, not a
replacement./models fetch; from that response only each opted-in
alias's own configured model is filled, not every model the gateway lists.live_pricing = true there is no
refresher task and no network traffic; behavior is identical to a build
without the feature. Turning the last flagged provider off at runtime
(config reload) clears the snapshot on the next refresh cycle, so live
prices stop filling without a restart. The snapshot lives only in
zeroclaw_providers::pricing (see crates/zeroclaw-providers/src/pricing.rs);
it is read by record_tool_loop_cost_usage and spawned once from the
channels supervisor and the gateway startup.Like [cost.rates], a live price only affects requests made after the snapshot
is populated; there's no retroactive repricing of past records.
CostTracker::record_usage_with_agent appends one CostRecord per
priced response to <workspace>/state/costs.jsonl, one JSON object
per line. The file is read on startup to seed daily_records() so
the dashboard's per-agent rollup survives restarts.
cost_usd is computed at record time from the rate sheet in effect
at that moment. Records are immutable: if the operator adds
rates after some requests have already been recorded, those existing
records keep cost_usd = 0. Only requests made after the rate is
configured (and the daemon reloaded so the orchestrator's pricing
map rebuilds) carry a non-zero cost.
This is the most common surprise after first enabling the rate sheet. The fix is to wait for new requests; there's no retroactive repricing.
CostConfig::enforcement.mode decides what happens when a projected
cost would push daily_total or monthly_total past the configured
limit:
warn: the default; record the event with a warn-level log and
let the request through.block: refuse the request with a BudgetExceeded error.route_down: substitute route_down_model (a cheaper
alternative) for the original model. The substitution happens before
the request is dispatched.allow_override = true lets a request bypass block by passing an
override token on the CLI (zeroclaw --override). Defaults to
false. warn_at_percent controls when the gateway surfaces a
warning banner ahead of the hard limit; defaults to 80%.
When cost.track_per_agent is true (default) every recorded
CostRecord carries the originating agent alias. The dashboard's
Spend by agent panel and GET /api/cost?agent=<alias> consume
this field. Setting track_per_agent = false is an optimization for
high-volume installs where the extra HashMap aggregation shows up in
profiles; the trade-off is losing the per-agent dimension everywhere.
/config/cost → Limits tab: every flat [cost].* field
(enabled, limits, enforcement, track_per_agent). Rate-sheet rows
are not edited here, they're tied to the provider that owns the
model, so they live one tier down./config/providers.<category>/<type> → Costs tab: rate-sheet
editor for that provider type. The + Add input suggests upstream
resource ids drawn from providers.<category>.<type>.*.model
across configured aliases, so the operator can one-click a rate row
for every model they've actually bound. This is the only entry
point for editing [cost.rates.providers.<category>.<type>.*].The dashboard's Cost tab shows three panels plus a Window picker (today / last 7 days / last 30 days / this month / all time):
costs.jsonl.<window>: per-agent rollup over the picked
window. Visible when track_per_agent is true.<window>: per-model rollup. Each row's model
id is clickable; the click resolves the owning provider type from
configured aliases and navigates to that provider's Costs tab. When
the model id isn't bound to any configured provider the click is a
no-op (there's no qualified rate-sheet route for an orphan model).GET /api/cost: current CostSummary (matches the dashboard's
Cost overview shape). Add ?agent=<alias> for a single-agent view.GET /api/config/templates: every map-keyed section the schema
registers, used by the Rates tab's category × provider-type
dropdowns.POST /api/config/map-key?path=cost.rates.providers.<category>.<type>&key=<resource>
create a new rate row. The path is rejected if no such map
section exists; the resource key passes #[resource_key] instead
of validate_alias_key.Dashboard shows $0.0000 for all agents after configuring rates.
Old records are immutable, they were recorded with cost_usd = 0
because no rate was set when they happened. Make a new chat request
after the daemon reload and check Cost overview > Session plus
Spend by model; both should populate for the new request.
Drift detected against cost.rates.* paths after save. A pre
v0.8.0 daemon mangled hyphenated HashMap keys in the dirty-save path,
silently dropping every write to the rate sheet. If you see this on
v0.8.0+ it's a real bug: the dirty-path resolution lives in
crates/zeroclaw-config/src/schema.rs::apply_dirty_path; file an
issue with the daemon version and the path that drifted.
missing_pricing warns spam the log. Emitted once per
(provider_type, model) pair when resolve_rates returns (0.0, 0.0). Either the rate isn't configured for that model, or the
upstream returned a different model id than what's in the rate
sheet (some providers return versioned ids like
claude-3-5-sonnet-20241022 even when you configured
claude-3-5-sonnet). Add the exact id the warn names, or set the
unversioned id and rely on resolve_rates's suffix-match path.