Skip to content

gen_ai.client.token.usage records nothing when the last response of a model call has no usage #7560

Closed

Description

@Somay-kousis

🔴 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:

  1. Checkout of main (or pip install google-adk litellm)
  2. Save the script below as repro.py
  3. 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 no usage_metadata the 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:1036 uses TokenUsage.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_usage is 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

  1. surajksharma07 commented on Oct 16, 2026

    @surajksharma07
    Collaborator

    @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.

  2. added
    tracing[Component] This issue is related to OpenTelemetry tracing
    models[Component] This issue is related to model support
    on Oct 16, 2026
  3. Somay-kousis commented on Oct 16, 2026

    @Somay-kousis
    ContributorAuthor

    Thanks @surajksharma07, you're right about the 0/0 line. The > 0 checks 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 with stream_options.include_usage). Same agent and RunConfig(streaming_mode=SSE) as the repro:

    # main
    Skipping missing token usage metadata for agent weather and model openai/gpt-4o
    event: FinishReason.MAX_TOKENS usage: (120, 40)
    event: FinishReason.MAX_TOKENS usage: None
    gen_ai.client.token.usage: {}
    
    # #7561
    event: FinishReason.MAX_TOKENS usage: (120, 40)
    event: FinishReason.MAX_TOKENS usage: None
    gen_ai.client.token.usage: {'input': 120, 'output': 40}
    

    It would still be good if someone with a key could confirm against a live provider.

  4. added a commit that references this issue on Oct 19, 2026
    8ff9941
  5. surajksharma07 commented on Oct 19, 2026

    @surajksharma07
    Collaborator

    #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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

models[Component] This issue is related to model supporttracing[Component] This issue is related to OpenTelemetry tracing

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions