Repository navigation
Description
🔴 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:
- Install
google-adk(2.13.0 ormain) andlitellm. - Run the minimal reproduction script below (or call a thinking-enabled model such as
vertex_ai/gemini-*or an OpenAI reasoning model throughLiteLlm). - Inspect
usage_metadata.candidates_token_countandTokenUsage.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 == 5total_token_count == 35gen_ai.usage.output_tokens == 25
Observed Behavior:
candidates_token_count == 25thoughts_token_count == 5total_token_count == 35gen_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.0andmain(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
fix(models): avoid double-counting LiteLLM reasoning tokens in output