Skip to content

Usage metadata is dropped in SSE streaming when a turn ends in a tool call #645

Description

@dkindlund

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

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingneeds review[Status] The PR/issue is awaiting review from the maintainer

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions