fix(anthropic_responses): preserve Responses refusal blocks in Anthropic translation

When OpenAI Responses returns a refusal content block, Anthropic /v1/messages
erased the refusal text into an empty content array and emitted stop_reason 'end_turn'.
Translate refusal blocks to Anthropic text blocks, map stop_reason to 'refusal',
and add 'refusal' to AnthropicFinishReason.

Fixes #39721
This commit is contained in:
Atharva-Kanherkar 2026-09-04 14:33:10 +05:30
parent c8635ecc67
commit 200e2901d6
5 changed files with 138 additions and 31 deletions

View file

@ -111,7 +111,7 @@ class AnthropicResponsesStreamWrapper:
item_type: Final = getattr(item, "type", None) or (item.get("type") if isinstance(item, dict) else None)
item_id = getattr(item, "id", None) or (item.get("id") if isinstance(item, dict) else None)
if item_type == "message":
if item_type in ("message", "refusal"):
self._open_block(item_id, {"type": "text", "text": ""})
elif item_type == "function_call":
call_id: Final = (
@ -132,7 +132,7 @@ class AnthropicResponsesStreamWrapper:
return
# ---- text delta ----
if event_type == "response.output_text.delta":
if event_type in ("response.output_text.delta", "response.refusal.delta"):
item_id = getattr(event, "item_id", None) or (event.get("item_id") if isinstance(event, dict) else None)
delta = getattr(event, "delta", "") or (event.get("delta", "") if isinstance(event, dict) else "")
block_idx = self._item_id_to_block_index.get(item_id, -1) if item_id else self._current_block_index
@ -238,6 +238,24 @@ class AnthropicResponsesStreamWrapper:
if out_type == "function_call":
stop_reason = "tool_use"
break
elif out_type == "refusal":
stop_reason = "refusal"
break
elif out_type == "message":
content_parts = getattr(out_item, "content", ()) or (
out_item.get("content") or () if isinstance(out_item, dict) else ()
)
for part in content_parts:
part_type = getattr(part, "type", None) or (
part.get("type") if isinstance(part, dict) else None
)
if (
part_type == "refusal"
or hasattr(part, "refusal")
or (isinstance(part, dict) and "refusal" in part)
):
stop_reason = "refusal"
break
self._chunk_queue.append(
{

View file

@ -631,10 +631,18 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
elif isinstance(item, ResponseOutputMessage):
for part in item.content:
if getattr(part, "type", None) == "output_text":
part_type = getattr(part, "type", None)
if part_type == "output_text":
content.append(
AnthropicResponseContentBlockText(type="text", text=getattr(part, "text", "")).model_dump()
)
elif part_type == "refusal" or hasattr(part, "refusal"):
content.append(
AnthropicResponseContentBlockText(
type="text", text=getattr(part, "refusal", "") or ""
).model_dump()
)
stop_reason = "refusal"
elif isinstance(item, ResponseFunctionToolCall):
try:
@ -654,11 +662,22 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
elif isinstance(item, dict):
item_type = item.get("type")
if item_type == "message":
for part in item.get("content", []):
if isinstance(part, dict) and part.get("type") == "output_text":
content.append(
AnthropicResponseContentBlockText(type="text", text=part.get("text", "")).model_dump()
)
for part in item.get("content", ()):
if isinstance(part, dict):
part_type = part.get("type")
if part_type == "output_text":
content.append(
AnthropicResponseContentBlockText(
type="text", text=part.get("text", "")
).model_dump()
)
elif part_type == "refusal" or "refusal" in part:
content.append(
AnthropicResponseContentBlockText(
type="text", text=part.get("refusal", "") or ""
).model_dump()
)
stop_reason = "refusal"
elif item_type == "reasoning":
content.extend(
self._thinking_blocks_from_reasoning_item(
@ -679,6 +698,10 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
).model_dump()
)
stop_reason = "tool_use"
elif item_type == "refusal":
refusal_text = item.get("refusal") or item.get("text", "") or ""
content.append(AnthropicResponseContentBlockText(type="text", text=refusal_text).model_dump())
stop_reason = "refusal"
# status -> stop_reason override
if response.status == "incomplete":

View file

@ -658,7 +658,7 @@ class AnthropicOutputTokensDetails(BaseModel):
thinking_tokens: int | None = None
AnthropicFinishReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"]
AnthropicFinishReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use", "refusal"]
class AnthropicResponse(BaseModel):

View file

@ -308,3 +308,37 @@ class TestResponseCompletedUsage:
"cache_creation_input_tokens": 10,
"cache_read_input_tokens": 4004,
}
class TestRefusalStreamEvents:
def test_refusal_delta_emits_text_delta(self):
chunks = _process_all(
[
{"type": "response.created"},
{"type": "response.refusal.delta", "item_id": "ref_1", "delta": "I cannot fulfill this."},
]
)
assert any(
c.get("type") == "content_block_delta" and c.get("delta", {}).get("text") == "I cannot fulfill this."
for c in chunks
)
def test_response_completed_with_refusal_sets_stop_reason_refusal(self):
response = SimpleNamespace(
status="completed",
output=[{"type": "message", "content": [{"type": "refusal", "refusal": "Policy violation"}]}],
usage=None,
)
chunks = _process_all([{"type": "response.completed", "response": response}])
message_delta = next(c for c in chunks if c["type"] == "message_delta")
assert message_delta["delta"]["stop_reason"] == "refusal"
def test_response_completed_with_standalone_refusal_item_sets_stop_reason_refusal(self):
response = SimpleNamespace(
status="completed",
output=[{"type": "refusal", "refusal": "Standalone refusal"}],
usage=None,
)
chunks = _process_all([{"type": "response.completed", "response": response}])
message_delta = next(c for c in chunks if c["type"] == "message_delta")
assert message_delta["delta"]["stop_reason"] == "refusal"

View file

@ -147,9 +147,7 @@ class TestOutputConfigStructuredOutput:
def test_output_config_format_explicit_strict_true_is_preserved(self):
"""Nested output_config.format with explicit strict=True is preserved."""
req = _make_request(
output_config={"format": {"type": "json_schema", "schema": self._SCHEMA, "strict": True}}
)
req = _make_request(output_config={"format": {"type": "json_schema", "schema": self._SCHEMA, "strict": True}})
kwargs = _ADAPTER.translate_request(req)
assert kwargs["text"]["format"]["strict"] is True
@ -1207,6 +1205,18 @@ def _make_output_message(texts: List[str]) -> MagicMock:
return msg
def _make_refusal_message(refusal_text: str) -> MagicMock:
from openai.types.responses import ResponseOutputMessage
part = MagicMock()
part.type = "refusal"
part.refusal = refusal_text
msg = MagicMock(spec=ResponseOutputMessage)
msg.content = [part]
return msg
def _make_function_call_item(call_id: str, name: str, arguments: str) -> MagicMock:
"""Build a mock ResponseFunctionToolCall."""
from openai.types.responses import ResponseFunctionToolCall # type: ignore[import]
@ -1279,6 +1289,38 @@ class TestTranslateResponse:
result: Any = _ADAPTER.translate_response(response)
assert result["stop_reason"] == "end_turn"
def test_refusal_part_becomes_text_block_and_sets_stop_reason_refusal(self):
response = _make_mock_response(output=[_make_refusal_message("I cannot fulfill this request.")])
result: Any = _ADAPTER.translate_response(response)
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert result["content"][0]["text"] == "I cannot fulfill this request."
assert result["stop_reason"] == "refusal"
def test_dict_refusal_part_in_message_becomes_text_block(self):
output_item = {
"type": "message",
"content": [{"type": "refusal", "refusal": "Refused by policy"}],
}
response = _make_mock_response(output=[output_item])
result: Any = _ADAPTER.translate_response(response)
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert result["content"][0]["text"] == "Refused by policy"
assert result["stop_reason"] == "refusal"
def test_dict_refusal_item_becomes_text_block(self):
output_item = {
"type": "refusal",
"refusal": "Standalone refusal",
}
response = _make_mock_response(output=[output_item])
result: Any = _ADAPTER.translate_response(response)
assert len(result["content"]) == 1
assert result["content"][0]["type"] == "text"
assert result["content"][0]["text"] == "Standalone refusal"
assert result["stop_reason"] == "refusal"
def test_incomplete_status_sets_max_tokens(self):
"""status='incomplete' overrides stop_reason to 'max_tokens'."""
response = _make_mock_response(
@ -1337,9 +1379,7 @@ class TestTranslateResponse:
]
)
result: Any = _ADAPTER.translate_response(response)
assert result["content"] == [
{"type": "thinking", "thinking": "Weighing the options.", "signature": None}
]
assert result["content"] == [{"type": "thinking", "thinking": "Weighing the options.", "signature": None}]
def test_thinking_blocks_are_dropped_when_replayed_to_anthropic(self):
"""Replaying this turn to an Anthropic model must not send a signature it cannot verify."""
@ -1481,9 +1521,7 @@ class TestToolResultImages:
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}
],
"content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}],
},
]
@ -1630,9 +1668,7 @@ class TestToolResultDocuments:
},
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}
],
"content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}],
},
]
@ -1665,9 +1701,7 @@ class TestToolResultDocuments:
def test_document_title_becomes_filename(self):
output = self._tool_output(self._translate([self._base64_document(title="quarterly-report.pdf")]))
assert output == [
{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}
]
assert output == [{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}]
def test_url_document_becomes_file_url_part(self):
output = self._tool_output(
@ -1776,9 +1810,7 @@ class TestUserContentDocuments:
def test_document_title_becomes_filename(self):
content = self._user_content(self._translate([self._base64_document(title="quarterly-report.pdf")]))
assert content == [
{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}
]
assert content == [{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}]
def test_url_document_becomes_file_url_part(self):
content = self._user_content(
@ -1808,9 +1840,7 @@ class TestUserContentDocuments:
assert content == [{"type": "input_text", "text": "still here"}]
def test_document_breakpoint_rides_on_the_file_part(self):
content = self._user_content(
self._translate([self._base64_document(prompt_cache_breakpoint=self.EXPLICIT)])
)
content = self._user_content(self._translate([self._base64_document(prompt_cache_breakpoint=self.EXPLICIT)]))
assert content == [
{
"type": "input_file",
@ -1857,7 +1887,9 @@ class TestPromptCacheBreakpointToResponses:
]
def test_system_without_breakpoint_still_becomes_instructions(self):
request = _make_request(system=[{"type": "text", "text": "Be concise."}, {"type": "text", "text": "Be helpful."}])
request = _make_request(
system=[{"type": "text", "text": "Be concise."}, {"type": "text", "text": "Be helpful."}]
)
kwargs = _ADAPTER.translate_request(request)
assert kwargs["instructions"] == "Be concise.\nBe helpful."
assert kwargs["input"] == [