From 16f4648269b57aea94792349c890681c379d0797 Mon Sep 17 00:00:00 2001 From: strawgate Date: Fri, 8 May 2026 22:11:27 -0500 Subject: [PATCH] fix(adapter): handle GenericResponseOutputItem in translate_response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When use_chat_completions_api: true bridges to chat completions, the response output contains GenericResponseOutputItem (Pydantic models) instead of OpenAI SDK native types. translate_response() had no branch for these — the isinstance(item, dict) fallback doesn't match because Pydantic v2 BaseModel subclasses are not dicts. Content was silently dropped for any model using the chat completions bridge. Add an isinstance(item, GenericResponseOutputItem) branch handling both 'message' (-> text block) and 'reasoning' (-> thinking block) types. Tests: 3 new tests using real Pydantic GenericResponseOutputItem instances. Existing tests used MagicMock which passes isinstance() for any type. --- .../responses_adapters/transformation.py | 40 ++++++- .../test_responses_adapters_transformation.py | 106 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) 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 2badc2a3276..8e0bd4e0ec6 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -412,7 +412,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: # Response translation: Responses API -> Anthropic # # ------------------------------------------------------------------ # - def translate_response( + def translate_response( # noqa: PLR0915 self, response: ResponsesAPIResponse, ) -> AnthropicMessagesResponse: @@ -426,6 +426,10 @@ class LiteLLMAnthropicToResponsesAPIAdapter: ) from litellm.types.llms.openai import ResponseAPIUsage + from litellm.types.responses.main import ( + GenericResponseOutputItem, + OutputText, + ) content: List[Dict[str, Any]] = [] stop_reason: AnthropicFinishReason = "end_turn" @@ -467,6 +471,40 @@ class LiteLLMAnthropicToResponsesAPIAdapter: ) stop_reason = "tool_use" + elif isinstance(item, GenericResponseOutputItem): + if item.type == "reasoning": + for part in (item.content or []): + if isinstance(part, OutputText) and part.text: + content.append( + AnthropicResponseContentBlockThinking( + type="thinking", + thinking=part.text, + signature=None, + ).model_dump() + ) + elif item.type == "message": + for part in (item.content or []): + if isinstance(part, OutputText) and part.text: + content.append( + AnthropicResponseContentBlockText( + type="text", text=part.text + ).model_dump() + ) + elif item.type == "function_call": + try: + input_data = json.loads(item.arguments) if hasattr(item, "arguments") and item.arguments else {} + except (json.JSONDecodeError, TypeError): + input_data = {} + content.append( + AnthropicResponseContentBlockToolUse( + type="tool_use", + id=getattr(item, "call_id", "") or getattr(item, "id", ""), + name=getattr(item, "name", ""), + input=input_data, + ).model_dump() + ) + stop_reason = "tool_use" + elif isinstance(item, dict): item_type = item.get("type") if item_type == "message": 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..7f2e84ada3c 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 @@ -9,6 +9,11 @@ import sys from typing import Any, Dict, List from unittest.mock import MagicMock +from litellm.types.responses.main import ( + GenericResponseOutputItem, + OutputText, +) + sys.path.insert(0, os.path.abspath("../../../../../../..")) from litellm.llms.anthropic.experimental_pass_through.responses_adapters.transformation import ( @@ -1043,3 +1048,104 @@ class TestTranslateResponse: assert "text" in types assert "tool_use" in types assert result["stop_reason"] == "tool_use" + + # ------------------------------------------------------------------ # + # Real GenericResponseOutputItem (Pydantic) tests # + # ------------------------------------------------------------------ # + # These exercise the path taken when use_chat_completions_api: true + # bridges to chat completions. The chat-completion bridge produces + # GenericResponseOutputItem Pydantic instances, NOT OpenAI SDK types + # or plain dicts. MagicMock passes isinstance(item, X) for any X, + # so mock-only tests could never catch the Pydantic-vs-dict mismatch. + # ------------------------------------------------------------------ # + + def _make_real_response(self, output: list) -> Any: + """Build a real ResponsesAPIResponse with the given output items.""" + from litellm.types.llms.openai import ResponsesAPIResponse + + return ResponsesAPIResponse( + id="resp_real", + output=output, + created_at=0, + model="test", + object="response", + status="completed", + ) + + def test_generic_output_item_message_pydantic(self): + """GenericResponseOutputItem (type=message) Pydantic -> text block.""" + item = GenericResponseOutputItem( + type="message", + id="msg_1", + status="completed", + role="assistant", + content=[ + OutputText( + type="output_text", + text="Hello from Pydantic!", + annotations=[], + ) + ], + ) + response = self._make_real_response(output=[item]) + result: Any = _ADAPTER.translate_response(response) + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Hello from Pydantic!" + + def test_generic_output_item_reasoning_pydantic(self): + """GenericResponseOutputItem (type=reasoning) Pydantic -> thinking block.""" + item = GenericResponseOutputItem( + type="reasoning", + id="rs_1", + status="completed", + role="assistant", + content=[ + OutputText( + type="output_text", + text="I need to think about this first.", + annotations=[], + ) + ], + ) + response = self._make_real_response(output=[item]) + result: Any = _ADAPTER.translate_response(response) + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "thinking" + assert "think" in result["content"][0]["thinking"] + + def test_generic_output_item_reasoning_plus_message_pydantic(self): + """Reasoning + message GenericResponseOutputItem -> thinking + text.""" + reasoning = GenericResponseOutputItem( + type="reasoning", + id="rs_1", + status="completed", + role="assistant", + content=[OutputText( + type="output_text", + text="Let me reason step by step.", + annotations=[], + )], + ) + message = GenericResponseOutputItem( + type="message", + id="msg_1", + status="completed", + role="assistant", + content=[OutputText( + type="output_text", + text="The answer is 42.", + annotations=[], + )], + ) + response = self._make_real_response(output=[reasoning, message]) + result: Any = _ADAPTER.translate_response(response) + types = [b["type"] for b in result["content"]] + assert "thinking" in types + assert "text" in types + texts = { + b["type"]: b.get("text") or b.get("thinking", "") + for b in result["content"] + } + assert "Let me reason" in texts["thinking"] + assert "answer is 42" in texts["text"]