mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
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:
parent
c8635ecc67
commit
200e2901d6
5 changed files with 138 additions and 31 deletions
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] == [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue