fix(anthropic-messages): report reasoning tokens as output_tokens_details.thinking_tokens on bridged responses

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
mateo 2026-10-31 22:14:13 +00:00
parent 6aa546303e
commit 987250ffa8
7 changed files with 124 additions and 0 deletions

View file

@ -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,

View file

@ -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):

View file

@ -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):
"""

View file

@ -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)

View file

@ -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)}

View file

@ -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,