diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 5a6debc4af5..70d6e55a469 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -262,6 +262,19 @@ def tool_call_dict_from_output_item(item: Mapping[str, Any], index: int) -> _Cha return tool_call_dict +def _is_silent_output(output_items: Sequence[object]) -> bool: + """True when a completed response carries no message and no tool call. + + Covers an empty ``output`` list and a reasoning-only list. Both are legal + ``status: completed`` responses that convert to zero chat choices. + """ + for item in output_items: + item_type = item.get("type") if isinstance(item, Mapping) else getattr(item, "type", None) + if item_type != "reasoning": + return False + return True + + def _flat_responses_tool_choice(choice_type: str, name: str) -> ToolChoiceFunctionParam | ToolChoiceCustomParam: if choice_type == "custom": return ToolChoiceCustomParam(type="custom", name=name) @@ -784,7 +797,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): @staticmethod def _build_empty_incomplete_choice( output_items: Sequence[object], - finish_reason: Literal["length", "content_filter"], + finish_reason: Literal["length", "content_filter", "stop"], ) -> "Choices": from litellm.types.utils import Choices, Message @@ -913,7 +926,15 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ) if len(choices) == 0 and not response_is_incomplete: - raise ValueError(f"Unknown items in responses API response: {output_items}") + if raw_response.status == "completed" and _is_silent_output(output_items): + # A completed turn with nothing to say: no message and no tool + # call (empty output, or reasoning only). Chat Completions + # returns an empty assistant message with finish_reason "stop" + # for this case; mirror it instead of failing a successful + # request with a 500. + choices.append(self._build_empty_incomplete_choice(output_items, "stop")) + else: + raise ValueError(f"Unknown items in responses API response: {output_items}") if response_is_incomplete: incomplete_finish_reason: Final = _map_incomplete_reason_to_finish_reason( diff --git a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py index e4ada0a9b31..ced5d335b9d 100644 --- a/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py +++ b/tests/test_litellm/completion_extras/litellm_responses_transformation/test_completion_extras_litellm_responses_transformation_transformation.py @@ -3662,14 +3662,58 @@ def test_transform_response_incomplete_content_filter_maps_finish_reason(): assert result.choices[0].message.content == "" -def test_transform_response_zero_choices_not_incomplete_still_raises(): +def test_transform_response_zero_choices_not_completed_still_raises(): handler = LiteLLMResponsesTransformationHandler() raw_response = _make_empty_responses_api_response() + raw_response.status = "in_progress" with pytest.raises(ValueError, match="Unknown items"): _call_transform_response(handler, raw_response) +def test_transform_response_unknown_completed_item_still_raises(): + handler = LiteLLMResponsesTransformationHandler() + raw_response = _make_empty_responses_api_response() + raw_response.output = [{"type": "mystery_item", "id": "x_1"}] + + with pytest.raises(ValueError, match="Unknown items"): + _call_transform_response(handler, raw_response) + + +def test_transform_response_empty_completed_output_returns_silent_stop_choice(): + """A ``status: completed`` response with ``output: []`` is a legal silent turn + (seen live from gpt-5.6 on Bedrock Mantle right after a tool result). The + bridge mirrors Chat Completions: one empty assistant message with + finish_reason "stop", usage preserved, instead of a 500 (#37039).""" + handler = LiteLLMResponsesTransformationHandler() + raw_response = _make_empty_responses_api_response() + + result = _call_transform_response(handler, raw_response) + + assert len(result.choices) == 1 + choice = result.choices[0] + assert choice.finish_reason == "stop" + assert choice.index == 0 + assert choice.message.role == "assistant" + assert choice.message.content == "" + assert not choice.message.tool_calls + assert result.usage.total_tokens == 2 + + +def test_transform_response_reasoning_only_completed_output_returns_silent_stop_choice(): + handler = LiteLLMResponsesTransformationHandler() + raw_response = _make_empty_responses_api_response() + raw_response.output = [_make_reasoning_only_output_item()] + + result = _call_transform_response(handler, raw_response) + + assert len(result.choices) == 1 + choice = result.choices[0] + assert choice.finish_reason == "stop" + assert choice.message.content == "" + assert choice.message.reasoning_items[0]["encrypted_content"] == "enc_abc" + + def test_transform_response_completed_with_reasonless_incomplete_details_keeps_stop(): from openai.types.responses import ResponseOutputMessage, ResponseOutputText