Symptom
internal/cheapmodel/usage.go tracks token usage process-globally, and its doc comment asserts the premise that produced #176:
// Usage tracks cumulative token usage of the cheap (config-source) model across
// all NeedsModel component calls in this process. […] Process-global (there is a single
// cheap model per proxy); per-component attribution would need the Model interface to
// carry a label, a deferred refinement — today the LLM component in a config is extract,
// so the global total is that component's cost.
"Today the LLM component in a config is extract" has not been true since the cold-transcript sweep became its own component. Three components now call models through these clients — extract_llm, extract_llm_sweep (via PrefixAsk → CompletePrefixed, and via fallbackAsk → Complete), summarize, and agentdiet — and every one of them lands in the same llmCalls / llmInputTokens / llmOutputTokens / llmCacheWrite / llmCacheRead atomics. So Usage() is not "that component's cost"; it is "every cheap-model call in the process, priced later through one rate card".
That is what made /stats' extraction_cost_usd wrong in two directions at once (#176): it over-attributed summarize's and agentdiet's spend to extraction, and it mispriced the sweep, whose asks go to the request's own frontier model while the card is haiku's.
What #178 established, and why deferring is safe
Nothing user-visible now rests on the stale premise unnoticed. #178 moved extraction cost onto per-component recording: each component prices its own calls from ModelCall.CostUSD, at the rates of the model it actually addressed (metrics.RecordExtractionSpend). cheapmodel.Usage() survives only as a labelled fallback — /stats uses it when no component priced anything, and says so via cost_source: host_total, whose documented meaning is explicitly "the host's process-global cheap-model spend. A SUPERSET: it also carries summarize and agentdiet, and prices everything through one rate card." Under partial pricing the aggregate additionally names unpriced_components.
So the wrongness is now declared at the point of use rather than silently inherited. The comment is wrong about the world rather than about the code, and a reader of /stats is told which regime they are in. That is the reason this is a refactor rather than a defect, and the reason it can wait.
What remains is that Usage() still cannot answer "what did component X spend", so any future consumer that wants that has to either re-derive it per component (as extraction now does) or be misled by the comment.
What a fix looks like
The refinement the comment itself defers: give the Model interface a way to carry the calling component's identity, so recordUsageCache can bucket by it — the same shape #178 applied to metrics/extract.go, where the recorders take a component name and the aggregate is derived by summing rather than maintained alongside.
Two constraints from #178's experience:
Usage() and CacheUsage() keep their exact signatures. /stats' llm_calls, llm_input_tokens and llm_output_tokens are read by deploy/harbor/*.py, and the per-component breakdown must be additive.
- The per-call
Sink already nests and reaches every ancestor (WithCallSink), and extract_llm plus extract_llm_sweep's fallbackAsk both use it to attribute one call. A component label on the client would make that the default rather than something each call site opts into.
Correct the comment as part of the fix, or ahead of it. A stale premise stated as a design rationale is how #176 survived review: the comment read as a decision someone had made, not as an assumption that had expired.
Provenance
Raised in review of #178 and deferred there. #178 removed the user-visible consequence; this is the underlying scoping.
Symptom
internal/cheapmodel/usage.gotracks token usage process-globally, and its doc comment asserts the premise that produced #176:"Today the LLM component in a config is extract" has not been true since the cold-transcript sweep became its own component. Three components now call models through these clients —
extract_llm,extract_llm_sweep(viaPrefixAsk→CompletePrefixed, and viafallbackAsk→Complete),summarize, andagentdiet— and every one of them lands in the samellmCalls/llmInputTokens/llmOutputTokens/llmCacheWrite/llmCacheReadatomics. SoUsage()is not "that component's cost"; it is "every cheap-model call in the process, priced later through one rate card".That is what made
/stats'extraction_cost_usdwrong in two directions at once (#176): it over-attributedsummarize's andagentdiet's spend to extraction, and it mispriced the sweep, whose asks go to the request's own frontier model while the card is haiku's.What #178 established, and why deferring is safe
Nothing user-visible now rests on the stale premise unnoticed. #178 moved extraction cost onto per-component recording: each component prices its own calls from
ModelCall.CostUSD, at the rates of the model it actually addressed (metrics.RecordExtractionSpend).cheapmodel.Usage()survives only as a labelled fallback —/statsuses it when no component priced anything, and says so viacost_source: host_total, whose documented meaning is explicitly "the host's process-global cheap-model spend. A SUPERSET: it also carriessummarizeandagentdiet, and prices everything through one rate card." Under partial pricing the aggregate additionally namesunpriced_components.So the wrongness is now declared at the point of use rather than silently inherited. The comment is wrong about the world rather than about the code, and a reader of
/statsis told which regime they are in. That is the reason this is a refactor rather than a defect, and the reason it can wait.What remains is that
Usage()still cannot answer "what did component X spend", so any future consumer that wants that has to either re-derive it per component (as extraction now does) or be misled by the comment.What a fix looks like
The refinement the comment itself defers: give the
Modelinterface a way to carry the calling component's identity, sorecordUsageCachecan bucket by it — the same shape #178 applied tometrics/extract.go, where the recorders take a component name and the aggregate is derived by summing rather than maintained alongside.Two constraints from #178's experience:
Usage()andCacheUsage()keep their exact signatures./stats'llm_calls,llm_input_tokensandllm_output_tokensare read bydeploy/harbor/*.py, and the per-component breakdown must be additive.Sinkalready nests and reaches every ancestor (WithCallSink), andextract_llmplusextract_llm_sweep'sfallbackAskboth use it to attribute one call. A component label on the client would make that the default rather than something each call site opts into.Correct the comment as part of the fix, or ahead of it. A stale premise stated as a design rationale is how #176 survived review: the comment read as a decision someone had made, not as an assumption that had expired.
Provenance
Raised in review of #178 and deferred there. #178 removed the user-visible consequence; this is the underlying scoping.