fix(responses bridge): map incomplete responses to finish_reason instead of 500

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
milan 2026-08-20 15:24:02 +00:00
parent 6d47468dae
commit b0df8469b1
2 changed files with 150 additions and 8 deletions

View file

@ -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

View file

@ -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