From bf4175612deb0153660302327c717f10f0414216 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:23:18 +0000 Subject: [PATCH] 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> --- .../adapters/transformation.py | 13 ++++ litellm/types/llms/anthropic.py | 7 ++ .../anthropic_messages/anthropic_response.py | 6 ++ ...al_pass_through_adapters_transformation.py | 78 +++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 22f9bfd30ea..d95d313d375 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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 diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index bb861030d86..0e5c0df0bf7 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -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): diff --git a/litellm/types/llms/anthropic_messages/anthropic_response.py b/litellm/types/llms/anthropic_messages/anthropic_response.py index 679948c5235..841713ef95a 100644 --- a/litellm/types/llms/anthropic_messages/anthropic_response.py +++ b/litellm/types/llms/anthropic_messages/anthropic_response.py @@ -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): """ diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index c0c6e315b5b..18837af879c 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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,