Skip to content

[LiteLLM] Reasoning tokens are double-counted in candidates_token_count and gen_ai.usage.output_tokens #7975

Description

@rladeira

🔴 Required Information

Describe the Bug:
LiteLlm maps LiteLLM's usage.completion_tokens directly to GenerateContentResponseUsageMetadata.candidates_token_count while also mapping usage.completion_tokens_details.reasoning_tokens to thoughts_token_count — in both the non-streaming path (_model_response_to_generate_content_response) and the streaming path (UsageMetadataChunk handling in LiteLlm.generate_content_async).

In OpenAI/LiteLLM usage semantics, completion_tokens already includes reasoning_tokens (LiteLLM normalizes Gemini, OpenAI, Anthropic, Bedrock, xAI, and Ollama responses to this invariant). In google-genai semantics, candidates_token_count excludes thoughts. Because TokenUsage.from_usage_metadata (google/adk/telemetry/_token_usage.py) computes output_tokens = candidates_token_count + thoughts_token_count, reasoning tokens are counted twice in gen_ai.usage.output_tokens (and in derived gen_ai.usage.total_tokens) on OpenTelemetry spans and cost dashboards for every reasoning model routed through LiteLlm.

(AnthropicLlm already fixed the analogous issue in commit a95b008f by subtracting thinking tokens from candidates_token_count.)

Steps to Reproduce:

  1. Install google-adk (2.13.0 or main) and litellm.
  2. Run the minimal reproduction script below (or call a thinking-enabled model such as vertex_ai/gemini-* or an OpenAI reasoning model through LiteLlm).
  3. Inspect usage_metadata.candidates_token_count and TokenUsage.from_usage_metadata(usage_metadata).to_attributes()["gen_ai.usage.output_tokens"].

Expected Behavior:
For a response with prompt_tokens=10, completion_tokens=25, total_tokens=35, and completion_tokens_details.reasoning_tokens=5:

  • candidates_token_count == 20 (max(completion_tokens - reasoning_tokens, 0))
  • thoughts_token_count == 5
  • total_token_count == 35
  • gen_ai.usage.output_tokens == 25

Observed Behavior:

  • candidates_token_count == 25
  • thoughts_token_count == 5
  • total_token_count == 35
  • gen_ai.usage.output_tokens == 30 (reasoning tokens counted twice in both non-streaming and streaming modes)

Environment Details:

  • ADK Library Version (pip show google-adk): 2.13.0 and main (76627586)
  • Desktop OS: Linux
  • Python Version (python -V): Python 3.12

Model Information:

  • Are you using LiteLLM: Yes
  • Which model is being used: Any reasoning-capable model via LiteLlm (e.g., vertex_ai/gemini-2.5-flash, OpenAI o-series / GPT-5, Anthropic extended thinking via LiteLLM, Vertex AI Model Garden MaaS reasoning models)

🟡 Optional Information

Regression:
No — candidates_token_count has mapped raw completion_tokens since reasoning token extraction was added to LiteLlm.

Logs:

stream=False candidates=25 thoughts=5 total=35 gen_ai.usage.output_tokens=30 (expected 25)
stream=True  candidates=25 thoughts=5 total=35 gen_ai.usage.output_tokens=30 (expected 25)

Additional Context:
Proposed fix: match AnthropicLlm (a95b008f) by computing candidates_token_count = max(completion_tokens - reasoning_tokens, 0) in both _model_response_to_generate_content_response and the streaming UsageMetadataChunk branch, leaving prompt_token_count, cached_content_token_count, thoughts_token_count, and total_token_count unchanged.

Minimal Reproduction Code:

import asyncio
from google.adk.models.lite_llm import LiteLlm, LiteLLMClient
from google.adk.models.llm_request import LlmRequest
from google.adk.telemetry._token_usage import TokenUsage
from google.genai import types
from litellm import ModelResponse, ModelResponseStream
from litellm.types.utils import Delta, StreamingChoices

USAGE = {
    "prompt_tokens": 10,
    "completion_tokens": 25,
    "total_tokens": 35,
    "completion_tokens_details": {"reasoning_tokens": 5},
}


class FakeClient(LiteLLMClient):

  async def acompletion(self, model, messages, tools, **kwargs):
    if kwargs.get("stream"):

      async def gen():
        yield ModelResponseStream(
            choices=[
                StreamingChoices(
                    delta=Delta(content="hi", role="assistant"),
                    finish_reason=None,
                )
            ]
        )
        yield ModelResponseStream(
            choices=[StreamingChoices(finish_reason="stop")]
        )
        yield ModelResponseStream(
            usage=USAGE, choices=[StreamingChoices(finish_reason=None)]
        )

      return gen()
    return ModelResponse(
        model="fake",
        choices=[{
            "message": {"role": "assistant", "content": "hi"},
            "finish_reason": "stop",
        }],
        usage=USAGE,
    )


async def run(stream: bool) -> None:
  llm = LiteLlm(model="openai/fake-reasoner", llm_client=FakeClient())
  req = LlmRequest(
      model="openai/fake-reasoner",
      contents=[types.Content(role="user", parts=[types.Part(text="hi")])],
  )
  usage = None
  async for r in llm.generate_content_async(req, stream=stream):
    if r.usage_metadata:
      usage = r.usage_metadata
  attrs = TokenUsage.from_usage_metadata(usage).to_attributes()
  print(
      f"stream={stream!s:5} candidates={usage.candidates_token_count}"
      f" thoughts={usage.thoughts_token_count}"
      f" total={usage.total_token_count}"
      f" gen_ai.usage.output_tokens={attrs.get('gen_ai.usage.output_tokens')}"
      " (expected 25)"
  )


asyncio.run(run(False))
asyncio.run(run(True))

How often has this issue occurred?:

  • Always (100%)

Activity

  1. added a commit that references this issue on Nov 3, 2026
    e876735
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions