Uh oh!
There was an error while loading. Please reload this page.
fix: record token usage for streaming generations - #362
Merged
Conversation
A streamed generation reported zero tokens, zero cost, and no context
pressure estimate. Three things had to be true at once for the usage to
survive, and none of them were:
1. `api_prompt_execute` consumes the stream and returns nil, so there is no
response body for `resolve_prompt` to read usage from — the non-streaming
path's only source.
2. Chat Completions emits usage on a final chunk, and only when the request
sets `stream_options: {include_usage: true}`. We never asked, so the
chunk never came. `api_stream_usage_parameters` is a provider hook rather
than a hardcoded flag: providers that report unconditionally, or not at
all, are unaffected by the default empty hash.
3. That chunk arrives *after* `content.done`, which is where the OpenAI chunk
handler called `process_prompt_finished` — building the response from a
usage_stack the usage had not reached yet. Completion is now deferred to
`stream_finished!`, which the base provider calls once the stream drains,
and its result is returned from `resolve_prompt` so the generation (and
any tool-call recursion inside it) still runs exactly once.
`record_stream_usage` also converts the payload before handing it to
`Usage.from_provider_usage`, which early-returns on anything that is not a
Hash: the stainless gems hand back model objects like
OpenAI::Models::CompletionUsage, and an unconverted object was silently
dropped. All-zero and nil payloads are ignored so the `usage: null` on
ordinary content chunks does not add empty entries to a stack that is
summed with `reduce(:+)`.
The usage chunk carries an empty `choices` array, so the handler reads usage
before dereferencing `choices.first`.
Verified against OpenRouter (anthropic/claude-sonnet-4.5): streaming went
from `usage=nil` to input=14 output=4 total=18, matching the same prompt
issued non-streaming. A real 30-page document-enrichment batch now reports
475/72 rather than 0/0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>TonsOfFun
marked this pull request as ready for review
August 14, 2026 23:49
CI was red on 14 tests. Three causes:
Streaming lifecycle (OpenAI Chat, Gemini, Ollama). Completion is now
deferred from content.done to stream_finished!, so the close event fires
when the stream drains rather than on content.done. The tests drive
chunks by hand and never drained, so no :close was ever emitted. They
now call stream_finished! after the done event, and the OpenAI one
asserts that content.done alone does not close — that is the new
contract, not an incidental detail.
Request building (OpenAI Chat, OpenRouter, Ollama). Streamed requests
now carry stream_options: {include_usage: true}; the expected bodies and
the recorded cassette requests did not. Both updated.
The suite's own new test asserted that a default provider asks for no
extra streaming parameters, against a mock that overrode exactly that
method to ask for them. The override served no purpose — the mock's
fake execute ignores the parameters — so it is gone. The Chat opt-in is
asserted against a real serialized request instead (see below), which
also drops that file's undeclared dependency on the openai gem.
Fixing the request-building tests surfaced a defect worth its own note:
usage payloads were pushed onto a stack summed with reduce(:+), but a
streamed usage is a running total for the turn, not a delta. Chat
Completions sends exactly one, so this was invisible — while Gemini's
OpenAI-compatible endpoint, which now opts in by inheritance, repeats a
cumulative usage on every chunk. A four-chunk reply reported 50 input
tokens instead of 14. The turn now keeps a single entry that later
payloads replace, reset per resolve_prompt so tool-calling turns still
accumulate.
Adds test/providers/open_ai/chat/streaming_usage_test.rb, which drives
a real SSE body through the gem's stream helper — the one path CI never
exercised. Before the PR, the usage chunk's empty choices array crashed
the chunk handler outright (NoMethodError on nil.index), so it covers
the nil guard, the recorded usage, the deferred close firing once, the
opt-in on the wire, and a Gemini-shaped stream that repeats its usage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G8CBDTo2dd9Y4mYn2TDu5PUh oh!
There was an error while loading. Please reload this page.
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 freeto 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.
The bug
Any generation with
stream: truereports zero tokens, zero cost, and no context-pressure estimate. Non-streaming is unaffected.The visible symptoms are all downstream of one missing number: the dashboard shows
Tokens 0andTotal Cost $0.00beside a run that plainly made an API call, and the trace detail's Context Pressure bar does not render at all — its condition requires a span with non-zero tokens.Why it happens
Three things had to be true at once for the usage to survive. None of them were.
There is no response body to read.
api_prompt_executeconsumes the stream and returnsnil, soresolve_prompt— whose only source of usage is the response — has nothing to push ontousage_stack.The provider was never asked for usage. Chat Completions emits usage on a final chunk, and only when the request sets
stream_options: {include_usage: true}. Rather than hardcode the flag, this adds anapi_stream_usage_parametershook that defaults to{}— providers that report unconditionally, or not at all, are unaffected.The response was built before the usage arrived. That final chunk lands after
content.done, which is exactly where the OpenAI chunk handler calledprocess_prompt_finished. Completion is now deferred tostream_finished!, which the base provider calls once the stream drains. Its result is returned fromresolve_promptso the generation — and any tool-call recursion inside it — still runs exactly once.There was also a silent conversion failure:
Usage.from_provider_usageearly-returns on anything that is not a Hash, and the stainless gems hand back model objects (OpenAI::Models::CompletionUsage).record_stream_usagenow converts before delegating, ignores all-zero and nil payloads so theusage: nullon ordinary content chunks does not add empty entries to a stack summed withreduce(:+), and reads usage before dereferencingchoices.firstbecause the usage chunk carries an emptychoicesarray.Verification
Against OpenRouter (
anthropic/claude-sonnet-4.5):usage=nilinput=14 output=4 total=18input=14 output=40/0475/72The streaming and non-streaming numbers now agree for an identical prompt, which is the property that matters.
Downstream, in a self-hosted dashboard: per-agent token counts and cost populate, and the Context Pressure bar renders.
Tests
test/providers/base_provider_stream_usage_test.rbcovers usage on a final chunk, model-object conversion, the all-zero/nil guard, the Chat Completions opt-in, the empty default for other providers, and that a deferred completion runsprocess_prompt_finishedexactly once.ActiveSupport::MessageEncryptor::InvalidMessage, no master key), which breaks existing tests on a clean tree too. Relying on CI here.Risk
Touches the shared streaming path for every provider. The deferral is opt-in per chunk handler:
stream_completion_pendingis only set by the OpenAI Chat handler, andstream_finished!is a no-op for handlers that still finish inline, so Anthropic/Gemini/Ollama/Bedrock behaviour is unchanged.🤖 Generated with Claude Code