Originally created by: sosidudku1
Follow-up to [#61], which attached the shape of a turn to message_sent (latency_ms, step_count, outcome) and deliberately left token accounting out. This adds the size.
message_sent carries no usage data, so token consumption can only be inferred by multiplying step_count by a hand-calibrated constant. A step that reads a long file and a step that answers "yes" count the same, so the estimate is off by multiples on exactly the turns worth looking at.
Providers already report per-call usage, and the pricing tables already exist in userModels[].pricing. Neither reached the analytics seam.
TurnUsageMeter sums usage across every LLM call a turn makes, the agent loop's own steps plus any sub-runner (reflection, link-gen, vote, rewriter, distill), on both the buffered and streaming paths. Three optional fields land on message_sent:
prompt_tokenscompletion_tokenscost_usdBuckets are keyed by session id. TurnController serializes turns within a session but runs different sessions concurrently (sidecar, Telegram, task runner), so a single shared bucket would bill one session's tokens to whichever session finished first.
Each field is omitted rather than sent as a zero when it cannot be measured. A provider that reports no usage block sends no token keys; a model with no configured pricing sends no cost_usd. Local runners therefore report real token counts with no cost, instead of a misleading 0.00 that would read as "this turn was free" in a chart. An explicit cost_usd: 0 is still emitted when a priced model genuinely cost nothing.
Numbers only. No message content, file paths, tool arguments, or user data, the same boundary [#61] held to. The fields ride the existing analytics opt-out, since nothing is emitted unless captureMessageSent fires, and they stay off first_message_sent, which keeps carrying provider and model alone.
CostAccumulator.recordTurn was being called without model, so estimateCost hit its !model?.pricing guard and returned 0 for every turn. The opt-in cost readout has been reporting a constant zero. It now receives the resolved model, which is what makes cost_usd meaningful, and repairs the accumulator for whenever a reader is wired up to it.
tsc clean.turn-usage-meter.test.ts, including cross-session isolation; 6 added to analytics-events.test.ts covering key-absence and the explicit-zero case).main (TUI/sidecar/tools), none introduced here.main after [#80], [#81] and [#82] landed; merged without conflicts.Real per-user and per-model spend without the step-count multiplier, and the cost-versus-retention question that estimate could not answer. Pricing stops being hardcoded in dashboard SQL and arrives with the event.
Originally posted by: sosidudku1
On the drive-by fix:
CostAccumulatorhas been recording a constant zeroFlagging this separately since it is a pre-existing bug rather than something this PR introduces, and it is worth a decision independent of the telemetry work.
bootstrap.tscalledrecordTurnwith{ modelId, usage }and nomodel:But
estimateCostneeds the model to reach pricing:So the guard fired on every call.
sessionUsd,dayUsdandlastTurnUsdhave all been accumulating zero since the accumulator was wired up, regardless of provider or pricing config.Two things keep this from having been user-visible so far:
costTracking.enabled, which defaults off.snapshot().costTracking.showInStatusBarexists in the config schema, but no reader is wired to it, so the zero never reached a screen.That makes it latent rather than a live misreport, which is the good version of this news. It does mean the accumulator would have shipped zeros the moment a status-bar reader was added.
The fix here is one line of plumbing: resolve the model at the provider seam and pass it through, so both
recordTurnand the newcost_usdfield get real pricing.Two follow-ups I did not do in this PR, happy either way:
CostAccumulatorhas no test file. A test asserting that a priced model yields a non-zerosessionUsdwould have caught this and would stop it regressing. Worth a small separate PR.showInStatusBaris meant to work, it still needs a reader. Separate scope from telemetry, so I left it alone.Ticket changed by: Ooooze