Merge pull request #35998 from BerriAI/litellm_fix_bedrock_adaptive_thinking_token_accounting

fix(anthropic,bedrock): report provider thinking tokens instead of classifying them as text
This commit is contained in:
Mateo Wang 2026-08-19 15:14:49 -07:00 committed by GitHub
commit f80b5e3cbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 584 additions and 36 deletions

View file

@ -723,7 +723,7 @@ class ChunkProcessor:
for choice in response.choices:
if (
hasattr(cast(Choices, choice).message, "reasoning_content")
and cast(Choices, choice).message.reasoning_content is not None
and cast(Choices, choice).message.reasoning_content
):
if reasoning_tokens is None:
reasoning_tokens = 0
@ -987,7 +987,12 @@ class ChunkProcessor:
returned_usage.completion_tokens_details is not None
and returned_usage.completion_tokens_details.reasoning_tokens is None
):
returned_usage.completion_tokens_details.reasoning_tokens = reasoning_tokens
capped_reasoning_tokens: Final = min(max(0, reasoning_tokens), returned_usage.completion_tokens)
returned_usage.completion_tokens_details.reasoning_tokens = capped_reasoning_tokens
if returned_usage.completion_tokens_details.text_tokens is None:
returned_usage.completion_tokens_details.text_tokens = (
returned_usage.completion_tokens - capped_reasoning_tokens
)
if prompt_tokens_details is not None:
returned_usage.prompt_tokens_details = prompt_tokens_details

View file

@ -5,6 +5,7 @@ from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, NoReturn, cast
import httpx
from pydantic import ValidationError
import litellm
from litellm.constants import (
@ -39,6 +40,7 @@ from litellm.types.llms.anthropic import (
AnthropicMessagesTool,
AnthropicMessagesToolChoice,
AnthropicOutputSchema,
AnthropicOutputTokensDetails,
AnthropicSystemMessageContent,
AnthropicThinkingParam,
AnthropicWebSearchTool,
@ -2104,6 +2106,68 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
compaction_blocks,
)
@staticmethod
def _thinking_tokens_from_usage(usage_object: Mapping[str, object]) -> int | None:
details: Final = usage_object.get("output_tokens_details")
if not isinstance(details, Mapping):
return None
try:
return AnthropicOutputTokensDetails.model_validate(details).thinking_tokens
except ValidationError:
return None
@staticmethod
def _response_has_thinking_block(completion_response: Mapping[str, object] | None) -> bool:
if completion_response is None:
return False
content: Final = completion_response.get("content")
if not isinstance(content, list):
return False
return any(
isinstance(block, Mapping) and block.get("type") in ("thinking", "redacted_thinking") for block in content
)
def _build_completion_token_details(
self,
usage_object: Mapping[str, object],
iterations: Sequence[object] | None,
completion_tokens: int,
reasoning_content: str | None,
completion_response: Mapping[str, object] | None,
) -> CompletionTokensDetailsWrapper:
iteration_thinking_tokens: Final = self._sum_iteration_thinking_tokens(iterations) if iterations else None
reported_thinking_tokens: Final = (
iteration_thinking_tokens
if iteration_thinking_tokens is not None
else self._thinking_tokens_from_usage(usage_object)
)
if reported_thinking_tokens is not None:
capped_reported: Final = min(max(0, reported_thinking_tokens), completion_tokens)
return CompletionTokensDetailsWrapper(
reasoning_tokens=capped_reported,
text_tokens=completion_tokens - capped_reported,
)
if reasoning_content:
estimated: Final = min(
token_counter(text=reasoning_content, count_response_tokens=True),
completion_tokens,
)
return CompletionTokensDetailsWrapper(
reasoning_tokens=max(0, estimated),
text_tokens=completion_tokens - max(0, estimated),
)
if self._response_has_thinking_block(completion_response):
return CompletionTokensDetailsWrapper(reasoning_tokens=None, text_tokens=None)
return CompletionTokensDetailsWrapper(reasoning_tokens=0, text_tokens=completion_tokens)
def _sum_iteration_thinking_tokens(self, iterations: Sequence[object]) -> int | None:
per_iteration: Final = tuple(
self._thinking_tokens_from_usage(iteration) if isinstance(iteration, Mapping) else None
for iteration in iterations
)
reported: Final = tuple(tokens for tokens in per_iteration if tokens is not None)
return sum(reported) if len(reported) == len(per_iteration) else None
@staticmethod
def is_anthropic_usage_object(usage_object: dict) -> bool:
"""Anthropic reports prompt cache tokens as top-level ``cache_read_input_tokens`` /
@ -2222,14 +2286,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig):
cache_creation_token_details=cache_creation_token_details,
text_tokens=raw_input_tokens,
)
# Always populate completion_token_details, not just when there's reasoning_content
estimated_reasoning_tokens: Final = (
token_counter(text=reasoning_content, count_response_tokens=True) if reasoning_content else 0
)
reasoning_tokens: Final = min(estimated_reasoning_tokens, completion_tokens)
completion_token_details: Final = CompletionTokensDetailsWrapper(
reasoning_tokens=max(0, reasoning_tokens),
text_tokens=(completion_tokens - reasoning_tokens if reasoning_tokens > 0 else completion_tokens),
completion_token_details: Final = self._build_completion_token_details(
usage_object=_usage,
iterations=iterations,
completion_tokens=completion_tokens,
reasoning_content=reasoning_content,
completion_response=completion_response,
)
total_tokens: Final = prompt_tokens + completion_tokens

View file

@ -1834,6 +1834,7 @@ class AmazonConverseConfig(BaseConfig):
self,
usage: ConverseTokenUsageBlock,
reasoning_content: str | None = None,
thinking_ran: bool = False,
) -> Usage:
input_tokens = usage["inputTokens"]
output_tokens: Final = usage["outputTokens"]
@ -1854,10 +1855,19 @@ class AmazonConverseConfig(BaseConfig):
cache_creation_tokens=cache_creation_input_tokens,
text_tokens=raw_input_tokens,
)
reasoning_tokens = token_counter(text=reasoning_content, count_response_tokens=True) if reasoning_content else 0
completion_tokens_details: Final = CompletionTokensDetailsWrapper(
reasoning_tokens=reasoning_tokens,
text_tokens=(output_tokens - reasoning_tokens if reasoning_tokens > 0 else output_tokens),
reasoning_tokens: Final = (
token_counter(text=reasoning_content, count_response_tokens=True) if reasoning_content else 0
)
completion_tokens_details: Final = (
CompletionTokensDetailsWrapper(
reasoning_tokens=reasoning_tokens,
text_tokens=output_tokens - reasoning_tokens,
)
if reasoning_tokens > 0
else CompletionTokensDetailsWrapper(
reasoning_tokens=None if thinking_ran else 0,
text_tokens=None if thinking_ran else output_tokens,
)
)
openai_usage: Final = Usage(
prompt_tokens=input_tokens,
@ -2254,6 +2264,7 @@ class AmazonConverseConfig(BaseConfig):
usage: Final = self.transform_usage(
completion_response["usage"],
reasoning_content=chat_completion_message.get("reasoning_content"),
thinking_ran=reasoningContentBlocks is not None,
)
## HANDLE TOOL CALLS

View file

@ -330,6 +330,7 @@ class AWSEventStreamDecoder:
self.response_id: str | None = None
self.json_mode = json_mode
self._current_tool_name: str | None = None
self._thinking_ran = False
def check_empty_tool_call_args(self) -> bool:
"""
@ -559,7 +560,12 @@ class AWSEventStreamDecoder:
elif "stopReason" in chunk_data:
finish_reason = map_finish_reason(chunk_data.get("stopReason", "stop"))
elif "usage" in chunk_data:
usage = converse_config.transform_usage(chunk_data.get("usage", {}))
usage = converse_config.transform_usage(
chunk_data.get("usage", {}),
thinking_ran=self._thinking_ran,
)
if thinking_blocks:
self._thinking_ran = True
model_response_provider_specific_fields: Final = {}
if "trace" in chunk_data:

View file

@ -1996,6 +1996,12 @@ class LiteLLMCompletionResponsesConfig:
output_items.append(item)
return output_items
@staticmethod
def _encode_thinking_blocks(message: Message) -> str | None:
thinking_blocks: Final[Sequence[Mapping[str, object]]] = getattr(message, "thinking_blocks", None) or ()
preserved: Final = tuple(block for block in thinking_blocks if block.get("signature") or block.get("data"))
return json.dumps(preserved, separators=(",", ":")) if preserved else None
@staticmethod
def _extract_reasoning_output_items(
chat_completion_response: ModelResponse,
@ -2004,12 +2010,14 @@ class LiteLLMCompletionResponsesConfig:
for choice in choices:
if hasattr(choice, "message") and choice.message:
message = choice.message
if hasattr(message, "reasoning_content") and message.reasoning_content:
reasoning_content = getattr(message, "reasoning_content", None) or ""
encrypted_content = LiteLLMCompletionResponsesConfig._encode_thinking_blocks(message)
if reasoning_content or encrypted_content:
# Only check the first choice for reasoning content
return [
GenericResponseOutputItem(
type="reasoning",
id=f"rs_{hash(str(message.reasoning_content))}",
id=f"rs_{hash(reasoning_content or encrypted_content)}",
status=LiteLLMCompletionResponsesConfig._map_chat_completion_finish_reason_to_responses_status(
choice.finish_reason
),
@ -2017,10 +2025,13 @@ class LiteLLMCompletionResponsesConfig:
content=[
OutputText(
type="output_text",
text=message.reasoning_content,
text=text,
annotations=[],
)
for text in (reasoning_content,)
if text
],
encrypted_content=encrypted_content,
)
]
return []
@ -2292,18 +2303,19 @@ class LiteLLMCompletionResponsesConfig:
# Translate completion_tokens_details to output_tokens_details
if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details is not None:
completion_details: Final = usage.completion_tokens_details
output_details_dict: Final[dict[str, int]] = {}
if hasattr(completion_details, "reasoning_tokens") and completion_details.reasoning_tokens is not None:
output_details_dict["reasoning_tokens"] = completion_details.reasoning_tokens
if hasattr(completion_details, "text_tokens") and completion_details.text_tokens is not None:
output_details_dict["text_tokens"] = completion_details.text_tokens
if hasattr(completion_details, "image_tokens") and completion_details.image_tokens is not None:
output_details_dict["image_tokens"] = completion_details.image_tokens
if output_details_dict:
response_usage.output_tokens_details = OutputTokensDetails(**output_details_dict)
reasoning_token_count: Final = getattr(completion_details, "reasoning_tokens", None)
optional_output_details: Final[dict[str, int]] = {
field: value
for field, value in (
("text_tokens", getattr(completion_details, "text_tokens", None)),
("image_tokens", getattr(completion_details, "image_tokens", None)),
)
if value is not None
}
response_usage.output_tokens_details = OutputTokensDetails(
reasoning_tokens=reasoning_token_count if reasoning_token_count is not None else 0,
**optional_output_details,
)
return response_usage

View file

@ -620,6 +620,12 @@ class AnthropicResponseUsageBlock(BaseModel):
output_tokens: int
class AnthropicOutputTokensDetails(BaseModel):
model_config = ConfigDict(extra="allow")
thinking_tokens: int | None = None
AnthropicFinishReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"]

View file

@ -1262,3 +1262,83 @@ def test_get_combined_tool_content_joins_many_custom_tool_input_fragments_in_ord
assert isinstance(combined[1], ChatCompletionMessageCustomToolCall)
assert combined[1].custom.name == "run_script"
assert combined[1].custom.input == "".join(object_fragments)
def _reasoning_stream_chunk() -> ModelResponseStream:
return ModelResponseStream(
id="chatcmpl-reasoning",
model="claude-opus-4-8",
choices=[StreamingChoices(finish_reason=None, index=0, delta=Delta(content="10", role="assistant"))],
)
def test_count_reasoning_tokens_returns_none_for_signature_only_thinking():
from litellm.types.utils import Choices, Message, ModelResponse
processor = ChunkProcessor(chunks=[_reasoning_stream_chunk()])
response = ModelResponse(
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(content="10", role="assistant", reasoning_content=""),
)
]
)
assert processor.count_reasoning_tokens(response) is None
def test_count_reasoning_tokens_counts_visible_reasoning():
from litellm.types.utils import Choices, Message, ModelResponse
processor = ChunkProcessor(chunks=[_reasoning_stream_chunk()])
response = ModelResponse(
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(
content="10",
role="assistant",
reasoning_content="let me count the primes under thirty",
),
)
]
)
assert processor.count_reasoning_tokens(response) > 0
@pytest.mark.parametrize(
"estimated_reasoning_tokens, expected_reasoning_tokens, expected_text_tokens",
[(40, 40, 60), (250, 100, 0)],
)
def test_calculate_usage_fills_unknown_split_from_reasoning_estimate(
estimated_reasoning_tokens, expected_reasoning_tokens, expected_text_tokens
):
from litellm.types.utils import CompletionTokensDetailsWrapper
chunk = ModelResponseStream(
id="chatcmpl-unknown-split",
model="claude-opus-4-8",
choices=[StreamingChoices(finish_reason="stop", index=0, delta=Delta(content=None, role=None))],
usage=Usage(
prompt_tokens=50,
completion_tokens=100,
total_tokens=150,
completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=None, text_tokens=None),
),
)
processor = ChunkProcessor(chunks=[chunk])
usage = processor.calculate_usage(
chunks=[chunk],
model="claude-opus-4-8",
completion_output="10",
reasoning_tokens=estimated_reasoning_tokens,
)
assert usage.completion_tokens == 100
assert usage.completion_tokens_details.reasoning_tokens == expected_reasoning_tokens
assert usage.completion_tokens_details.text_tokens == expected_text_tokens

View file

@ -221,6 +221,162 @@ def test_calculate_usage_clamps_text_tokens_when_reasoning_estimate_exceeds_outp
assert usage.completion_tokens_details.text_tokens == 0
def test_calculate_usage_prefers_provider_reported_thinking_tokens():
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={
"input_tokens": 32,
"output_tokens": 421,
"output_tokens_details": {"thinking_tokens": 372},
},
reasoning_content="",
completion_response={
"content": [
{"type": "thinking", "thinking": "", "signature": "sig"},
{"type": "text", "text": "10"},
]
},
)
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 372
assert usage.completion_tokens_details.text_tokens == 49
def test_calculate_usage_provider_thinking_tokens_win_over_visible_reasoning_estimate():
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={
"input_tokens": 50,
"output_tokens": 811,
"output_tokens_details": {"thinking_tokens": 747},
},
reasoning_content="short visible reasoning that tokenizes to far fewer than 747 tokens",
)
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 747
assert usage.completion_tokens_details.text_tokens == 64
def test_calculate_usage_sums_provider_thinking_tokens_across_iterations():
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={
"input_tokens": 10,
"output_tokens": 300,
"iterations": [
{"input_tokens": 5, "output_tokens": 100, "output_tokens_details": {"thinking_tokens": 60}},
{"input_tokens": 5, "output_tokens": 200, "output_tokens_details": {"thinking_tokens": 90}},
],
},
reasoning_content=None,
)
assert usage.completion_tokens == 300
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 150
assert usage.completion_tokens_details.text_tokens == 150
def test_calculate_usage_falls_back_when_only_some_iterations_report_thinking_tokens():
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={
"input_tokens": 10,
"output_tokens": 300,
"output_tokens_details": {"thinking_tokens": 240},
"iterations": [
{"input_tokens": 5, "output_tokens": 100, "output_tokens_details": {"thinking_tokens": 60}},
{"input_tokens": 5, "output_tokens": 200},
],
},
reasoning_content=None,
)
assert usage.completion_tokens == 300
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 240
assert usage.completion_tokens_details.text_tokens == 60
def test_calculate_usage_reports_unknown_split_when_only_some_iterations_report_thinking_tokens():
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={
"input_tokens": 10,
"output_tokens": 300,
"iterations": [
{"input_tokens": 5, "output_tokens": 100, "output_tokens_details": {"thinking_tokens": 60}},
{"input_tokens": 5, "output_tokens": 200},
],
},
reasoning_content="",
completion_response={"content": [{"type": "thinking", "thinking": "", "signature": "sig"}]},
)
assert usage.completion_tokens == 300
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens is None
assert usage.completion_tokens_details.text_tokens is None
def test_calculate_usage_reports_unknown_split_when_thinking_ran_without_a_count():
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={"input_tokens": 32, "output_tokens": 580},
reasoning_content="",
completion_response={
"content": [
{"type": "redacted_thinking", "data": "encrypted"},
{"type": "text", "text": "10"},
]
},
)
assert usage.completion_tokens == 580
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens is None
assert usage.completion_tokens_details.text_tokens is None
def test_calculate_usage_without_thinking_reports_all_output_as_text():
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={"input_tokens": 32, "output_tokens": 171},
reasoning_content=None,
completion_response={"content": [{"type": "text", "text": "10"}]},
)
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 0
assert usage.completion_tokens_details.text_tokens == 171
def test_calculate_usage_ignores_malformed_provider_thinking_tokens():
config = AnthropicConfig()
usage = config.calculate_usage(
usage_object={
"input_tokens": 32,
"output_tokens": 100,
"output_tokens_details": {"thinking_tokens": "not-a-number"},
},
reasoning_content=None,
)
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 0
assert usage.completion_tokens_details.text_tokens == 100
def test_calculate_usage_handles_mocked_output_tokens_with_reasoning_content():
config = AnthropicConfig()

