Summary
In SSE streaming mode, usageMetadata never reaches an Event for any turn that ends in a function call. Token counts read as zero, so anything downstream doing cost tracking, budget enforcement, or quota accounting sees a free run.
It fails silently by construction: "no usage reported" and "usage reported as zero" are the same value once a number reaches a cost gate, so nothing downstream can tell them apart.
Full disclosure: I authored #485, which is one of the three changes that compose into this. This is a follow-up, not a complaint — I think each change was individually right and the interaction was genuinely hard to see.
Affected versions
Present in 1.4.0, 1.5.0, 1.6.0 and on main (aee56e07a). dist/esm/utils/streaming_utils.js is byte-identical across those releases apart from one import-path move.
1.3.0 and earlier delivered the usage correctly.
Where it goes
LlmAgent.postprocess() — core/src/agents/llm_agent.ts:975-981:
// If no model response, skip.
if (
(!llmResponse.content || llmResponse.content.parts?.length === 0) &&
!llmResponse.errorCode &&
!llmResponse.interrupted
) {
return; // no Event created; usageMetadata discarded
}
StreamingResponseAggregator.close() returns content: undefined for a turn that accumulated no parts, and errorCode is undefined because finishReason === STOP. The guard matches, the response is skipped, and the usage it carried goes with it.
streaming_utils.ts is not at fault. It does the right thing — close() deliberately preserves trailing usage metadata, and says so in a comment. The response is discarded one layer up.
Why only tool-calling turns
With NonProgressiveStrategy (the default — PROGRESSIVE_SSE_STREAMING is defaultOn: false), non-text parts are yielded immediately and the accumulated text is cleared, so a turn ending in a function call has nothing left by close().
| turn shape |
usage reaches an Event? |
| text-only + trailing usage-only STOP chunk |
yes — text accumulated, so close() has content |
ends in a functionCall + trailing usage-only STOP chunk |
no — lost |
text + functionCall + trailing usage-only chunk |
no — lost |
| single chunk carrying both parts and usage |
yes |
For a tool-using agent that is nearly every turn.
Observed impact
From a production pipeline running both models over 60 days:
| model |
successful sessions |
reporting zero tokens |
gemini-2.5-pro |
202 |
0 |
gemini-3.5-flash |
3 |
3 |
gemini-3.5-flash reported 0 input, 0 output and $0.00 on 7/7 sessions, including every one that completed successfully. gemini-2.5-pro on the identical code path is unaffected, because it carries usage on a parts-bearing chunk rather than a trailing usage-only one.
How it arose
Three individually-correct changes, all first shipped in 1.4.0:
| PR |
merged |
effect |
| #426 |
2026-06-17 |
added the empty-STOP-chunk early return. Its description correctly noted metadata was still delivered via close() — true when written |
| #450 |
2026-07-06 |
added || llmResponse.content.parts?.length === 0 to the postprocess guard |
| #485 |
2026-07-14 |
made close() emit content: undefined, so the !llmResponse.content arm now matches |
#485 fixed a real problem: the empty-parts model turn poisoned session history and Vertex rejected the next request with HTTP 400 (#21, #22), breaking multi-turn tool sessions. Composed, these turned a loud failure into a silent one.
Suggested fix
The constraint is that a metadata-only response must reach an Event without reintroducing content: { parts: [] } into session history — the exact thing #485 removed. A plain revert re-breaks #21/#22.
The narrowest change is to let the guard fall through when the response carries usage metadata:
if (
(!llmResponse.content || llmResponse.content.parts?.length === 0) &&
!llmResponse.errorCode &&
!llmResponse.interrupted &&
!llmResponse.usageMetadata // <-- keep usage-only responses
) {
return;
}
This preserves #485's invariant — content stays undefined, so no empty-parts content enters session history — while giving the usage a path to an Event. Consumers keying on content are unaffected; consumers reading usageMetadata start seeing it again.
Happy to send this as a PR with tests if the approach looks right.
Second, related defect (separate, milder)
streaming_utils.ts has two assignments to this.usageMetadata; the early-return one is guarded, the main one is not:
// guarded
if (llmResponse.usageMetadata) { this.usageMetadata = llmResponse.usageMetadata; }
// UNGUARDED
this.usageMetadata = llmResponse.usageMetadata;
A later chunk carrying no usage therefore erases usage that arrived on an earlier chunk.
adk-python fixed exactly this in aebb2a13b (2026-08-05, "fix(models): keep streamed usage metadata when a later chunk reports none"):
- self._usage_metadata = llm_response.usage_metadata
+ if llm_response.usage_metadata:
+ self._usage_metadata = llm_response.usage_metadata
The JS aggregator still has the unguarded form at the equivalent site.
This one is milder — in the text-only case the count still reaches an Event via an earlier partial response — so it becomes independently fatal only alongside a tool-call turn, where the drop above is already binding. Mentioning it here since it is the same subsystem and has a ready precedent.
Notes on evidence
- The per-turn table is derived from ADK's code given the chunk shapes we observe, reproduced against 1.4.0 and 1.6.0 with synthetic chunks.
- The session table is direct measurement from production traffic.
- The
adk-python comparison is from the maintainers' own commit, not our measurement.
Related
#21, #22, #425, #389, #344, #289
Summary
In SSE streaming mode,
usageMetadatanever reaches anEventfor any turn that ends in a function call. Token counts read as zero, so anything downstream doing cost tracking, budget enforcement, or quota accounting sees a free run.It fails silently by construction: "no usage reported" and "usage reported as zero" are the same value once a number reaches a cost gate, so nothing downstream can tell them apart.
Full disclosure: I authored #485, which is one of the three changes that compose into this. This is a follow-up, not a complaint — I think each change was individually right and the interaction was genuinely hard to see.
Affected versions
Present in 1.4.0, 1.5.0, 1.6.0 and on
main(aee56e07a).dist/esm/utils/streaming_utils.jsis byte-identical across those releases apart from one import-path move.1.3.0 and earlier delivered the usage correctly.
Where it goes
LlmAgent.postprocess()—core/src/agents/llm_agent.ts:975-981:StreamingResponseAggregator.close()returnscontent: undefinedfor a turn that accumulated no parts, anderrorCodeisundefinedbecausefinishReason === STOP. The guard matches, the response is skipped, and the usage it carried goes with it.streaming_utils.tsis not at fault. It does the right thing —close()deliberately preserves trailing usage metadata, and says so in a comment. The response is discarded one layer up.Why only tool-calling turns
With
NonProgressiveStrategy(the default —PROGRESSIVE_SSE_STREAMINGisdefaultOn: false), non-text parts are yielded immediately and the accumulated text is cleared, so a turn ending in a function call has nothing left byclose().close()has contentfunctionCall+ trailing usage-only STOP chunkfunctionCall+ trailing usage-only chunkFor a tool-using agent that is nearly every turn.
Observed impact
From a production pipeline running both models over 60 days:
gemini-2.5-progemini-3.5-flashgemini-3.5-flashreported 0 input, 0 output and $0.00 on 7/7 sessions, including every one that completed successfully.gemini-2.5-proon the identical code path is unaffected, because it carries usage on a parts-bearing chunk rather than a trailing usage-only one.How it arose
Three individually-correct changes, all first shipped in 1.4.0:
close()— true when written|| llmResponse.content.parts?.length === 0to thepostprocessguardclose()emitcontent: undefined, so the!llmResponse.contentarm now matches#485 fixed a real problem: the empty-parts model turn poisoned session history and Vertex rejected the next request with HTTP 400 (#21, #22), breaking multi-turn tool sessions. Composed, these turned a loud failure into a silent one.
Suggested fix
The constraint is that a metadata-only response must reach an
Eventwithout reintroducingcontent: { parts: [] }into session history — the exact thing #485 removed. A plain revert re-breaks #21/#22.The narrowest change is to let the guard fall through when the response carries usage metadata:
This preserves #485's invariant —
contentstaysundefined, so no empty-parts content enters session history — while giving the usage a path to an Event. Consumers keying oncontentare unaffected; consumers readingusageMetadatastart seeing it again.Happy to send this as a PR with tests if the approach looks right.
Second, related defect (separate, milder)
streaming_utils.tshas two assignments tothis.usageMetadata; the early-return one is guarded, the main one is not:A later chunk carrying no usage therefore erases usage that arrived on an earlier chunk.
adk-pythonfixed exactly this inaebb2a13b(2026-08-05, "fix(models): keep streamed usage metadata when a later chunk reports none"):The JS aggregator still has the unguarded form at the equivalent site.
This one is milder — in the text-only case the count still reaches an Event via an earlier partial response — so it becomes independently fatal only alongside a tool-call turn, where the drop above is already binding. Mentioning it here since it is the same subsystem and has a ready precedent.
Notes on evidence
adk-pythoncomparison is from the maintainers' own commit, not our measurement.Related
#21, #22, #425, #389, #344, #289