mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
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:
parent
6aa546303e
commit
987250ffa8
7 changed files with 124 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
13
tests/integration/translation/params.py
Normal file
13
tests/integration/translation/params.py
Normal 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)}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue