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:
voidborne-d 2026-04-02 19:09:16 +00:00
parent d1df4e838b
commit 1c5bc89467
4 changed files with 132 additions and 2 deletions

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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