mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
parent
6d47468dae
commit
b0df8469b1
2 changed files with 150 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue