fix(anthropic): keep every non-streamed reasoning item for Messages clients and read a deployment's reasoning_summary in the keep check

This commit is contained in:
mateo-berri 2026-11-07 03:34:55 -08:00
parent 15e6017012
commit cadfe1d179
7 changed files with 127 additions and 13 deletions

View file

@ -13,9 +13,7 @@ from typing import (
from typing_extensions import ReadOnly, TypedDict
from litellm.litellm_core_utils.core_helpers import attribute_or_none
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.utils import CallTypes
if TYPE_CHECKING:
from litellm import CustomStreamWrapper, LiteLLMLoggingObj, ModelResponse
@ -38,10 +36,6 @@ def _restore_routing_prefix(model: str, custom_llm_provider: str) -> str:
return f"{custom_llm_provider}/{model}"
def _serves_anthropic_messages(logging_obj: "LiteLLMLoggingObj") -> bool:
return attribute_or_none(logging_obj, "call_type") == CallTypes.anthropic_messages.value
class ResponsesToCompletionBridgeHandler:
def __init__(self):
from .transformation import LiteLLMResponsesTransformationHandler
@ -188,7 +182,9 @@ class ResponsesToCompletionBridgeHandler:
model_response: Final = validated_kwargs["model_response"]
logging_obj: Final = validated_kwargs["logging_obj"]
custom_llm_provider: Final = validated_kwargs["custom_llm_provider"]
serves_anthropic_messages: Final = _serves_anthropic_messages(logging_obj)
from .transformation import serves_anthropic_messages
streams_reasoning_signatures: Final = serves_anthropic_messages(logging_obj)
if kwargs.get("stream") is True and "stream" not in optional_params:
optional_params = {**optional_params, "stream": True}
logging_obj.record_bridged_chat_request(optional_params, litellm_params)
@ -269,7 +265,7 @@ class ResponsesToCompletionBridgeHandler:
cast("Mapping[str, object]", optional_params) # cast-ok: optional_params is an untyped dict
),
replays_openai_history=custom_llm_provider == "openai",
streams_reasoning_signatures=serves_anthropic_messages,
streams_reasoning_signatures=streams_reasoning_signatures,
)
streamwrapper: Final = CustomStreamWrapper(
completion_stream=completion_stream,
@ -292,7 +288,9 @@ class ResponsesToCompletionBridgeHandler:
model_response: Final = validated_kwargs["model_response"]
logging_obj: Final = validated_kwargs["logging_obj"]
custom_llm_provider: Final = validated_kwargs["custom_llm_provider"]
serves_anthropic_messages: Final = _serves_anthropic_messages(logging_obj)
from .transformation import serves_anthropic_messages
streams_reasoning_signatures: Final = serves_anthropic_messages(logging_obj)
if kwargs.get("stream") is True and "stream" not in optional_params:
optional_params = {**optional_params, "stream": True}
logging_obj.record_bridged_chat_request(optional_params, litellm_params)
@ -376,7 +374,7 @@ class ResponsesToCompletionBridgeHandler:
cast("Mapping[str, object]", optional_params) # cast-ok: optional_params is an untyped dict
),
replays_openai_history=custom_llm_provider == "openai",
streams_reasoning_signatures=serves_anthropic_messages,
streams_reasoning_signatures=streams_reasoning_signatures,
)
streamwrapper: Final = CustomStreamWrapper(
completion_stream=completion_stream,

View file

@ -39,7 +39,12 @@ from litellm.litellm_core_utils.allowed_tools_tool_choice import (
parse_chat_allowed_tools_tool_choice,
responses_allowed_tools_tool_choice,
)
from litellm.litellm_core_utils.core_helpers import as_str_mapping, drops_unsupported_params, iter_items
from litellm.litellm_core_utils.core_helpers import (
as_str_mapping,
attribute_or_none,
drops_unsupported_params,
iter_items,
)
from litellm.litellm_core_utils.hidden_params import get_hidden_params, get_or_create_hidden_params
from litellm.litellm_core_utils.prompt_templates.prompt_cache_breakpoint_utils import (
prompt_cache_breakpoint_with_readable_mode,
@ -80,7 +85,7 @@ from litellm.types.llms.openai import (
ResponsesAPIResponse,
ResponsesAPIStreamEvents,
)
from litellm.types.utils import Delta, GenericStreamingChunk, ModelResponseStream, StreamingChoices
from litellm.types.utils import CallTypes, Delta, GenericStreamingChunk, ModelResponseStream, StreamingChoices
if TYPE_CHECKING:
from openai.types.responses import ResponseInputImageParam, ResponseOutputItem
@ -265,6 +270,10 @@ class _BuiltReasoningItem(TypedDict):
summary: Sequence[_ReasoningSummaryText]
def serves_anthropic_messages(logging_obj: object) -> bool:
return attribute_or_none(logging_obj, "call_type") == CallTypes.anthropic_messages.value
def _get_reasoning_items(
msg: "AllMessageValues",
) -> list[ChatCompletionReasoningItem]:
@ -1352,6 +1361,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
structured_output: bool = False,
replay_openai_history: bool = False,
completed: bool = False,
keeps_every_reasoning_item: bool = False,
) -> list["Choices"]:
"""
Convert Responses API output items to chat completion choices.
@ -1363,6 +1373,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
first message item becomes content
replay_openai_history: Record the output's item sequence for the next turn
completed: The response finished, so its item sequence is whole
keeps_every_reasoning_item: Keep each reasoning item before an answer, not only the last
Returns:
List of Choices objects
@ -1395,7 +1406,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
for item in output_items:
if isinstance(item, ResponseReasoningItem):
pending_reasoning_items = (
*(pending_reasoning_items if replay_openai_history else ()),
*(pending_reasoning_items if replay_openai_history or keeps_every_reasoning_item else ()),
_build_reasoning_item(
item_id=item.id,
encrypted_content=getattr(item, "encrypted_content", None),
@ -1645,6 +1656,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
)
== "openai",
completed=raw_response.status == "completed",
keeps_every_reasoning_item=serves_anthropic_messages(logging_obj),
)
response_is_incomplete: Final = raw_response.status == "incomplete" or (

View file

@ -17,6 +17,7 @@ from litellm._logging import verbose_logger
from litellm.litellm_core_utils.asyncify import run_async_function
from litellm.litellm_core_utils.core_helpers import as_object_list, as_str_mapping, drops_unsupported_params
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObject
from litellm.litellm_core_utils.model_param_helper import peek_reasoning_summary_aliases
from litellm.litellm_core_utils.prompt_templates.reasoning_utils import (
is_encrypted_reasoning_block,
thinking_blocks_with_encrypted_reasoning,
@ -786,6 +787,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
custom_llm_provider=target_provider,
api_base=api_base if isinstance(api_base, str) else None,
deployment_model_info=as_str_mapping(provider_kwargs.get("model_info")),
reasoning_summary=peek_reasoning_summary_aliases(provider_kwargs),
drop_params=drop_params,
)

View file

@ -266,6 +266,7 @@ class ChatRouteInputs:
reasoning_effort: str | Mapping[str, object] | None = None
api_base: str | None = None
thinking: Mapping[str, object] | None = None
reasoning_summary: object | None = None
_NO_ROUTE_INPUTS: Final = ChatRouteInputs()
@ -311,6 +312,7 @@ def _keeps_encrypted_reasoning_for_responses(
deployment_model_info,
tools=route.tools,
reasoning_effort=route.reasoning_effort,
reasoning_summary=route.reasoning_summary,
api_base=route.api_base,
)
@ -362,6 +364,7 @@ class AnthropicAdapter:
custom_llm_provider: str | None = None,
api_base: str | None = None,
deployment_model_info: Mapping[str, object] | None = None,
reasoning_summary: object | None = None,
drop_params: bool = True,
) -> tuple[ChatCompletionRequest | None, dict[str, str]]:
"""
@ -398,6 +401,7 @@ class AnthropicAdapter:
custom_llm_provider=custom_llm_provider,
api_base=api_base,
deployment_model_info=deployment_model_info,
reasoning_summary=reasoning_summary,
drop_params=drop_params,
report_reasoning_drops=True,
)
@ -1384,6 +1388,7 @@ class LiteLLMAnthropicMessagesAdapter:
custom_llm_provider: str | None = None,
api_base: str | None = None,
deployment_model_info: Mapping[str, object] | None = None,
reasoning_summary: object | None = None,
preserve_midturn_system: bool = False,
drop_params: bool = True,
report_reasoning_drops: bool = False,
@ -1444,6 +1449,7 @@ class LiteLLMAnthropicMessagesAdapter:
reasoning_effort=new_kwargs.get("reasoning_effort"),
api_base=api_base,
thinking=anthropic_message_request.get("thinking"),
reasoning_summary=reasoning_summary,
),
)
## ADD SYSTEM MESSAGE TO MESSAGES

View file

@ -303,6 +303,7 @@ def chat_completion_ends_in_responses_api(
*,
tools: Sequence[Mapping[str, object]] | None = None,
reasoning_effort: str | Mapping[str, object] | None = None,
reasoning_summary: object | None = None,
api_base: str | None = None,
) -> bool:
"""Whether chat completion for this model is served by a Responses API.
@ -324,6 +325,7 @@ def chat_completion_ends_in_responses_api(
custom_llm_provider=provider,
tools=tools,
reasoning_effort=reasoning_effort,
reasoning_summary=reasoning_summary,
api_base=api_base,
deployment_model_info=deployment_model_info,
)

View file

@ -4151,6 +4151,72 @@ def test_transform_response_hands_encrypted_reasoning_to_messages_clients_only()
assert content[0]["data"] == encrypted_reasoning_signature(encrypted)
@pytest.mark.parametrize(
("call_type", "kept_ids"),
[
pytest.param("anthropic_messages", ["rs_first", "rs_second"], id="messages_client_keeps_every_item"),
pytest.param("acompletion", ["rs_second"], id="chat_client_keeps_the_last_item"),
],
)
def test_non_streamed_tool_turn_hands_messages_clients_every_reasoning_item(
call_type: str, kept_ids: list[str]
) -> None:
from types import SimpleNamespace
from unittest.mock import Mock
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.utils import ModelResponse, Usage
def reasoning(item_id: str) -> ResponseReasoningItem:
return ResponseReasoningItem(
id=item_id, summary=[], type="reasoning", content=None, encrypted_content=f"gAAAA_{item_id}", status=None
)
def call(index: int, city: str) -> ResponseFunctionToolCall:
return ResponseFunctionToolCall(
id=f"fc_{index}",
type="function_call",
status="completed",
arguments=json.dumps({"city": city}),
call_id=f"call_{index}",
name="get_weather",
)
raw: Final = ResponsesAPIResponse.model_construct(
id="resp_two_reasoning",
created_at=1,
error=None,
incomplete_details=None,
model="gpt-5.4-nano",
output=[reasoning("rs_first"), call(1, "Oslo"), reasoning("rs_second"), call(2, "Lyon")],
status="completed",
usage=None,
)
result: Final = LiteLLMResponsesTransformationHandler().transform_response(
model="gpt-5.4-nano",
raw_response=raw,
model_response=ModelResponse(
id="chatcmpl-two-reasoning",
created=1,
model=None,
object="chat.completion",
system_fingerprint=None,
choices=[],
usage=Usage(completion_tokens=0, prompt_tokens=0, total_tokens=0),
),
logging_obj=SimpleNamespace(call_type=call_type),
request_data={"model": "gpt-5.4-nano"},
messages=[{"role": "user", "content": "Oslo or Lyon?"}],
optional_params={},
litellm_params={"custom_llm_provider": "azure"},
encoding=Mock(),
)
message: Final = result.choices[0].message
assert [item["id"] for item in message.reasoning_items] == kept_ids
assert [tool_call.function.name for tool_call in message.tool_calls] == ["get_weather", "get_weather"]
def test_streaming_function_call_tool_id_for_degenerate_call_id():
"""In streaming, Bedrock Mantle's function_call event carries a unique ``id``
(``fc_...``) and a non-unique, index-based ``call_id`` (``call_0``). For that

