Skip to content

fix(assistant): accumulate run token usage across turns - #150

Merged
juacker merged 1 commit into
mainfrom
fix/run-usage-accumulation
Aug 5, 2026
Merged

fix(assistant): accumulate run token usage across turns#150
juacker merged 1 commit into
mainfrom
fix/run-usage-accumulation

Conversation

@juacker

@juacker juacker commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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.rs declared usage outside the agentic turn loop and handled the
provider usage event with a plain assignment:

ProviderEvent::Usage { usage: u } => {
    usage = Some(u);
}

One provider request is issued per turn, and openai.rs sets
stream_options: { include_usage: true }, so each turn emits its own usage
chunk. 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 (delegated worker_task runs,
MiniMax-M3 over the OpenAI-compatible path). Tool calls counted from
assistant_tool_calls, tokens from assistant_runs.usage_json:

Task Tool calls Reported input tokens
T7 11 227
T1 7 464
T6 7 783
T5 5 406
T2 4 429
T4 4 225
T3 3 302
T8 3 385

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
totalTokens at all, while all 53 runs on the other path had one.
openai.rs read total_tokens straight from the payload with no fallback,
and MiniMax's OpenAI-compatible endpoint omits it. anthropic.rs
hardcoded total_tokens: None, so Anthropic runs never had a total either.

Fix

Accumulate per turn, then sum turns.

  • Reports within one turn collapse field-wise (max). This matters for
    Anthropic, which reports twice per turn: message_start carries the
    prompt count plus a small output preview, message_delta the cumulative
    output. Summing them would double-count the preview.
  • Finished turns are added into the run total.
  • usage continues to hold the derived run total at all times, so the
    existing cancel and failure paths report the tokens spent by earlier
    turns instead of losing them.
  • RunUsage::ensure_total() fills a missing total_tokens from
    input + 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.rsmerge_turn_report, add_turn, ensure_total on
    RunUsage; Default derive; 6 tests.
  • engine.rs — per-turn accumulator, fold at turn end, derived running
    total 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_turn
through 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.sqlite keep their wrong values; this fixes
accounting from here on, with no migration.

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.
@juacker
juacker merged commit 75fd6a4 into main Aug 5, 2026
2 checks passed
@juacker
juacker deleted the fix/run-usage-accumulation branch August 5, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant