Skip to content

Commit 8e386a9

Browse files
zhuhongdcopybara-github
authored andcommitted
fix: inherit usage_metadata when after_model_callback replaces response
Merge #7449 Fixes #7451 PiperOrigin-RevId: 996012278
1 parent 7d56ef8 commit 8e386a9

2 files changed

Lines changed: 104 additions & 8 deletions

File tree

‎src/google/adk/flows/llm_flows/core/_finalizer.py‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
from __future__ import annotations
1818

19+
from typing import Any
1920
from typing import AsyncGenerator
2021
from typing import Optional
2122

@@ -169,24 +170,33 @@ async def handle_before_model_callback(
169170
return None
170171

171172

172-
def _inherit_unset_streaming_fields(
173+
def _inherit_unset_model_fields(
173174
original: LlmResponse, replacement: Optional[LlmResponse]
174175
) -> Optional[LlmResponse]:
175-
"""Carries streaming-control fields from a replaced response.
176+
"""Carries model-owned fields from a replaced response.
176177
177178
A callback replacement that leaves ``partial``/``turn_complete`` unset must
178179
not change the streaming semantics of the response it replaces. Otherwise
179180
every streamed delta looks final downstream: SSE clients render N final
180181
responses, ``Runner`` persists each delta as a separate session event, and
181-
the live path can close its request queue early. An explicitly set value on
182-
the replacement is always respected.
182+
the live path can close its request queue early.
183+
184+
``usage_metadata`` is carried the same way. It measures the model call
185+
(prompt, candidate and cached token counts as billed by the provider), not
186+
the content of the response, so replacing the content does not change it.
187+
A replacement that leaves it unset would otherwise erase the call from
188+
token accounting: the finalized ``Event`` copies only non-None fields, so
189+
the persisted session event, the BigQuery analytics plugin and A2A
190+
converters all see no usage for that call.
191+
192+
An explicitly set value on the replacement is always respected.
183193
184194
Args:
185195
original: The response being replaced.
186196
replacement: The callback-provided response, if there is one.
187197
188198
Returns:
189-
The replacement, with unset streaming-control fields filled in, or None
199+
The replacement, with unset model-owned fields filled in, or None
190200
when there is no replacement. A copy is returned only when a field
191201
actually needs filling, so explicitly complete replacements keep their
192202
identity.
@@ -198,11 +208,13 @@ def _inherit_unset_streaming_fields(
198208
replacement, LlmResponse
199209
):
200210
return replacement
201-
updates = {}
211+
updates: dict[str, Any] = {}
202212
if replacement.partial is None and original.partial is not None:
203213
updates['partial'] = original.partial
204214
if replacement.turn_complete is None and original.turn_complete is not None:
205215
updates['turn_complete'] = original.turn_complete
216+
if replacement.usage_metadata is None and original.usage_metadata is not None:
217+
updates['usage_metadata'] = original.usage_metadata
206218
if updates:
207219
return replacement.model_copy(update=updates)
208220
return replacement
@@ -266,7 +278,7 @@ async def _maybe_add_grounding_metadata(
266278
)
267279
)
268280
if callback_response:
269-
return _inherit_unset_streaming_fields(
281+
return _inherit_unset_model_fields(
270282
llm_response,
271283
await _maybe_add_grounding_metadata(callback_response),
272284
)
@@ -280,7 +292,7 @@ async def _maybe_add_grounding_metadata(
280292
llm_response=llm_response,
281293
)
282294
if callback_response:
283-
return _inherit_unset_streaming_fields(
295+
return _inherit_unset_model_fields(
284296
llm_response,
285297
await _maybe_add_grounding_metadata(callback_response),
286298
)

‎tests/unittests/flows/llm_flows/test_base_llm_flow_partial_handling.py‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,3 +381,87 @@ async def test_run_async_sse_rebuilt_responses_stay_partial():
381381
'scrubbed',
382382
'scrubbed',
383383
]
384+
385+
386+
_USAGE = types.GenerateContentResponseUsageMetadata(
387+
prompt_token_count=120,
388+
candidates_token_count=8,
389+
total_token_count=128,
390+
)
391+
392+
393+
@pytest.mark.asyncio
394+
async def test_after_model_callback_replacement_inherits_usage_metadata():
395+
"""A rebuilt replacement keeps the model call's token accounting."""
396+
replacement = _text_response('scrubbed')
397+
agent = Agent(
398+
name='test_agent',
399+
after_model_callback=[_rebuilding_callback(replacement)],
400+
)
401+
original = _text_response('Hello world.')
402+
original.usage_metadata = _USAGE
403+
404+
result = await _run_handle_after_model_callback(agent, original)
405+
406+
assert result.usage_metadata == _USAGE
407+
assert result.content.parts[0].text == 'scrubbed'
408+
# The callback's object is not mutated; inheritance returns a copy.
409+
assert replacement.usage_metadata is None
410+
411+
412+
@pytest.mark.asyncio
413+
async def test_after_model_callback_replacement_explicit_usage_respected():
414+
"""A replacement that reports its own usage is not overridden."""
415+
own_usage = types.GenerateContentResponseUsageMetadata(
416+
prompt_token_count=1, candidates_token_count=1, total_token_count=2
417+
)
418+
replacement = _text_response('scrubbed')
419+
replacement.usage_metadata = own_usage
420+
agent = Agent(
421+
name='test_agent',
422+
after_model_callback=[_rebuilding_callback(replacement)],
423+
)
424+
original = _text_response('Hello world.')
425+
original.usage_metadata = _USAGE
426+
427+
result = await _run_handle_after_model_callback(agent, original)
428+
429+
assert result.usage_metadata == own_usage
430+
431+
432+
class _StreamingFakeModelWithUsage(_StreamingFakeModel):
433+
"""Like _StreamingFakeModel, but reports usage on the final response only."""
434+
435+
async def generate_content_async(
436+
self, llm_request: LlmRequest, stream: bool = False
437+
) -> AsyncGenerator[LlmResponse, None]:
438+
deltas = ['Hello ', 'world.']
439+
if stream:
440+
for delta in deltas:
441+
yield _text_response(delta, partial=True)
442+
final = _text_response(''.join(deltas))
443+
final.usage_metadata = _USAGE
444+
yield final
445+
446+
447+
@pytest.mark.asyncio
448+
async def test_run_async_sse_rebuilt_final_keeps_usage_metadata():
449+
"""End-to-end: the rebuilt final event still carries the call's usage."""
450+
agent = Agent(
451+
name='test_agent',
452+
model=_StreamingFakeModelWithUsage(model='fake-streaming'),
453+
after_model_callback=[_rebuilding_callback(_text_response('scrubbed'))],
454+
)
455+
invocation_context = await testing_utils.create_invocation_context(
456+
agent=agent,
457+
user_content='test message',
458+
run_config=RunConfig(streaming_mode=StreamingMode.SSE),
459+
)
460+
461+
flow = BaseLlmFlowForTesting()
462+
events = []
463+
async for event in flow.run_async(invocation_context):
464+
events.append(event)
465+
466+
assert [event.partial for event in events] == [True, True, None]
467+
assert [event.usage_metadata for event in events] == [None, None, _USAGE]

0 commit comments

Comments
 (0)