View file

@ -801,6 +801,34 @@ def test_encrypted_reasoning_is_kept_when_the_request_tools_route_it_to_response
assert bool(assistant.get("thinking_blocks")) is kept
@pytest.mark.parametrize(
("deployment_params", "kept"),
[
pytest.param({"reasoning_summary": "auto"}, True, id="deployment_summary_bridges_to_responses"),
pytest.param({"extra_body": {"reasoningSummary": "auto"}}, True, id="extra_body_summary_bridges_to_responses"),
pytest.param({}, False, id="no_summary_stays_on_chat"),
],
)
def test_encrypted_reasoning_is_kept_when_the_deployment_reasoning_summary_routes_it_to_responses(
deployment_params: dict[str, object], kept: bool
) -> None:
from litellm.llms.anthropic.messages.adapters.handler import (
LiteLLMMessagesToCompletionTransformationHandler,
)
request: Final = _replayed_reasoning_request([])
completion_kwargs, _mapping = LiteLLMMessagesToCompletionTransformationHandler._prepare_completion_kwargs(
max_tokens=64,
messages=cast(list[dict[str, object]], request["messages"]),
model="azure/gpt-5.1",
thinking={"type": "enabled", "budget_tokens": 2048},
extra_kwargs={"api_base": "https://keep-check.openai.azure.com", **deployment_params},
)
assistant: Final = cast(list[Mapping[str, object]], completion_kwargs["messages"])[1]
assert bool(assistant.get("thinking_blocks")) is kept
def test_translate_anthropic_messages_to_openai_sets_reasoning_content():
"""Reasoning-aware chat providers read reasoning_content, so thinking text must land there.