fix(anthropic,bedrock): report provider thinking tokens instead of classifying them as text

Resolves LIT-5244

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-08-05 20:47:05 +00:00
parent 24dbd2b2db
commit af2246c5b8
10 changed files with 462 additions and 24 deletions

View file

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

View file

@ -1,9 +1,11 @@
import json
import re
import time
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 (
@ -38,6 +40,7 @@ from litellm.types.llms.anthropic import (
AnthropicMessagesTool,
AnthropicMessagesToolChoice,
AnthropicOutputSchema,
AnthropicOutputTokensDetails,
AnthropicSystemMessageContent,
AnthropicThinkingParam,
AnthropicWebSearchTool,
@ -2104,6 +2107,66 @@ 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:
reported_thinking_tokens: Final = (
self._sum_iteration_thinking_tokens(iterations)
if iterations
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) for iteration in iterations if isinstance(iteration, Mapping)
)
reported: Final = tuple(tokens for tokens in per_iteration if tokens is not None)
return sum(reported) if reported else None
def calculate_usage(
self,
usage_object: dict,
@ -2182,14 +2245,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

@ -1764,6 +1764,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"]
@ -1784,10 +1785,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,
@ -2184,6 +2194,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

@ -4,7 +4,7 @@ Handles transforming from Responses API -> LiteLLM completion (Chat Completion
import json
import re
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import Any, Final, Literal, cast
from openai.types.chat.chat_completion_named_tool_choice_param import (
@ -1745,6 +1745,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,
@ -1753,23 +1759,31 @@ 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
),
role="assistant",
content=[
OutputText(
type="output_text",
text=message.reasoning_content,
annotations=[],
)
],
content=(
[
OutputText(
type="output_text",
text=reasoning_content,
annotations=[],
)
]
if reasoning_content
# mutable-ok: GenericResponseOutputItem.content is typed as a list
else []
),
encrypted_content=encrypted_content,
)
]
return []

View file

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

View file

@ -1180,3 +1180,49 @@ 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

View file

@ -119,6 +119,118 @@ 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_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

@ -5934,3 +5934,84 @@ 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

View file

@ -263,6 +263,107 @@ class TestReasoningContentFinalResponse:
assert len(reasoning_items) == 1, "Should have exactly one reasoning item"
assert reasoning_items[0].content[0].text == "Reasoning for first answer"
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_streaming_chunk_id_raw():
"""Test that streaming chunk IDs are raw (not encoded) to match OpenAI format"""