diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index ed49943b7fe..a3917256013 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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 diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index dae7044a5bc..6dd1e07a626 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -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( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index ae970e1ff06..3d7ba166ac2 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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 diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index 02b817cd334..81d628ce96f 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -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