diff --git a/litellm/completion_extras/litellm_responses_transformation/transformation.py b/litellm/completion_extras/litellm_responses_transformation/transformation.py index 5f3e9ac753c..fa0e607fafd 100644 --- a/litellm/completion_extras/litellm_responses_transformation/transformation.py +++ b/litellm/completion_extras/litellm_responses_transformation/transformation.py @@ -59,6 +59,7 @@ if TYPE_CHECKING: ChatCompletionRedactedThinkingBlock, ChatCompletionThinkingBlock, OpenAIMessageContentListBlock, + ResponsesAPIResponse, ) from litellm.types.utils import Choices @@ -157,6 +158,28 @@ def _flat_responses_tool_choice(choice_type: str, name: str) -> ToolChoiceFuncti return ToolChoiceFunctionParam(type="function", name=name) +def _incomplete_reason_to_finish_reason(reason: str | None) -> str: + return "content_filter" if reason == "content_filter" else "length" + + +def _empty_incomplete_choice(raw_response: "ResponsesAPIResponse", output_items: Sequence[object]) -> "Choices": + """An incomplete response can carry no message output at all (e.g. every output + token spent on reasoning). Chat completions models this as an empty assistant + message with finish_reason length/content_filter, not as an error.""" + from litellm.types.utils import Choices, Message + + incomplete_reason: Final = ( + raw_response.incomplete_details.reason if raw_response.incomplete_details is not None else None + ) + if incomplete_reason is None: + raise ValueError(f"Unknown items in responses API response: {output_items}") + return Choices( + message=Message(role="assistant", content=""), + finish_reason=_incomplete_reason_to_finish_reason(incomplete_reason), + index=0, + ) + + def _reasoning_item_to_response_input( r_item: ChatCompletionReasoningItem, ) -> dict[str, object]: @@ -758,16 +781,12 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge): ) # Convert response output to choices using the static helper - choices: Final = self._convert_response_output_to_choices( + converted_choices: Final = self._convert_response_output_to_choices( output_items=output_items, handle_raw_dict_callback=self._handle_raw_dict_response_item, ) - if len(choices) == 0: - if raw_response.incomplete_details is not None and raw_response.incomplete_details.reason is not None: - raise ValueError(f"{model} unable to complete request: {raw_response.incomplete_details.reason}") - else: - raise ValueError(f"Unknown items in responses API response: {output_items}") + choices: Final = converted_choices or [_empty_incomplete_choice(raw_response, output_items)] setattr(model_response, "choices", choices) @@ -1392,7 +1411,7 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): ) ] ) - elif event_type == "response.completed": + elif event_type in ("response.completed", "response.incomplete"): # Response is fully complete - now we can signal is_finished=True # This ensures we don't prematurely end the stream before tool_calls arrive @@ -1407,7 +1426,13 @@ class OpenAiResponsesToChatCompletionStreamIterator(BaseModelResponseIterator): if isinstance(item, dict) ) - finish_reason: Final = "tool_calls" if has_function_calls else "stop" + incomplete_details: Final = (response_data.get("incomplete_details") or {}) if response_data else {} + terminal_finish_reason: Final = ( + "stop" + if event_type == "response.completed" + else _incomplete_reason_to_finish_reason(incomplete_details.get("reason")) + ) + finish_reason: Final = "tool_calls" if has_function_calls else terminal_finish_reason # Extract reasoning items with encrypted_content for round-tripping completed_reasoning_items: list[_BuiltReasoningItem] | None = None 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 5508931b35d..0b4b44a0a6b 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 @@ -3485,3 +3485,120 @@ async def test_acompletion_bridge_normalizes_tool_choice_on_the_wire( post_kwargs = mock_post.call_args.kwargs request_body = post_kwargs["json"] if "json" in post_kwargs else json.loads(post_kwargs["data"]) assert request_body["tool_choice"] == expected_wire_tool_choice + + +def _make_incomplete_reasoning_only_response(reason): + from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse + + return ResponsesAPIResponse( + id="resp_incomplete", + created_at=1760144904, + error=None, + incomplete_details={"reason": reason} if reason else None, + instructions=None, + metadata={}, + model="gpt-5.6-sol", + object="response", + output=[{"type": "reasoning", "id": "rs_1", "summary": [], "content": []}], + parallel_tool_calls=True, + temperature=1.0, + tool_choice="auto", + tools=[], + top_p=1.0, + max_output_tokens=16, + previous_response_id=None, + reasoning={"effort": "high"}, + status="incomplete", + text={"format": {"type": "text"}}, + truncation="disabled", + usage=ResponseAPIUsage( + input_tokens=37, + input_tokens_details=None, + output_tokens=16, + output_tokens_details={"reasoning_tokens": 16}, + total_tokens=53, + cost=None, + ), + user=None, + store=True, + ) + + +@pytest.mark.parametrize( + "reason,expected_finish_reason", + [("max_output_tokens", "length"), ("content_filter", "content_filter")], +) +def test_transform_response_incomplete_reasoning_only_output(reason, expected_finish_reason): + """A reasoning model can spend every output token on reasoning, leaving no + message item. That must surface as an empty choice with the mapped + finish_reason, not a ValueError that callers turn into a 500.""" + handler = LiteLLMResponsesTransformationHandler() + + logging_obj = Mock() + logging_obj.model_call_details = {} + + result = handler.transform_response( + model="gpt-5.6-sol", + raw_response=_make_incomplete_reasoning_only_response(reason), + model_response=_make_empty_model_response(), + logging_obj=logging_obj, + request_data={"model": "gpt-5.6-sol"}, + messages=[{"role": "user", "content": "compute something long"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + assert len(result.choices) == 1 + assert result.choices[0].finish_reason == expected_finish_reason + assert result.choices[0].message.content == "" + assert result.usage.prompt_tokens == 37 + assert result.usage.completion_tokens == 16 + + +def test_transform_response_unknown_items_without_incomplete_details_still_raises(): + handler = LiteLLMResponsesTransformationHandler() + + logging_obj = Mock() + logging_obj.model_call_details = {} + + with pytest.raises(ValueError, match="Unknown items"): + handler.transform_response( + model="gpt-5.6-sol", + raw_response=_make_incomplete_reasoning_only_response(None), + model_response=_make_empty_model_response(), + logging_obj=logging_obj, + request_data={"model": "gpt-5.6-sol"}, + messages=[{"role": "user", "content": "compute something long"}], + optional_params={}, + litellm_params={}, + encoding=Mock(), + ) + + +@pytest.mark.parametrize( + "reason,expected_finish_reason", + [("max_output_tokens", "length"), ("content_filter", "content_filter")], +) +def test_response_incomplete_stream_event_emits_mapped_finish_reason(reason, expected_finish_reason): + from litellm.completion_extras.litellm_responses_transformation.transformation import ( + OpenAiResponsesToChatCompletionStreamIterator, + ) + + iterator = OpenAiResponsesToChatCompletionStreamIterator(streaming_response=None, sync_stream=True) + + chunk = { + "type": "response.incomplete", + "response": { + "id": "resp_incomplete", + "status": "incomplete", + "incomplete_details": {"reason": reason}, + "output": [{"type": "reasoning", "id": "rs_1", "summary": [], "content": []}], + "usage": {"input_tokens": 37, "output_tokens": 16, "total_tokens": 53}, + }, + } + + result = iterator.chunk_parser(chunk) + + assert len(result.choices) > 0 + assert result.choices[0].finish_reason == expected_finish_reason