fix(adapter): handle GenericResponseOutputItem in translate_response

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.
This commit is contained in:
strawgate 2026-05-08 22:11:27 -05:00
parent fa81017e12
commit 16f4648269
2 changed files with 145 additions and 1 deletions

View file

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

View file

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