diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index 4b1b5a8a11a..0818aaf0032 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -1582,6 +1582,13 @@ class LiteLLMAnthropicMessagesAdapter: return from_server_tool_use return cls._first_positive_prompt_tokens_detail_value(usage, ("web_search_requests",)) + @staticmethod + def _get_reasoning_tokens(usage: Usage) -> int: + details: Final = usage.completion_tokens_details + if details is None or details.reasoning_tokens is None: + return 0 + return details.reasoning_tokens + @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) @@ -1600,6 +1607,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"] = {"thinking_tokens": reasoning_tokens} if web_search_requests > 0: return UsageDelta( **usage_delta, diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index dc35af81091..71b079fafe3 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -602,12 +602,17 @@ class ServerToolUsage(TypedDict, total=False): web_search_requests: ReadOnly[int] +class UsageDeltaOutputTokensDetails(TypedDict, total=False): + thinking_tokens: ReadOnly[int] + + class UsageDelta(TypedDict, total=False): input_tokens: int output_tokens: int cache_creation_input_tokens: int cache_read_input_tokens: int server_tool_use: ReadOnly[ServerToolUsage] + output_tokens_details: ReadOnly[UsageDeltaOutputTokensDetails] 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 4f419385630..ddbb6adbc1b 100644 --- a/litellm/types/llms/anthropic_messages/anthropic_response.py +++ b/litellm/types/llms/anthropic_messages/anthropic_response.py @@ -64,6 +64,10 @@ AnthropicResponseContentBlock: TypeAlias = ( ) +class AnthropicOutputTokensDetails(TypedDict, total=False): + thinking_tokens: ReadOnly[int] + + class AnthropicUsage(TypedDict, total=False): """ Input and output tokens used in the request @@ -84,6 +88,8 @@ class AnthropicUsage(TypedDict, total=False): """ server_tool_use: NotRequired[ReadOnly[ServerToolUsage]] + output_tokens_details: NotRequired[ReadOnly[AnthropicOutputTokensDetails]] + class AnthropicMessagesResponse(TypedDict, total=False): """ diff --git a/tests/integration/translation/messages/params/__init__.py b/tests/integration/translation/messages/params/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/translation/messages/params/test_messages_usage_thinking_tokens.py b/tests/integration/translation/messages/params/test_messages_usage_thinking_tokens.py new file mode 100644 index 00000000000..5b1e26b5964 --- /dev/null +++ b/tests/integration/translation/messages/params/test_messages_usage_thinking_tokens.py @@ -0,0 +1,67 @@ +from dataclasses import replace +from typing import Final + +import pytest +from integration._support.client import Gateway +from integration._support.provider import SharedProvider +from integration.translation.case import TranslationTestCase +from integration.translation.messages.bases.azure import GPT_5_4_TEST_CASE as AZURE_GPT_5_4_TEST_CASE +from integration.translation.messages.bases.gemini import GEMINI_3_5_FLASH_TEST_CASE +from integration.translation.messages.bases.openai import GPT_5_4_TEST_CASE as OPENAI_GPT_5_4_TEST_CASE +from integration.translation.params import with_field +from integration.translation.runner import assert_translation + +SCENARIO: Final = "usage_thinking_tokens" +THINKING_TOKENS: Final = 4 +EXPECTED_OUTPUT_TOKENS_DETAILS: Final = {"thinking_tokens": THINKING_TOKENS} + +OPENAI_GPT_5_4_THINKING_TOKENS_TEST_CASE: Final = replace( + OPENAI_GPT_5_4_TEST_CASE, + scenario=SCENARIO, + mock_provider_response=with_field( + OPENAI_GPT_5_4_TEST_CASE.mock_provider_response, + ("usage", "output_tokens_details", "reasoning_tokens"), + THINKING_TOKENS, + ), + expected_litellm_response=with_field( + OPENAI_GPT_5_4_TEST_CASE.expected_litellm_response, + ("usage", "output_tokens_details"), + EXPECTED_OUTPUT_TOKENS_DETAILS, + ), +) + +AZURE_GPT_5_4_THINKING_TOKENS_TEST_CASE: Final = replace( + AZURE_GPT_5_4_TEST_CASE, + scenario=SCENARIO, + mock_provider_response=with_field( + AZURE_GPT_5_4_TEST_CASE.mock_provider_response, + ("usage", "completion_tokens_details", "reasoning_tokens"), + THINKING_TOKENS, + ), + expected_litellm_response=with_field( + AZURE_GPT_5_4_TEST_CASE.expected_litellm_response, + ("usage", "output_tokens_details"), + EXPECTED_OUTPUT_TOKENS_DETAILS, + ), +) + +GEMINI_3_5_FLASH_THINKING_TOKENS_TEST_CASE: Final = replace( + GEMINI_3_5_FLASH_TEST_CASE, + scenario=SCENARIO, + expected_litellm_response=with_field( + GEMINI_3_5_FLASH_TEST_CASE.expected_litellm_response, + ("usage", "output_tokens_details"), + {"thinking_tokens": 125}, + ), +) + +CASES: Final = ( + OPENAI_GPT_5_4_THINKING_TOKENS_TEST_CASE, + AZURE_GPT_5_4_THINKING_TOKENS_TEST_CASE, + GEMINI_3_5_FLASH_THINKING_TOKENS_TEST_CASE, +) + + +@pytest.mark.parametrize("case", CASES, ids=lambda case: case.id) +def test_messages_usage_thinking_tokens(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None: + assert_translation(case, gateway, provider) diff --git a/tests/integration/translation/params.py b/tests/integration/translation/params.py new file mode 100644 index 00000000000..30681d3aa71 --- /dev/null +++ b/tests/integration/translation/params.py @@ -0,0 +1,13 @@ +from collections.abc import Mapping, Sequence +from typing import Final + +from pydantic import JsonValue + + +def with_field(mapping: Mapping[str, JsonValue], path: Sequence[str], value: JsonValue) -> Mapping[str, JsonValue]: + """Return a copy of `mapping` with the nested key `path` set to `value`, creating intermediate objects.""" + head: Final = path[0] + if len(path) == 1: + return {**mapping, head: value} + inner: Final = mapping.get(head) + return {**mapping, head: with_field(inner if isinstance(inner, dict) else {}, path[1:], value)} diff --git a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py index cc366dcde4c..a62d2119e74 100644 --- a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py @@ -41,6 +41,7 @@ from litellm.types.llms.openai import ChatCompletionAssistantToolCall from litellm.types.utils import ( ChatCompletionDeltaToolCall, Choices, + CompletionTokensDetailsWrapper, Delta, Function, Message, @@ -3401,6 +3402,28 @@ 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_maps_reasoning_tokens_to_thinking_tokens(): + usage = Usage( + prompt_tokens=19, + completion_tokens=30, + total_tokens=49, + completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=25), + ) + + anthropic_usage = LiteLLMAnthropicMessagesAdapter.translate_openai_usage_to_anthropic_usage_delta(usage) + + assert anthropic_usage["output_tokens"] == 30 + assert anthropic_usage["output_tokens_details"] == {"thinking_tokens": 25} + + +def test_translate_openai_usage_to_anthropic_omits_output_tokens_details_without_reasoning_tokens(): + usage = Usage(prompt_tokens=19, completion_tokens=5, total_tokens=24) + + anthropic_usage = LiteLLMAnthropicMessagesAdapter.translate_openai_usage_to_anthropic_usage_delta(usage) + + assert "output_tokens_details" not in anthropic_usage + + def test_translate_openai_usage_to_anthropic_ignores_fractional_cache_tokens(): usage = Usage( prompt_tokens=120,