View file

@ -6005,6 +6005,87 @@ def test_adaptive_thinking_dropped_when_max_tokens_too_small_converse():
assert "thinking" not in optional_params
def test_converse_usage_reports_unknown_split_for_signature_only_thinking():
config = AmazonConverseConfig()
usage = config.transform_usage(
ConverseTokenUsageBlock(inputTokens=32, outputTokens=581, totalTokens=613),
reasoning_content="",
thinking_ran=True,
)
assert usage.completion_tokens == 581
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens is None
assert usage.completion_tokens_details.text_tokens is None
def test_converse_usage_estimates_split_for_visible_thinking():
config = AmazonConverseConfig()
usage = config.transform_usage(
ConverseTokenUsageBlock(inputTokens=32, outputTokens=581, totalTokens=613),
reasoning_content="Let me think about how many primes there are under thirty.",
thinking_ran=True,
)
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens > 0
assert (
usage.completion_tokens_details.reasoning_tokens + usage.completion_tokens_details.text_tokens
== usage.completion_tokens
)
def test_converse_usage_without_thinking_reports_all_output_as_text():
config = AmazonConverseConfig()
usage = config.transform_usage(ConverseTokenUsageBlock(inputTokens=32, outputTokens=171, totalTokens=203))
assert usage.completion_tokens_details is not None
assert usage.completion_tokens_details.reasoning_tokens == 0
assert usage.completion_tokens_details.text_tokens == 171
def test_converse_transform_response_signature_only_thinking_reports_unknown_split():
config = AmazonConverseConfig()
raw_response = MagicMock(status_code=200)
raw_response.text = json.dumps(
{
"output": {
"message": {
"role": "assistant",
"content": [
{"reasoningContent": {"reasoningText": {"text": "", "signature": "sig"}}},
{"text": "10"},
],
}
},
"stopReason": "end_turn",
"usage": {"inputTokens": 32, "outputTokens": 581, "totalTokens": 613},
}
)
raw_response.json.return_value = json.loads(raw_response.text)
response = config._transform_response(
model="bedrock/global.anthropic.claude-opus-4-8",
response=raw_response,
model_response=ModelResponse(),
stream=False,
logging_obj=None,
optional_params={},
api_key=None,
data={},
messages=[],
encoding=None,
)
assert response.choices[0].message.reasoning_content == ""
assert response.usage.completion_tokens_details.reasoning_tokens is None
assert response.usage.completion_tokens_details.text_tokens is None
def test_is_converse_usage_shape_distinguishes_camel_case_from_anthropic():
config = AmazonConverseConfig()
assert config.is_converse_usage_shape({"inputTokens": 1, "outputTokens": 2}) is True

View file

@ -419,6 +419,107 @@ class TestLiteLLMCompletionResponsesConfig:
]
assert len(message_items) == 2, "Should have two message items"
def test_signature_only_thinking_block_still_emits_reasoning_item(self):
response = ModelResponse(
id="test-id",
created=1234567890,
model="test-model",
object="chat.completion",
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(
content="10",
role="assistant",
reasoning_content="",
thinking_blocks=[
{"type": "thinking", "thinking": "", "signature": "signature-payload"}
],
),
)
],
)
responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="Test input",
responses_api_request={},
chat_completion_response=response,
)
reasoning_items = [
item for item in responses_api_response.output if item.type == "reasoning"
]
assert len(reasoning_items) == 1, "Signature-only thinking should still surface a reasoning item"
assert reasoning_items[0].content == []
assert "signature-payload" in reasoning_items[0].encrypted_content
def test_redacted_thinking_block_preserved_as_encrypted_content(self):
response = ModelResponse(
id="test-id",
created=1234567890,
model="test-model",
object="chat.completion",
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(
content="10",
role="assistant",
thinking_blocks=[{"type": "redacted_thinking", "data": "redacted-payload"}],
),
)
],
)
responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="Test input",
responses_api_request={},
chat_completion_response=response,
)
reasoning_items = [
item for item in responses_api_response.output if item.type == "reasoning"
]
assert len(reasoning_items) == 1
assert "redacted-payload" in reasoning_items[0].encrypted_content
def test_visible_thinking_keeps_text_and_signature(self):
response = ModelResponse(
id="test-id",
created=1234567890,
model="test-model",
object="chat.completion",
choices=[
Choices(
finish_reason="stop",
index=0,
message=Message(
content="10",
role="assistant",
reasoning_content="counting the primes",
thinking_blocks=[
{"type": "thinking", "thinking": "counting the primes", "signature": "sig"}
],
),
)
],
)
responses_api_response = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="Test input",
responses_api_request={},
chat_completion_response=response,
)
reasoning_items = [
item for item in responses_api_response.output if item.type == "reasoning"
]
assert len(reasoning_items) == 1
assert reasoning_items[0].content[0].text == "counting the primes"
assert "sig" in reasoning_items[0].encrypted_content
def test_transform_chat_completion_response_status_with_stop(self):
"""
Test that transforming a chat completion response with 'stop' finish_reason
@ -2537,10 +2638,10 @@ class TestUsageTransformation:
assert response_usage.output_tokens_details.text_tokens == 50
assert response_usage.output_tokens_details.image_tokens == 100
def test_reasoning_tokens_not_forced_to_zero_when_absent(self):
# Regression: previously the else branch wrote reasoning_tokens=0 even when
# completion_tokens_details had no reasoning (reasoning_tokens=None). That caused
# the proxy to always report reasoning_tokens=0 for non-thinking responses.
def test_reasoning_tokens_fall_back_to_zero_when_absent(self):
# The OpenAI SDK's ResponseUsage requires output_tokens_details.reasoning_tokens
# as an int, so an absent count degrades to 0 on the responses wire instead of
# dropping output_tokens_details and breaking SDK clients.
usage = Usage(
prompt_tokens=10,
completion_tokens=50,
@ -2571,7 +2672,8 @@ class TestUsageTransformation:
)
assert response_usage.output_tokens_details is not None
assert response_usage.output_tokens_details.reasoning_tokens is None
assert response_usage.output_tokens_details.reasoning_tokens == 0
assert response_usage.output_tokens_details.text_tokens == 50
def test_reasoning_tokens_preserved_when_thinking_occurred(self):
# Regression: reasoning_tokens must survive the chat->responses translation

View file

@ -297,7 +297,8 @@ def test_transform_usage_with_zero_values():
cached_tokens=0 is preserved (cache was available; nothing was cached).
reasoning_tokens=0 is preserved the same way: an explicit provider-reported
zero passes through, while an absent value (None) is omitted.
zero passes through, while an absent value (None) falls back to 0 because the
Responses API wire contract requires reasoning_tokens as an int.
"""
completion_response = create_mock_completion_response(
model="gpt-4",
@ -321,6 +322,32 @@ def test_transform_usage_with_zero_values():
print("✓ Transformation preserves explicit reasoning_tokens=0 and omits absent values")
def test_transform_usage_unknown_reasoning_split_keeps_output_tokens_details():
"""
An unknown reasoning split (reasoning_tokens=None, text_tokens=None) must still
emit output_tokens_details with an integer reasoning_tokens: the OpenAI SDK's
ResponseUsage requires the field, so omitting it breaks /v1/responses clients.
"""
from openai.types.responses.response_usage import (
OutputTokensDetails as OpenAISDKOutputTokensDetails,
)
from litellm.types.utils import CompletionTokensDetailsWrapper
usage = Usage(
prompt_tokens=100,
completion_tokens=500,
total_tokens=600,
completion_tokens_details=CompletionTokensDetailsWrapper(reasoning_tokens=None, text_tokens=None),
)
responses_usage = LiteLLMCompletionResponsesConfig._transform_chat_completion_usage_to_responses_usage(usage)
assert responses_usage.output_tokens_details is not None
assert responses_usage.output_tokens_details.reasoning_tokens == 0
OpenAISDKOutputTokensDetails.model_validate(responses_usage.output_tokens_details.model_dump(exclude_none=True))
def test_input_tokens_details_requires_cached_tokens():
"""
Test that InputTokensDetails has cached_tokens as an int with default value 0.