Skip to content

Commit dad4c1a

Browse files
authored
Merge pull request #37710 from BerriAI/litellm_fix_responses_bridge_incomplete_500
fix(responses_bridge): map incomplete responses to finish_reason length instead of 500
2 parents 680bcfd + 16bba15 commit dad4c1a

2 files changed

Lines changed: 380 additions & 30 deletions

File tree

litellm/completion_extras/litellm_responses_transformation/transformation.py

Lines changed: 97 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,58 @@ def _build_reasoning_item(
113113
}
114114

115115

116+
def _reasoning_item_from_output_item(item: object) -> _BuiltReasoningItem | None:
117+
from openai.types.responses import ResponseReasoningItem
118+
119+
if isinstance(item, ResponseReasoningItem):
120+
return _build_reasoning_item(
121+
item_id=item.id,
122+
encrypted_content=getattr(item, "encrypted_content", None),
123+
summary_raw=item.summary,
124+
)
125+
if isinstance(item, dict) and item.get("type") == "reasoning":
126+
return _build_reasoning_item(
127+
item_id=item.get("id", ""),
128+
encrypted_content=item.get("encrypted_content"),
129+
summary_raw=item.get("summary"),
130+
)
131+
return None
132+
133+
134+
def _reasoning_items_from_output_items(output_items: Sequence[object]) -> tuple[_BuiltReasoningItem, ...]:
135+
return tuple(
136+
reasoning_item
137+
for reasoning_item in (_reasoning_item_from_output_item(item) for item in output_items)
138+
if reasoning_item is not None
139+
)
140+
141+
142+
def _as_chat_reasoning_items(
143+
reasoning_items: Sequence[_BuiltReasoningItem],
144+
) -> list[ChatCompletionReasoningItem] | None:
145+
if not reasoning_items:
146+
return None
147+
# cast-ok: _BuiltReasoningItem is the structural shape ChatCompletionReasoningItem
148+
# describes, and TypedDict invariance is what stops the two from unifying here.
149+
return cast(list[ChatCompletionReasoningItem], list(reasoning_items))
150+
151+
152+
def _map_incomplete_reason_to_finish_reason(incomplete_reason: str | None) -> Literal["length", "content_filter"]:
153+
if incomplete_reason == "content_filter":
154+
return "content_filter"
155+
return "length"
156+
157+
158+
def _incomplete_reason_from_response_payload(response_payload: object) -> str | None:
159+
if not isinstance(response_payload, Mapping):
160+
return None
161+
incomplete_details: Final = response_payload.get("incomplete_details")
162+
if not isinstance(incomplete_details, Mapping):
163+
return None
164+
reason: Final = incomplete_details.get("reason")
165+
return reason if isinstance(reason, str) else None
166+
167+
116168
class _ChatToolCallDict(ChatCompletionToolCallChunk, total=False):
117169
provider_specific_fields: Mapping[str, object]
118170

@@ -657,6 +709,27 @@ def _convert_response_output_to_choices(
657709

658710
return choices
659711

712+
@staticmethod
713+
def _build_empty_incomplete_choice(
714+
output_items: Sequence[object],
715+
finish_reason: Literal["length", "content_filter"],
716+
) -> "Choices":
717+
from litellm.types.utils import Choices, Message
718+
719+
reasoning_items: Final = _reasoning_items_from_output_items(output_items)
720+
reasoning_content: Final = " ".join(
721+
summary_block["text"]
722+
for reasoning_item in reasoning_items
723+
for summary_block in reasoning_item["summary"]
724+
if summary_block.get("text")
725+
)
726+
message: Final = Message(
727+
content="",
728+
reasoning_content=reasoning_content if reasoning_content else None,
729+
reasoning_items=_as_chat_reasoning_items(reasoning_items),
730+
)
731+
return Choices(message=message, finish_reason=finish_reason, index=0)
732+
660733
@classmethod
661734
def _extract_output_from_completed_event(cls, parsed_chunk: Mapping[str, object]) -> list[dict[str, object]] | None:
662735
response_payload: Final = parsed_chunk.get("response")
@@ -763,11 +836,22 @@ def transform_response(
763836
handle_raw_dict_callback=self._handle_raw_dict_response_item,
764837
)
765838

766-
if len(choices) == 0:
767-
if raw_response.incomplete_details is not None and raw_response.incomplete_details.reason is not None:
768-
raise ValueError(f"{model} unable to complete request: {raw_response.incomplete_details.reason}")
839+
response_is_incomplete: Final = raw_response.status == "incomplete" or (
840+
raw_response.incomplete_details is not None and raw_response.incomplete_details.reason is not None
841+
)
842+
843+
if len(choices) == 0 and not response_is_incomplete:
844+
raise ValueError(f"Unknown items in responses API response: {output_items}")
845+
846+
if response_is_incomplete:
847+
incomplete_finish_reason: Final = _map_incomplete_reason_to_finish_reason(
848+
raw_response.incomplete_details.reason if raw_response.incomplete_details is not None else None
849+
)
850+
if len(choices) == 0:
851+
choices.append(self._build_empty_incomplete_choice(output_items, incomplete_finish_reason))
769852
else:
770-
raise ValueError(f"Unknown items in responses API response: {output_items}")
853+
for choice in choices:
854+
choice.finish_reason = incomplete_finish_reason
771855

772856
setattr(model_response, "choices", choices)
773857

@@ -1392,12 +1476,7 @@ def translate_responses_chunk_to_openai_stream(
13921476
)
13931477
]
13941478
)
1395-
elif event_type == "response.completed":
1396-
# Response is fully complete - now we can signal is_finished=True
1397-
# This ensures we don't prematurely end the stream before tool_calls arrive
1398-
1399-
# Check if response contains function_call items in output
1400-
# to determine correct finish_reason
1479+
elif event_type in ("response.completed", "response.incomplete"):
14011480
response_data: Final = parsed_chunk.get("response", {})
14021481
output_items: Final = response_data.get("output", []) if response_data else []
14031482

@@ -1407,25 +1486,14 @@ def translate_responses_chunk_to_openai_stream(
14071486
if isinstance(item, dict)
14081487
)
14091488

1410-
finish_reason: Final = "tool_calls" if has_function_calls else "stop"
1489+
finish_reason: Final = (
1490+
_map_incomplete_reason_to_finish_reason(_incomplete_reason_from_response_payload(response_data))
1491+
if event_type == "response.incomplete"
1492+
else ("tool_calls" if has_function_calls else "stop")
1493+
)
14111494

1412-
# Extract reasoning items with encrypted_content for round-tripping
1413-
completed_reasoning_items: list[_BuiltReasoningItem] | None = None
1414-
for item in output_items:
1415-
if not isinstance(item, dict) or item.get("type") != "reasoning":
1416-
continue
1417-
if completed_reasoning_items is None:
1418-
completed_reasoning_items = []
1419-
completed_reasoning_items.append(
1420-
_build_reasoning_item(
1421-
item_id=item.get("id", ""),
1422-
encrypted_content=item.get("encrypted_content"),
1423-
summary_raw=item.get("summary"),
1424-
)
1425-
)
1426-
completed_reasoning_items_typed: Final = cast(
1427-
list[ChatCompletionReasoningItem] | None,
1428-
completed_reasoning_items,
1495+
terminal_reasoning_items_typed: Final = _as_chat_reasoning_items(
1496+
_reasoning_items_from_output_items(output_items)
14291497
)
14301498

14311499
usage = None
@@ -1439,7 +1507,7 @@ def translate_responses_chunk_to_openai_stream(
14391507
index=0,
14401508
delta=Delta(
14411509
content="",
1442-
reasoning_items=completed_reasoning_items_typed,
1510+
reasoning_items=terminal_reasoning_items_typed,
14431511
),
14441512
finish_reason=finish_reason,
14451513
)

0 commit comments

Comments
 (0)