fix(billing): capture provider usage before the response body is read - #506
Conversation
The two CodeRabbit findings on #503, both verified against the merged code before being taken. #503 was merged without their being addressed; this is the follow-up. ## A rung whose body will not parse still cost money `_log_token_usage` is the single chokepoint that accumulates the run-scoped provider total. It ran AFTER `_build_model_provenance(response)` and `response.choices[0].message` — so a response carrying real usage with an empty or malformed `choices` raised on that read before the accumulation happened. The walk catches it as a transport failure and advances to the next fallback rung, whose usage IS captured, and the run then reports the fallback's tokens while the provider charged for both. The call moves to immediately after `_completion_with_hard_timeout` returns. Nothing it needs comes from the parsed body: it reads only `response.usage` and returns early when that is absent. The mutation test shows the mechanism verbatim: ERROR event=llm_request_end model=minimax/MiniMax-M3 success=False error_type=IndexError error=list index out of range INFO event=llm_fallback_used primary_model=minimax/MiniMax-M3 served_model=zai/glm-5.2 reason=transport_error — two completions billed, one counted. ## The capture disagreed with its own log line Anthropic reports cache reads at the top level; OpenAI nests them under `prompt_tokens_details.cached_tokens`. `_log_token_usage` already read the nested field — ten lines above the `observe` call, for the log string — and passed only the top-level one to the capture. Every OpenAI call therefore recorded `cache_read_input_tokens=0` beside a log line printing the real number. SELECT between the two, never sum: they are the same tokens, and both are sub-buckets of `prompt_tokens`, so adding would double-count the most expensive half of the bill. A third test pins that specifically — the forbidden answer is 1600 where both fields read 800. ## Verified - Three mutations, each caught, each restored and checksum-verified with `shasum -a 256 -c`: restoring the old call order, passing only the top-level cache field, and summing the two cache sources. - OSS unit suite: 5059 passed, 10 skipped, 6 subtests passed. - `uv run ruff check reflexio/ tests/` — clean; `ruff format` applied. - `uv run pyright reflexio/server/llm` — 0 errors, 0 warnings.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Essentials Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review. 📝 WalkthroughWalkthroughThe LLM client now captures nested cached-token counts without double-counting and logs token usage before response parsing. Tests cover cache-token precedence and fallback responses with unreadable primary bodies. ChangesToken usage capture
Priority: ⬇️ Low Estimated code review effort: 3 (Moderate) | ~20 minutes Change: Bug fix Suggested reviewers: Merge Risk: ⚪ Minimal · up to The token-accounting changes have no identified merge-blocking risk. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
fix(billing): capture provider usage before the response body is read
The two CodeRabbit findings on #503, both verified against the merged code
before being taken. #503 was merged without their being addressed; this is the
follow-up.
A rung whose body will not parse still cost money
_log_token_usageis the single chokepoint that accumulates the run-scopedprovider total. It ran AFTER
_build_model_provenance(response)andresponse.choices[0].message— so a response carrying real usage with an emptyor malformed
choicesraised on that read before the accumulation happened.The walk catches it as a transport failure and advances to the next fallback
rung, whose usage IS captured, and the run then reports the fallback's tokens
while the provider charged for both.
The call moves to immediately after
_completion_with_hard_timeoutreturns.Nothing it needs comes from the parsed body: it reads only
response.usageandreturns early when that is absent.
The mutation test shows the mechanism verbatim:
— two completions billed, one counted.
The capture disagreed with its own log line
Anthropic reports cache reads at the top level; OpenAI nests them under
prompt_tokens_details.cached_tokens._log_token_usagealready read thenested field — ten lines above the
observecall, for the log string — andpassed only the top-level one to the capture. Every OpenAI call therefore
recorded
cache_read_input_tokens=0beside a log line printing the real number.SELECT between the two, never sum: they are the same tokens, and both are
sub-buckets of
prompt_tokens, so adding would double-count the most expensivehalf of the bill. A third test pins that specifically — the forbidden answer is
1600 where both fields read 800.
Verified
shasum -a 256 -c: restoring the old call order, passing only the top-levelcache field, and summing the two cache sources.
uv run ruff check reflexio/ tests/— clean;ruff formatapplied.uv run pyright reflexio/server/llm— 0 errors, 0 warnings.Summary by CodeRabbit