mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
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:
commit
f80b5e3cbb
11 changed files with 584 additions and 36 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue