fix(assistant): accumulate run token usage across turns - #150
Merged
Conversation
The engine overwrote the run's usage on every provider usage event, so a multi-turn run persisted only its final turn. In a tool-using run that is the shortest turn of all, which made the reported token cost of a run arbitrary and far too low: a delegated worker run in this repo's own history logged 11 tool calls but reported 227 input tokens. Track usage per turn instead. Reports inside one turn restate that turn's running counters rather than adding to them (Anthropic sends the prompt count in `message_start` and the cumulative output in `message_delta`), so they collapse field-wise; finished turns are then summed into the run total. `usage` keeps holding that derived total, so the cancel and failure paths continue to report the tokens spent by earlier turns. Also derive `total_tokens` when the provider omits it. Anthropic never sends one and several OpenAI-compatible endpoints (MiniMax among them) do not either, which left runs with input and output counts but no total -- 93 of 93 such runs in this repo's history. The derived total excludes reasoning tokens because both native providers already bill those inside the output count.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
updated_at: 2026-08-05T16:40:00+02:00
summary: PR body and evidence for the run-usage accumulation fix (token accounting)
tags: [clai, usage, tokenomics, pr]
Problem
CLAI reports the token cost of a run, and the number is wrong for every
multi-turn run on the native provider paths.
engine.rsdeclaredusageoutside the agentic turn loop and handled theprovider usage event with a plain assignment:
One provider request is issued per turn, and
openai.rssetsstream_options: { include_usage: true }, so each turn emits its own usagechunk. Each assignment therefore discarded every earlier turn. What got
persisted was the last turn only — which in a tool-using run is the
shortest turn, the one that just writes the closing message.
Evidence from this repo's own history
.clai/data.sqlite, batch-09 worker runs (delegatedworker_taskruns,MiniMax-M3 over the OpenAI-compatible path). Tool calls counted from
assistant_tool_calls, tokens fromassistant_runs.usage_json:The task doing the most work reports nearly the fewest tokens, and no run
reports a plausible figure — a single turn carrying this workspace's system
prompt already exceeds these totals on its own. The numbers are not merely
low, they are uncorrelated with the work done.
A second defect compounded it: 93 of 93 MiniMax runs had no
totalTokensat all, while all 53 runs on the other path had one.openai.rsreadtotal_tokensstraight from the payload with no fallback,and MiniMax's OpenAI-compatible endpoint omits it.
anthropic.rshardcoded
total_tokens: None, so Anthropic runs never had a total either.Fix
Accumulate per turn, then sum turns.
Anthropic, which reports twice per turn:
message_startcarries theprompt count plus a small output preview,
message_deltathe cumulativeoutput. Summing them would double-count the preview.
usagecontinues to hold the derived run total at all times, so theexisting cancel and failure paths report the tokens spent by earlier
turns instead of losing them.
RunUsage::ensure_total()fills a missingtotal_tokensfrominput + output, once per turn after merging. Reasoning tokens are
excluded: both native providers bill them inside the output/completion
count, so adding them again would overstate the total. An authoritative
provider-supplied total always wins.
Deriving the total is done on a copy of the in-flight turn. A total
derived from a partial report must not survive the field-wise merge as if
the provider had sent it — otherwise Anthropic's
message_start(total 1001) would beat the true turn total (1500) under max.
Files
types.rs—merge_turn_report,add_turn,ensure_totalonRunUsage;Defaultderive; 6 tests.engine.rs— per-turn accumulator, fold at turn end, derived runningtotal on each usage event.
Provider parsers are untouched: the derivation lives at the one place that
knows a turn has ended.
Validation
cargo test --lib— 938 passed (6 new).cargo fmt --check,npm run typecheck,npm run lint,git diff --check.Not covered
The fold logic is unit-tested, but the engine wiring is not: there is no
provider-mock harness in the repo, so no test drives
run_session_turnthrough two turns and asserts the persisted total. The wiring is verified
only by compilation and by reading. Building that harness is the natural
follow-up and would protect several other engine behaviours too.
Historical rows in
data.sqlitekeep their wrong values; this fixesaccounting from here on, with no migration.