mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
fix(responses-bridge): handle ResponseReasoningItemParam (drop) to prevent prompt pollution and DeepSeek V4 400
Without this fix, when the Responses-API bridges to Chat-Completions, a
prior-turn `ResponseReasoningItemParam` (`type: "reasoning"`) falls
through the `else` branch of
`_transform_responses_api_input_item_to_chat_completion_message` and gets
treated as a generic user/assistant content message. This has two bad
effects for downstream Chat-Completion providers:
1. **Prompt pollution** — the raw chain-of-thought text ends up in the
visible `content` field of an extra assistant message, inflating
tokens and confusing the model on the next turn.
2. **DeepSeek V4 400** — the adjacent assistant message has no
`reasoning_content` field. DeepSeek V4 (thinking-mode-by-default)
rejects this with HTTP 400:
reasoning_content must be passed back
This is the Responses-API arm of the bug originally reported on the
Chat-Completions arm in #26395.
## Fix
Add an explicit branch in
`_transform_responses_api_input_item_to_chat_completion_message` that
drops `type: "reasoning"` items (`return []`). They are then absent from
the Chat-Completion `messages` array entirely. Provider-specific
transformations downstream (e.g. `DeepSeekChatConfig._transform_messages`)
remain responsible for injecting an empty `reasoning_content` on the
adjacent assistant message when the provider requires it.
This is a **minimum-viable** fix: a future improvement could merge the
reasoning text into the adjacent assistant message's `reasoning_content`
field for full fidelity. This PR deliberately stops at the smallest
change that unblocks the bridge without changing semantics for other
providers.
## Tests
`tests/test_litellm/responses/litellm_completion_transformation/test_responses_reasoning_input_item.py` — 7 cases:
`TestReasoningInputItemHandler` (4 cases):
- reasoning item with output_text content → dropped
- reasoning item with plain string content → dropped
- reasoning item with summary-only (SDK 0.17 form) → dropped
- empty reasoning item (no content, no summary) → dropped
`TestNonReasoningInputItemUnchanged` (3 cases):
- user message still passes through
- assistant message still passes through
- None content still returns [] (pre-existing behavior)
```
$ pytest tests/test_litellm/responses/litellm_completion_transformation/test_responses_reasoning_input_item.py
7 passed in 0.99s
```
## Refs
- BerriAI/litellm#26395 (Responses-API arm)
- Original Chat-Completions arm: BerriAI/litellm#26660 (handles
`_transform_messages` injection on the DeepSeek-provider side)
This commit is contained in:
parent
e182a5e0ba
commit
c16c2fa433
2 changed files with 100 additions and 0 deletions
|
|
@ -982,6 +982,33 @@ class LiteLLMCompletionResponsesConfig:
|
|||
return LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
|
||||
function_call=input_item
|
||||
)
|
||||
elif input_item.get("type") == "reasoning":
|
||||
# FIX (BerriAI/litellm#26395 — Responses-API path):
|
||||
# ``ResponseReasoningItemParam`` carries the prior-turn
|
||||
# chain-of-thought summary. The fall-through ``else`` branch
|
||||
# below would treat it as generic user/assistant content and
|
||||
# place the reasoning text into the regular ``content``
|
||||
# field of an extra assistant message. That has two bad effects
|
||||
# for downstream Chat-Completion providers:
|
||||
# 1. The reasoning text pollutes the prompt as visible
|
||||
# content, inflating tokens and confusing the model.
|
||||
# 2. The adjacent assistant message ends up WITHOUT a
|
||||
# ``reasoning_content`` field — and DeepSeek V4 rejects
|
||||
# that with HTTP 400
|
||||
# "reasoning_content must be passed back".
|
||||
#
|
||||
# Minimum-viable fix: skip the reasoning item entirely (return
|
||||
# []) so it does not end up in the Chat-Completion ``messages``
|
||||
# array at all. Provider-specific transformations downstream
|
||||
# (e.g. ``DeepSeekChatConfig._transform_messages``) are then
|
||||
# responsible for injecting an empty ``reasoning_content`` on
|
||||
# the adjacent assistant message if the provider requires it.
|
||||
#
|
||||
# A future improvement could merge the reasoning text into the
|
||||
# adjacent assistant message's ``reasoning_content`` field for
|
||||
# full fidelity; this PR deliberately stops at the minimum
|
||||
# change that unblocks the bridge.
|
||||
return []
|
||||
else:
|
||||
content = input_item.get("content")
|
||||
# Handle None content: Responses API allows None content, but GenericChatCompletionMessage requires content
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
"""
|
||||
Unit tests for ``_transform_responses_api_input_item_to_chat_completion_message``
|
||||
handling of ``ResponseReasoningItemParam`` (``type: "reasoning"``).
|
||||
|
||||
Covers the Responses-API path of BerriAI/litellm#26395 — without the fix,
|
||||
prior-turn reasoning items pollute the prompt as visible content and leave
|
||||
the adjacent assistant message without ``reasoning_content``, which DeepSeek
|
||||
V4 rejects with HTTP 400 ``reasoning_content must be passed back``.
|
||||
"""
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class TestReasoningInputItemHandler:
|
||||
"""Reasoning items are dropped from the Chat-Completion message stream."""
|
||||
|
||||
def test_reasoning_item_with_output_text_dropped(self):
|
||||
"""Standard Responses-API reasoning item shape → []."""
|
||||
item = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_abc",
|
||||
"summary": [],
|
||||
"content": [{"type": "output_text", "text": "step 1: think about X"}],
|
||||
}
|
||||
assert _transform_item(item) == []
|
||||
|
||||
def test_reasoning_item_with_string_content_dropped(self):
|
||||
"""Variant: reasoning content as a plain string → []."""
|
||||
item = {"type": "reasoning", "id": "rs_1", "content": "step 1: ..."}
|
||||
assert _transform_item(item) == []
|
||||
|
||||
def test_reasoning_item_with_summary_only_dropped(self):
|
||||
"""SDK 0.17 form: reasoning carried in summary list, no content → []."""
|
||||
item = {
|
||||
"type": "reasoning",
|
||||
"id": "rs_2",
|
||||
"summary": [{"type": "summary_text", "text": "..."}],
|
||||
}
|
||||
assert _transform_item(item) == []
|
||||
|
||||
def test_reasoning_item_empty_dropped(self):
|
||||
"""Reasoning item with neither content nor summary still drops cleanly."""
|
||||
item = {"type": "reasoning", "id": "rs_3"}
|
||||
assert _transform_item(item) == []
|
||||
|
||||
|
||||
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].get("role") == "user"
|
||||
|
||||
def test_assistant_message_unchanged(self):
|
||||
item = {"role": "assistant", "content": "hi"}
|
||||
out = _transform_item(item)
|
||||
assert len(out) == 1
|
||||
assert out[0].get("role") == "assistant"
|
||||
|
||||
def test_none_content_still_returns_empty(self):
|
||||
"""Pre-existing behavior: None content → [] (unchanged by this fix)."""
|
||||
item = {"role": "user", "content": None}
|
||||
assert _transform_item(item) == []
|
||||
Loading…
Add table
Reference in a new issue