Repository navigation
gen_ai.client.token.usage records nothing when the last response of a model call has no usage #7560
Description
🔴 Required Information
Describe the Bug:
record_client_token_usage only reads usage from the last response of a model call. If that response has no usage_metadata, it logs "Skipping missing token usage metadata" and records nothing, even when an earlier response of the same call reported the usage.
This happens with LiteLlm streaming when the model hits max_tokens in the middle of a tool call. LiteLlm yields the text it got, with the usage, then an error response with no usage. The gen_ai.client.token.usage metric gets no points for that call.
ADK's own per-agent token totals already handle this case: record_inference_telemetry uses TokenUsage.from_llm_responses, which takes the newest usage report. Only this metric still looks at the last response.
Steps to Reproduce:
- Checkout of main (or
pip install google-adk litellm) - Save the script below as
repro.py - Run
python repro.py. No network: the LiteLLM client is a fake that replays two stream chunks.
Expected Behavior:
gen_ai.client.token.usage records 120 input and 40 output tokens for the call.
Observed Behavior:
Skipping missing token usage metadata for agent weather and model openai/gpt-4o
adk module: src/google/adk/telemetry/_metrics.py
event: FinishReason.MAX_TOKENS usage: (120, 40)
event: FinishReason.MAX_TOKENS usage: None
gen_ai.client.token.usage: {}
Environment Details:
- ADK Library Version (pip show google-adk): 2.12.0, main at 019387c
- Desktop OS: macOS 26.6.2
- Python Version (python -V): Python 3.12.13 (litellm 1.85.7, opentelemetry-sdk 1.42.1)
Model Information:
- Are you using LiteLLM: Yes
- Which model is being used: openai/gpt-4o through a fake LiteLLM client, no real model call
🟡 Optional Information
Regression:
No. record_client_token_usage has read only the last response since the metric was added in 0bb329b.
Root cause:
src/google/adk/telemetry/_metrics.py:586-593:last_response = responses[-1], and if it has nousage_metadatathe function returns.src/google/adk/models/lite_llm.py:4313-4330: when a stream yields both a text response and a tool-call response, the usage goes on the text one and is then cleared, so the tool-call response that comes last has none.src/google/adk/telemetry/_instrumentation.py:1036usesTokenUsage.from_llm_responses(_token_usage.py:166) for the per-agent totals. Its docstring says it takes the newest report so a trailing chunk with no usage doesn't drop the figure.record_client_token_usageis called a few lines later (_instrumentation.py:1054) with the same responses and doesn't use it.
The other caller, tools/model_consult/_advisor.py:362, goes through the same function.
Minimal Reproduction Code:
import asyncio
import os
os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "True")
from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.run_config import RunConfig
from google.adk.agents.run_config import StreamingMode
from google.adk.models.lite_llm import LiteLlm
from google.adk.models.lite_llm import LiteLLMClient
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
import google.adk.telemetry._metrics as adk_metrics
from google.genai import types
from litellm import Function
from litellm.types.utils import ChatCompletionDeltaToolCall
from litellm.types.utils import Delta
from litellm.types.utils import ModelResponseStream
from litellm.types.utils import StreamingChoices
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
reader = InMemoryMetricReader()
metrics.set_meter_provider(MeterProvider(metric_readers=[reader]))
class FakeClient(LiteLLMClient):
"""Replays a stream that stops at max_tokens inside a tool call."""
async def acompletion(self, model, messages, tools, **kwargs):
async def stream():
yield ModelResponseStream(
choices=[
StreamingChoices(
finish_reason="length",
delta=Delta(
role="assistant",
content="Let me check the weather.",
tool_calls=[
ChatCompletionDeltaToolCall(
type="function",
id="call_1",
index=0,
function=Function(
name="get_weather",
arguments='{"city": "Par',
),
)
],
),
)
]
)
yield ModelResponseStream(
usage={
"prompt_tokens": 120,
"completion_tokens": 40,
"total_tokens": 160,
},
choices=[StreamingChoices(finish_reason=None)],
)
return stream()
def get_weather(city: str) -> str:
return f"Sunny in {city}"
def token_points(name):
points = {}
data = reader.get_metrics_data()
for rm in data.resource_metrics if data else []:
for sm in rm.scope_metrics:
for metric in sm.metrics:
if metric.name == name:
for point in metric.data.data_points:
key = dict(point.attributes).get("gen_ai.token.type")
points[key] = point.sum
return points
async def main():
print("adk module:", adk_metrics.__file__)
agent = LlmAgent(
name="weather",
model=LiteLlm(model="openai/gpt-4o", llm_client=FakeClient()),
tools=[get_weather],
)
runner = Runner(
app_name="app", agent=agent, session_service=InMemorySessionService()
)
await runner.session_service.create_session(
app_name="app", user_id="u", session_id="s"
)
async for event in runner.run_async(
user_id="u",
session_id="s",
new_message=types.UserContent("Weather in Paris?"),
run_config=RunConfig(streaming_mode=StreamingMode.SSE),
):
if not event.partial:
usage = event.usage_metadata
print(
"event:",
event.finish_reason,
"usage:",
(usage.prompt_token_count, usage.candidates_token_count)
if usage
else None,
)
print("gen_ai.client.token.usage:", token_points("gen_ai.client.token.usage"))
asyncio.run(main())How often has this issue occurred?:
- Always (100%), for any model call whose last response has no usage
I have a fix with a regression test ready and will open a PR.
Activity
surajksharma07 commentedon Oct 16, 2026
@Somay-kousis. Reproduced it on the first run on main (2.12.0): the per-agent totals have 120/40 but gen_ai.client.token.usage comes back empty.
Switching record_client_token_usage to TokenUsage.from_llm_responses as in #7561 worked on my side for the trailing MAX_TOKENS case and it still takes the newest report rather than summing so the cumulative streaming case isn’t double counted.
One small thing: the “used to record a 0/0 point” note in the PR doesn’t quite match main since the > 0 checks already skipped those so you may want to reword it. A test for two cumulative reports (e.g. 120/10 then 120/40) would also be nice to have.
Could you confirm this holds up on your end with a real LiteLLM stream not just the fake client? Once that checks out we’ll get #7561 moving for review.
Somay-kousis commentedon Oct 16, 2026
Thanks @surajksharma07, you're right about the 0/0 line. The
> 0checks already skipped those on main, so the only change in that edge is that it now logs the "Skipping missing token usage metadata" warning. Fixed the PR description.Added the cumulative test in 9a8a4aa: two reports, 120/10 then 120/40, with and without a trailing response that has no usage. Both record 120/40, not a sum. The trailing case fails on main, the other passes on both. Telemetry suite: 938 passed, 7 xfailed.
On a real stream: I don't have provider keys here, so I ran it through LiteLLM's own OpenAI code path against a local server that streams OpenAI's SSE format (text, a tool call cut off with
finish_reason: "length", then the usage-only chunk you get withstream_options.include_usage). Same agent andRunConfig(streaming_mode=SSE)as the repro:It would still be good if someone with a key could confirm against a live provider.
fix: keep client token usage when the last response has none
surajksharma07 commentedon Oct 19, 2026
#7561 is now merged in 8ff9941 @Somay-kousis and re-ran the repro on main after it: gen_ai.client.token.usage now records 120/40 and the telemetry metrics tests pass.
Not in 2.12.0 so anyone hitting this will get it in the next release or by installing from main until then.