fix(responses): preserve all reasoning items in non-streaming bridge (#43620)

This commit is contained in:
agustin18 2026-09-29 02:50:38 +00:00
parent 39d14bd855
commit 353896a664
2 changed files with 124 additions and 19 deletions

View file

@ -676,7 +676,6 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
from openai.types.responses import (
ResponseFunctionToolCall,
ResponseOutputMessage,
ResponseReasoningItem,
)
try:
@ -691,7 +690,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
choices: Final[list[Choices]] = []
index = 0
reasoning_content: str | None = None
pending_reasoning_item: _BuiltReasoningItem | None = None
pending_reasoning_items: Final[list[_BuiltReasoningItem]] = [] # mutable-ok: accumulator
# Collect all tool calls to put them in a single choice
# (Chat Completions API expects all tool calls in one message)
@ -699,13 +698,17 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
tool_call_index = 0
for item in output_items:
if isinstance(item, ResponseReasoningItem):
pending_reasoning_item = _build_reasoning_item(
item_id=item.id,
encrypted_content=getattr(item, "encrypted_content", None),
summary_raw=item.summary,
)
reasoning_content = " ".join(s["text"] for s in pending_reasoning_item["summary"] if s.get("text"))
reasoning_item: Final = _reasoning_item_from_output_item(item)
if reasoning_item is not None:
pending_reasoning_items.append(reasoning_item)
summary_texts: Final = [s["text"] for s in reasoning_item["summary"] if s.get("text")]
if summary_texts:
step_text: Final = " ".join(summary_texts)
reasoning_content = (
f"{reasoning_content} {step_text}".strip()
if reasoning_content
else step_text
)
elif isinstance(item, ResponseOutputMessage):
for content in item.content:
@ -720,10 +723,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
content=response_text if response_text else "",
reasoning_content=reasoning_content,
annotations=annotations,
reasoning_items=cast(
list[ChatCompletionReasoningItem] | None,
([pending_reasoning_item] if pending_reasoning_item is not None else None),
),
reasoning_items=_as_chat_reasoning_items(pending_reasoning_items),
)
choices.append(
@ -735,7 +735,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
)
reasoning_content = None # flush
pending_reasoning_item = None # flush
pending_reasoning_items.clear() # flush
index += 1
elif isinstance(item, ResponseFunctionToolCall):
@ -790,14 +790,11 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
content=None,
tool_calls=accumulated_tool_calls,
reasoning_content=reasoning_content,
reasoning_items=cast(
list[ChatCompletionReasoningItem] | None,
([pending_reasoning_item] if pending_reasoning_item is not None else None),
),
reasoning_items=_as_chat_reasoning_items(pending_reasoning_items),
)
choices.append(Choices(message=msg, finish_reason="tool_calls", index=index))
reasoning_content = None
pending_reasoning_item = None
pending_reasoning_items.clear()
return choices

View file

@ -4352,3 +4352,111 @@ def test_map_optional_params_verbosity_merges_into_text():
verbosity_only_request,
)
assert verbosity_only_request["text"] == {"verbosity": "low"}
@pytest.mark.parametrize(
"raw_output_items, expected_reasoning_ids, expected_summary_text, target_type",
[
pytest.param(
[
{
"type": "reasoning",
"id": "rs_1",
"summary": [{"type": "summary_text", "text": "Step 1"}],
"encrypted_content": "enc1",
},
{
"type": "reasoning",
"id": "rs_2",
"summary": [{"type": "summary_text", "text": "Step 2"}],
"encrypted_content": "enc2",
},
{
"type": "function_call",
"id": "fc_1",
"call_id": "call_1",
"name": "search",
"arguments": "{}",
"status": "completed",
},
],
["rs_1", "rs_2"],
"Step 1 Step 2",
"tool_calls",
id="multiple_reasoning_items_before_tool_call",
),
pytest.param(
[
{
"type": "reasoning",
"id": "rs_alpha",
"summary": [{"type": "summary_text", "text": "Analysis"}],
"encrypted_content": "enc_a",
},
{
"type": "reasoning",
"id": "rs_beta",
"summary": [{"type": "summary_text", "text": "Conclusion"}],
"encrypted_content": "enc_b",
},
],
["rs_alpha", "rs_beta"],
"Analysis Conclusion",
"message",
id="multiple_reasoning_items_before_output_message",
),
pytest.param(
[
{
"type": "function_call",
"id": "fc_solo",
"call_id": "call_solo",
"name": "lookup",
"arguments": "{}",
"status": "completed",
}
],
None,
None,
"tool_calls",
id="zero_reasoning_items_returns_none",
),
],
)
def test_convert_response_output_to_choices_preserves_all_reasoning_items(
raw_output_items: list[dict[str, object]],
expected_reasoning_ids: list[str] | None,
expected_summary_text: str | None,
target_type: str,
) -> None:
"""Verify non-streaming Responses API bridge retains all reasoning items across message and tool call turns."""
from openai.types.responses import ResponseOutputMessage
from openai.types.responses.response_output_message import ResponseOutputText
items_to_pass: Final[list[object]] = list(raw_output_items) # mutable-ok: fixture setup
if target_type == "message":
items_to_pass.append(
ResponseOutputMessage(
id="msg_1",
role="assistant",
status="completed",
type="message",
content=[ResponseOutputText(annotations=[], text="Done", type="output_text", logprobs=[])],
)
)
handler: Final = LiteLLMResponsesTransformationHandler()
choices: Final = handler._convert_response_output_to_choices(items_to_pass)
assert len(choices) == 1
msg = choices[0].message
reasoning_items = getattr(msg, "reasoning_items", None)
reasoning_content = getattr(msg, "reasoning_content", None)
if expected_reasoning_ids is None:
assert reasoning_items is None
assert reasoning_content is None
else:
assert reasoning_items is not None
assert [item["id"] for item in reasoning_items] == expected_reasoning_ids
assert reasoning_content == expected_summary_text