fix(anthropic): report reasoning tokens in /v1/messages usage for non-anthropic models

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-10 01:23:18 +00:00
parent f6b9518ddb
commit bf4175612d
4 changed files with 104 additions and 0 deletions

View file

@ -80,6 +80,7 @@ from litellm.types.llms.anthropic import (
AnthropicMessagesRequest,
AnthropicMessagesToolChoice,
AnthropicMessagesUserMessageParam,
AnthropicOutputTokensDetails,
AnthropicResponseContentBlockRedactedThinking,
AnthropicResponseContentBlockText,
AnthropicResponseContentBlockThinking,
@ -1265,6 +1266,15 @@ class LiteLLMAnthropicMessagesAdapter:
return explicit_value
return cls._first_positive_prompt_tokens_detail_value(usage, ("cache_creation_tokens", "cache_write_tokens"))
@classmethod
def _get_reasoning_tokens(cls, usage: Usage) -> int:
completion_tokens_details: Final = getattr(usage, "completion_tokens_details", None)
if completion_tokens_details is None:
return 0
if isinstance(completion_tokens_details, dict):
return cls._positive_int(completion_tokens_details.get("reasoning_tokens"))
return cls._positive_int(getattr(completion_tokens_details, "reasoning_tokens", None))
@classmethod
def _translate_openai_usage_to_anthropic_usage_delta(cls, usage: Usage) -> UsageDelta:
cache_read_input_tokens: Final = cls._get_cache_read_input_tokens(usage)
@ -1282,6 +1292,9 @@ class LiteLLMAnthropicMessagesAdapter:
usage_delta["cache_creation_input_tokens"] = cache_creation_input_tokens
if cache_read_input_tokens > 0:
usage_delta["cache_read_input_tokens"] = cache_read_input_tokens
reasoning_tokens: Final = cls._get_reasoning_tokens(usage)
if reasoning_tokens > 0:
usage_delta["output_tokens_details"] = AnthropicOutputTokensDetails(thinking_tokens=reasoning_tokens)
return usage_delta
@classmethod

View file

@ -486,11 +486,18 @@ class MessageDelta(TypedDict, total=False):
stop_reason: str | None
class AnthropicOutputTokensDetails(TypedDict, total=False):
"""Anthropic ``usage.output_tokens_details`` (extended thinking breakdown)."""
thinking_tokens: int
class UsageDelta(TypedDict, total=False):
input_tokens: int
output_tokens: int
cache_creation_input_tokens: int
cache_read_input_tokens: int
output_tokens_details: AnthropicOutputTokensDetails
class AppliedEdit(TypedDict, total=False):

View file

@ -3,6 +3,7 @@ from typing import Any, Literal, TypeAlias
from typing_extensions import NotRequired, TypedDict
from litellm.types.llms.anthropic import (
AnthropicOutputTokensDetails,
AnthropicResponseContentBlockText,
AnthropicResponseContentBlockToolUse,
ContextManagementResponse,
@ -71,6 +72,11 @@ class AnthropicUsage(TypedDict, total=False):
cache_creation_input_tokens: int
cache_read_input_tokens: int
"""
Extended thinking breakdown of ``output_tokens``
"""
output_tokens_details: AnthropicOutputTokensDetails
class AnthropicMessagesResponse(TypedDict, total=False):
"""

View file

@ -2331,6 +2331,84 @@ def test_translate_openai_usage_to_anthropic_cache_tokens_from_dict_details_with
assert anthropic_usage["cache_creation_input_tokens"] == 20
def test_translate_openai_usage_to_anthropic_reports_reasoning_tokens():
"""
Reasoning models served through /v1/messages must report the reasoning tokens they billed for,
in Anthropic's own shape (usage.output_tokens_details.thinking_tokens).
Fixes: https://github.com/BerriAI/litellm/issues/36376
"""
from litellm.types.utils import CompletionTokensDetailsWrapper
usage = Usage(
prompt_tokens=15,
completion_tokens=100,
total_tokens=115,
completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=68, text_tokens=32),
)
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(usage)
assert anthropic_usage["output_tokens"] == 100
assert anthropic_usage["output_tokens_details"] == {"thinking_tokens": 68}
def test_translate_openai_usage_to_anthropic_reads_reasoning_tokens_from_dict_details():
usage = Usage(prompt_tokens=15, completion_tokens=100, total_tokens=115)
usage.completion_tokens_details = {"reasoning_tokens": 68}
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(usage)
assert anthropic_usage["output_tokens_details"] == {"thinking_tokens": 68}
def test_translate_openai_usage_to_anthropic_omits_zero_reasoning_tokens():
from litellm.types.utils import CompletionTokensDetailsWrapper
usage = Usage(
prompt_tokens=15,
completion_tokens=100,
total_tokens=115,
completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=0, text_tokens=100),
)
anthropic_usage = LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage_delta(usage)
assert "output_tokens_details" not in anthropic_usage
def test_translate_openai_response_to_anthropic_reports_reasoning_tokens():
from litellm.types.utils import CompletionTokensDetailsWrapper
usage = Usage(
prompt_tokens=15,
completion_tokens=100,
total_tokens=115,
completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=68, text_tokens=32),
)
response = ModelResponse(
id="chatcmpl-1",
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(content="2", role="assistant"),
)
],
created=1,
model="gpt-5.1",
object="chat.completion",
usage=usage,
)
anthropic_response = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
response=response,
tool_name_mapping=None,
)
assert anthropic_response["usage"]["output_tokens_details"] == {"thinking_tokens": 68}
def test_translate_openai_usage_to_anthropic_ignores_fractional_cache_tokens():
usage = Usage(
prompt_tokens=120,