diff --git a/litellm/completion_extras/litellm_responses_transformation/handler.py b/litellm/completion_extras/litellm_responses_transformation/handler.py index 3a4f0721a62..861ffd91bfa 100644 --- a/litellm/completion_extras/litellm_responses_transformation/handler.py +++ b/litellm/completion_extras/litellm_responses_transformation/handler.py @@ -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, diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 985eb254510..a2de41a0c45 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -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 ( diff --git a/litellm/llms/anthropic/messages/adapters/handler.py b/litellm/llms/anthropic/messages/adapters/handler.py index abdf7924266..6f2a0a562cf 100644 --- a/litellm/llms/anthropic/messages/adapters/handler.py +++ b/litellm/llms/anthropic/messages/adapters/handler.py @@ -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, ) diff --git a/litellm/llms/anthropic/messages/adapters/transformation.py b/litellm/llms/anthropic/messages/adapters/transformation.py index be97946399d..0c564a194dc 100644 --- a/litellm/llms/anthropic/messages/adapters/transformation.py +++ b/litellm/llms/anthropic/messages/adapters/transformation.py @@ -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 diff --git a/litellm/responses/bridge_check.py b/litellm/responses/bridge_check.py index 74e04bb51ae..683a51fc6b6 100644 --- a/litellm/responses/bridge_check.py +++ b/litellm/responses/bridge_check.py @@ -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, ) diff --git a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index d2e2b0a9f7e..7ba833aad14 100644 --- a/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/unit/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -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 diff --git a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py index eddec1f9d0a..80038c18429 100644 --- a/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py +++ b/tests/unit/llms/anthropic/messages/adapters/test_anthropic_messages_adapters_transformation.py @@ -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.