fix(responses-bridge): preserve reasoning input items as reasoning_content

This commit is contained in:
HarryZhou 2026-08-09 22:59:25 +08:00
parent 8941f2a622
commit b6ee13803d
2 changed files with 310 additions and 1 deletions

View file

@ -557,7 +557,108 @@ class LiteLLMCompletionResponsesConfig:
continue
messages.extend(chat_completion_messages)
return messages
return LiteLLMCompletionResponsesConfig._merge_reasoning_only_assistant_messages(messages)
@staticmethod
def _merge_reasoning_only_assistant_messages(
messages: list[
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionMessageToolCall
| ChatCompletionResponseMessage
],
) -> list[
AllMessageValues | GenericChatCompletionMessage | ChatCompletionMessageToolCall | ChatCompletionResponseMessage
]:
"""
Responses API emits prior-turn reasoning as its own ``reasoning`` input
item, which becomes a standalone assistant message with
``content=None`` + ``reasoning_content``. Chat-completions providers
(e.g. DeepSeek V4, Kimi K2.6) expect the chain-of-thought on the
assistant message that carries the answer or tool calls. This pass
merges standalone reasoning-only assistant messages into the
immediately following assistant message.
If the reasoning item is not followed by an assistant message (e.g. a
stateless chain replays ``reasoning`` + ``user``), the standalone
reasoning message is preserved so the reasoning is still passed back.
"""
def _role(msg: Any) -> str:
if isinstance(msg, dict):
return str(msg.get("role") or "")
return str(getattr(msg, "role", "") or "")
def _reasoning_text(msg: Any) -> str | None:
if isinstance(msg, dict):
value = msg.get("reasoning_content")
else:
value = getattr(msg, "reasoning_content", None)
return value if isinstance(value, str) and value else None
def _content(msg: Any) -> Any:
if isinstance(msg, dict):
return msg.get("content")
return getattr(msg, "content", None)
def _tool_calls(msg: Any) -> Any:
if isinstance(msg, dict):
return msg.get("tool_calls")
return getattr(msg, "tool_calls", None)
merged: list[
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionMessageToolCall
| ChatCompletionResponseMessage
] = []
pending_reasoning: list[str] = []
for msg in messages:
if (
_role(msg) == "assistant"
and _content(msg) is None
and not _tool_calls(msg)
and _reasoning_text(msg) is not None
):
pending_reasoning.append(_reasoning_text(msg) or "")
continue
if pending_reasoning and _role(msg) == "assistant":
combined = "\n".join(pending_reasoning)
existing = _reasoning_text(msg)
if existing:
combined = existing + "\n" + combined
if isinstance(msg, dict):
msg["reasoning_content"] = combined
else:
setattr(msg, "reasoning_content", combined)
pending_reasoning = []
elif pending_reasoning:
# Not followed by an assistant message — keep the reasoning
# standalone instead of dropping it.
for text in pending_reasoning:
merged.append(
ChatCompletionResponseMessage(
role="assistant",
content=None,
reasoning_content=text,
)
)
pending_reasoning = []
merged.append(msg)
for text in pending_reasoning:
merged.append(
ChatCompletionResponseMessage(
role="assistant",
content=None,
reasoning_content=text,
)
)
return merged
@staticmethod
def _merged_trailing_assistant_message(
@ -1026,6 +1127,25 @@ class LiteLLMCompletionResponsesConfig:
return LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
function_call=input_item
)
elif input_item.get("type") == "reasoning":
# A ResponseReasoningItemParam carries the prior-turn chain-of-thought.
# Chat-completions providers (DeepSeek V4, Kimi K2.6, ...) expect this
# to be replayed as `reasoning_content` on an assistant message, not as
# visible `content` (prompt pollution) and not dropped (DeepSeek V4
# rejects multi-turn requests with a missing `reasoning_content`).
reasoning_text = LiteLLMCompletionResponsesConfig._extract_reasoning_text_from_input_item(input_item)
if not reasoning_text:
# No plaintext reasoning is available (e.g. encrypted_content only).
# Chat-completions providers cannot consume opaque encrypted blobs,
# so skip the item instead of polluting the prompt.
return []
return [
ChatCompletionResponseMessage(
role="assistant",
content=None,
reasoning_content=reasoning_text,
)
]
else:
content: Final[object] = input_item.get("content")
# Handle None content: Responses API allows None content, but GenericChatCompletionMessage requires content
@ -1041,6 +1161,48 @@ class LiteLLMCompletionResponsesConfig:
)
]
@staticmethod
def _extract_reasoning_text_from_input_item(input_item: Mapping[str, object]) -> str | None:
"""
Extract plaintext reasoning from a ResponseReasoningItemParam.
Handles:
- content as a string
- content as a list of blocks (output_text / summary_text / text)
- summary as a list of summary_text blocks (fallback)
Returns None when only opaque forms (e.g. encrypted_content) are present.
"""
content: Final[object] = input_item.get("content")
if isinstance(content, str) and content.strip():
return content
if isinstance(content, list):
text_parts: list[str] = []
for block in content:
if not isinstance(block, Mapping):
continue
block_type = block.get("type")
if block_type in ("encrypted_content", "redacted_thinking"):
continue
text = block.get("text")
if isinstance(text, str) and text.strip():
text_parts.append(text.strip())
if text_parts:
return "\n".join(text_parts)
summary: Final[object] = input_item.get("summary")
if isinstance(summary, list):
text_parts = []
for block in summary:
if not isinstance(block, Mapping):
continue
text = block.get("text")
if isinstance(text, str) and text.strip():
text_parts.append(text.strip())
if text_parts:
return "\n".join(text_parts)
return None
@staticmethod
def _is_input_item_tool_call_output(input_item: Mapping[str, object]) -> bool:
"""

View file

@ -0,0 +1,147 @@
"""
Unit tests for preserving prior-turn ``reasoning`` input items when the
Responses API is bridged to chat completions.
Without this handling, a ``ResponseReasoningItemParam`` falls through to the
generic message branch, polluting the prompt as visible assistant ``content``
or being silently dropped. Chat-completions providers such as DeepSeek V4 and
Kimi K2.6 require the chain-of-thought to be replayed as ``reasoning_content``
on an assistant message.
"""
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
def _transform_item(item):
return LiteLLMCompletionResponsesConfig._transform_responses_api_input_item_to_chat_completion_message(
input_item=item
)
def _transform_input(input_items):
return LiteLLMCompletionResponsesConfig._transform_response_input_param_to_chat_completion_message(
input=input_items
)
class TestReasoningInputItemHandler:
"""Reasoning input items map to assistant ``reasoning_content``."""
def test_reasoning_item_with_output_text_content(self):
"""Standard Responses-API reasoning item with output_text blocks."""
item = {
"type": "reasoning",
"id": "rs_abc",
"summary": [],
"content": [{"type": "output_text", "text": "step 1: think about X"}],
}
messages = _transform_item(item)
assert len(messages) == 1
assert messages[0]["role"] == "assistant"
assert messages[0]["content"] is None
assert messages[0]["reasoning_content"] == "step 1: think about X"
def test_reasoning_item_with_string_content(self):
"""Variant: reasoning content as a plain string."""
item = {"type": "reasoning", "id": "rs_1", "content": "step 1: ..."}
messages = _transform_item(item)
assert messages[0]["reasoning_content"] == "step 1: ..."
def test_reasoning_item_with_summary_only(self):
"""SDK form: reasoning carried in summary list, no content."""
item = {
"type": "reasoning",
"id": "rs_2",
"summary": [{"type": "summary_text", "text": "..."}],
}
messages = _transform_item(item)
assert messages[0]["reasoning_content"] == "..."
def test_reasoning_item_with_encrypted_content_only_dropped(self):
"""Opaque encrypted reasoning cannot be forwarded to chat completions."""
item = {"type": "reasoning", "id": "rs_3", "encrypted_content": "opaque-blob"}
assert _transform_item(item) == []
def test_reasoning_item_empty_dropped(self):
"""Reasoning item with neither content nor summary drops cleanly."""
assert _transform_item({"type": "reasoning", "id": "rs_4"}) == []
class TestReasoningInputItemMerging:
"""Standalone reasoning messages merge into the following assistant turn."""
def test_reasoning_merged_into_following_assistant_message(self):
"""Reasoning + assistant answer become one assistant message."""
messages = _transform_input(
[
{
"type": "reasoning",
"id": "rs_1",
"content": [{"type": "output_text", "text": "secret reasoning"}],
},
{"type": "message", "role": "assistant", "content": "The answer."},
]
)
assert len(messages) == 1
assert messages[0]["role"] == "assistant"
assert messages[0]["content"] == "The answer."
assert messages[0]["reasoning_content"] == "secret reasoning"
def test_reasoning_preserved_when_followed_by_user_message(self):
"""Stateless chain: reasoning + user prompt keeps the reasoning turn."""
messages = _transform_input(
[
{
"type": "reasoning",
"id": "rs_1",
"content": [{"type": "output_text", "text": "secret BLUEBERRY"}],
},
{"role": "user", "content": "What is the secret word?"},
]
)
assert len(messages) == 2
assert messages[0]["role"] == "assistant"
assert messages[0]["content"] is None
assert messages[0]["reasoning_content"] == "secret BLUEBERRY"
assert messages[1]["role"] == "user"
def test_reasoning_merged_into_function_call_assistant(self):
"""Reasoning + function_call becomes one assistant tool-call message."""
messages = _transform_input(
[
{
"type": "reasoning",
"id": "rs_1",
"content": [{"type": "output_text", "text": "I should look this up"}],
},
{
"type": "function_call",
"call_id": "call_1",
"name": "lookup",
"arguments": '{"cwe": "79"}',
},
]
)
assert len(messages) == 1
assert messages[0]["role"] == "assistant"
assert messages[0]["reasoning_content"] == "I should look this up"
assert len(messages[0]["tool_calls"]) == 1
class TestNonReasoningInputItemUnchanged:
"""Non-reasoning items still flow through the existing branches."""
def test_user_message_unchanged(self):
item = {"role": "user", "content": "hello"}
out = _transform_item(item)
assert len(out) == 1
assert out[0]["role"] == "user"
def test_assistant_message_unchanged(self):
item = {"role": "assistant", "content": "hi"}
out = _transform_item(item)
assert len(out) == 1
assert out[0]["role"] == "assistant"
assert out[0]["content"] == "hi"