Skip to content

refactor(cheapmodel): Usage() is process-global and its comment still asserts the premise that produced #176 #181

Description

@amiddavid

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 PrefixAskCompletePrefixed, and via fallbackAskComplete), 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions