mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(responses-bridge): preserve reasoning input items as reasoning_content
This commit is contained in:
parent
8941f2a622
commit
b6ee13803d
2 changed files with 310 additions and 1 deletions
|
|
@ -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:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue