mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
fix(anthropic): round-trip thinking blocks in multi-turn conversations
Fixes #24985. Two bugs in the Anthropic pass-through adapter: 1. Responses API path: thinking blocks became output_text inside the assistant message instead of top-level reasoning items. This lost the thinking/response boundary. 2. Chat Completions path: thinking_blocks were set but reasoning_content was not, causing backends like Kimi to reject the turn with 'reasoning_content is missing'. Fix 1: Convert thinking blocks to top-level reasoning items with signature as id and thinking text as summary_text. Fix 2: After setting thinking_blocks, also compute and set reasoning_content by joining all thinking block texts. Added 5 new test cases covering both paths.
This commit is contained in:
parent
d1df4e838b
commit
1c5bc89467
4 changed files with 132 additions and 2 deletions
|
|
@ -657,6 +657,16 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
assistant_message["tool_calls"] = tool_calls # type: ignore
|
||||
if len(thinking_blocks) > 0:
|
||||
assistant_message["thinking_blocks"] = thinking_blocks # type: ignore
|
||||
# Also set reasoning_content so backends that
|
||||
# rely on it (e.g. Kimi) don't reject the turn
|
||||
# with "reasoning_content is missing".
|
||||
reasoning_text = "".join(
|
||||
block.get("thinking", "")
|
||||
for block in thinking_blocks
|
||||
if block.get("type") == "thinking"
|
||||
)
|
||||
if reasoning_text:
|
||||
assistant_message["reasoning_content"] = reasoning_text # type: ignore
|
||||
new_messages.append(assistant_message)
|
||||
|
||||
return new_messages
|
||||
|
|
|
|||
|
|
@ -168,8 +168,22 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
elif btype == "thinking":
|
||||
thinking_text = block.get("thinking", "")
|
||||
if thinking_text:
|
||||
asst_parts.append(
|
||||
{"type": "output_text", "text": thinking_text}
|
||||
# Thinking blocks must become top-level
|
||||
# reasoning items, not output_text inside
|
||||
# the assistant message. Otherwise the
|
||||
# provider sees two output_text blocks
|
||||
# and loses the thinking/response boundary.
|
||||
input_items.append(
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": block.get("signature") or "",
|
||||
"summary": [
|
||||
{
|
||||
"type": "summary_text",
|
||||
"text": thinking_text,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
if asst_parts:
|
||||
input_items.append(
|
||||
|
|
|
|||
|
|
@ -2111,3 +2111,43 @@ class TestTranslateAnthropicOutputFormatToOpenAI:
|
|||
assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None
|
||||
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None
|
||||
assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None
|
||||
|
||||
|
||||
class TestThinkingBlocksReasoningContent:
|
||||
"""Verify that thinking_blocks also sets reasoning_content."""
|
||||
|
||||
def test_reasoning_content_set_when_thinking_blocks_present(self):
|
||||
"""When thinking_blocks are set, reasoning_content should also be populated."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Explain relativity.",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me think step by step..."},
|
||||
{"type": "text", "text": "Relativity is..."},
|
||||
],
|
||||
},
|
||||
]
|
||||
result = adapter.translate_anthropic_messages_to_chat_completion(messages)
|
||||
asst_msgs = [m for m in result if m.get("role") == "assistant"]
|
||||
assert len(asst_msgs) == 1
|
||||
asst = asst_msgs[0]
|
||||
assert asst.get("thinking_blocks") is not None
|
||||
assert len(asst["thinking_blocks"]) == 1
|
||||
assert asst.get("reasoning_content") == "Let me think step by step..."
|
||||
|
||||
def test_no_reasoning_content_without_thinking(self):
|
||||
"""When no thinking blocks, reasoning_content should not be set."""
|
||||
adapter = LiteLLMAnthropicMessagesAdapter()
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": [{"type": "text", "text": "Hi!"}]},
|
||||
]
|
||||
result = adapter.translate_anthropic_messages_to_chat_completion(messages)
|
||||
asst_msgs = [m for m in result if m.get("role") == "assistant"]
|
||||
assert len(asst_msgs) == 1
|
||||
assert asst_msgs[0].get("reasoning_content") is None
|
||||
|
|
|
|||
|
|
@ -1043,3 +1043,69 @@ class TestTranslateResponse:
|
|||
assert "text" in types
|
||||
assert "tool_use" in types
|
||||
assert result["stop_reason"] == "tool_use"
|
||||
|
||||
|
||||
class TestThinkingBlockRoundtrip:
|
||||
"""Verify that thinking blocks become reasoning items, not output_text."""
|
||||
|
||||
def test_thinking_becomes_reasoning_item(self):
|
||||
"""Thinking block in assistant history must become a top-level reasoning item."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Explain quantum entanglement."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Let me reason about this...", "signature": "sig_abc"},
|
||||
{"type": "text", "text": "Quantum entanglement is..."},
|
||||
],
|
||||
},
|
||||
]
|
||||
items = _ADAPTER.translate_messages_to_responses_input(messages)
|
||||
# Should have: user message, reasoning item, assistant message
|
||||
types = [item["type"] for item in items]
|
||||
assert "reasoning" in types, f"Expected reasoning item, got types: {types}"
|
||||
assert "output_text" not in str(items) or all(
|
||||
"output_text" in str(item) for item in items if item["type"] == "message" and item["role"] == "assistant"
|
||||
)
|
||||
|
||||
reasoning_item = next(i for i in items if i["type"] == "reasoning")
|
||||
assert reasoning_item["id"] == "sig_abc"
|
||||
assert reasoning_item["summary"][0]["type"] == "summary_text"
|
||||
assert reasoning_item["summary"][0]["text"] == "Let me reason about this..."
|
||||
|
||||
def test_thinking_not_inside_assistant_message(self):
|
||||
"""Thinking text must NOT appear as output_text inside the assistant message."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "My internal thought"},
|
||||
{"type": "text", "text": "My visible response"},
|
||||
],
|
||||
},
|
||||
]
|
||||
items = _ADAPTER.translate_messages_to_responses_input(messages)
|
||||
# Find the assistant message
|
||||
asst_messages = [i for i in items if i.get("type") == "message" and i.get("role") == "assistant"]
|
||||
assert len(asst_messages) == 1
|
||||
asst_content = asst_messages[0]["content"]
|
||||
# Assistant message should only contain the visible response
|
||||
assert len(asst_content) == 1
|
||||
assert asst_content[0]["text"] == "My visible response"
|
||||
|
||||
def test_empty_thinking_text_skipped(self):
|
||||
"""Empty thinking text should not produce a reasoning item."""
|
||||
messages = [
|
||||
{"role": "user", "content": "Hi"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": ""},
|
||||
{"type": "text", "text": "Hello!"},
|
||||
],
|
||||
},
|
||||
]
|
||||
items = _ADAPTER.translate_messages_to_responses_input(messages)
|
||||
types = [item["type"] for item in items]
|
||||
assert "reasoning" not in types
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue