mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
fix(anthropic): keep provider_specific_fields off the native /v1/messages wire (#39967)
The chat and Responses bridges serialize tool_use blocks with model_dump(), so every bridged /v1/messages response carried LiteLLM's internal provider_specific_fields key (null, or a Gemini thought signature). Clients replay the block verbatim, and the next turn that lands on a native Anthropic deployment (auto-router tier change, model swap) is rejected with "tool_use.provider_specific_fields: Extra inputs are not permitted" Strip the key from replayed content blocks at the single native Anthropic dispatch so already-poisoned transcripts self-heal on every native provider, and stop emitting the null on new responses. The bridges keep reading the signature for the Gemini round trip Closes #19739
This commit is contained in:
parent
a9f8a8d794
commit
01680d7b42
7 changed files with 82 additions and 8 deletions
|
|
@ -1411,6 +1411,25 @@ def flatten_unencrypted_web_search_results_in_anthropic_messages( # mutable-ok:
|
|||
return [_flatten_web_search_results_in_message(m) for m in messages] # mutable-ok: JSON wire format
|
||||
|
||||
|
||||
def _without_provider_specific_fields(block: object) -> object:
|
||||
if not isinstance(block, dict) or "provider_specific_fields" not in block:
|
||||
return block
|
||||
return {k: v for k, v in block.items() if k != "provider_specific_fields"} # mutable-ok: JSON wire format
|
||||
|
||||
|
||||
def _strip_provider_specific_fields_in_message(message: object) -> object:
|
||||
if not isinstance(message, dict) or not isinstance(message.get("content"), list):
|
||||
return message
|
||||
content: Final = [_without_provider_specific_fields(b) for b in message["content"]] # mutable-ok: JSON wire format
|
||||
return {**message, "content": content} # mutable-ok: JSON wire format
|
||||
|
||||
|
||||
def strip_provider_specific_fields_from_anthropic_messages(
|
||||
messages: Sequence[object],
|
||||
) -> Sequence[object]:
|
||||
return [_strip_provider_specific_fields_in_message(m) for m in messages] # mutable-ok: JSON wire format
|
||||
|
||||
|
||||
def _normalized_cache_control(cache_control: object) -> dict[str, str] | None: # mutable-ok: JSON wire format
|
||||
if not isinstance(cache_control, Mapping):
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1346,7 +1346,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
# Add provider_specific_fields if signature is present
|
||||
if provider_specific_fields:
|
||||
tool_use_block.provider_specific_fields = provider_specific_fields
|
||||
new_content.append(tool_use_block.model_dump())
|
||||
new_content.append(tool_use_block.model_dump(exclude_none=True))
|
||||
|
||||
return new_content
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from litellm.llms.anthropic.common_utils import (
|
|||
flatten_unencrypted_web_search_results_in_anthropic_messages,
|
||||
sanitize_tool_use_ids_in_anthropic_messages,
|
||||
strip_empty_content_blocks_from_anthropic_messages,
|
||||
strip_provider_specific_fields_from_anthropic_messages,
|
||||
)
|
||||
from litellm.llms.base_llm.anthropic_messages.transformation import (
|
||||
BaseAnthropicMessagesConfig,
|
||||
|
|
@ -650,7 +651,7 @@ def anthropic_messages_handler(
|
|||
|
||||
return base_llm_http_handler.anthropic_messages_handler(
|
||||
model=model,
|
||||
messages=messages,
|
||||
messages=strip_provider_specific_fields_from_anthropic_messages(messages),
|
||||
anthropic_messages_provider_config=anthropic_messages_provider_config,
|
||||
anthropic_messages_optional_request_params=dict(anthropic_messages_optional_request_params),
|
||||
_is_async=is_async,
|
||||
|
|
|
|||
|
|
@ -647,7 +647,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
id=item.call_id or item.id or "",
|
||||
name=item.name,
|
||||
input=input_data,
|
||||
).model_dump()
|
||||
).model_dump(exclude_none=True)
|
||||
)
|
||||
stop_reason = "tool_use"
|
||||
|
||||
|
|
@ -676,7 +676,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
id=item.get("call_id") or item.get("id", ""),
|
||||
name=item.get("name", ""),
|
||||
input=input_data,
|
||||
).model_dump()
|
||||
).model_dump(exclude_none=True)
|
||||
)
|
||||
stop_reason = "tool_use"
|
||||
|
||||
|
|
|
|||
|
|
@ -798,6 +798,7 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments():
|
|||
assert (
|
||||
result[0]["input"] == {}
|
||||
), "Empty function arguments should result in empty dict"
|
||||
assert "provider_specific_fields" not in result[0]
|
||||
|
||||
|
||||
def test_translate_openai_content_to_anthropic_text_and_tool_calls():
|
||||
|
|
@ -843,6 +844,11 @@ def test_translate_openai_content_to_anthropic_strips_gemini_thought_from_tool_c
|
|||
base = "call_3e9417b7925e49aca9a71dc1885e"
|
||||
sig = "CiIBDDnWx+/a=="
|
||||
combined = f"{base}{THOUGHT_SIGNATURE_SEPARATOR}{sig}"
|
||||
function = Function(
|
||||
name="get_weather",
|
||||
arguments='{"location": "Boston"}',
|
||||
)
|
||||
function.provider_specific_fields = {"thought_signature": sig}
|
||||
openai_choices = [
|
||||
Choices(
|
||||
message=Message(
|
||||
|
|
@ -852,10 +858,7 @@ def test_translate_openai_content_to_anthropic_strips_gemini_thought_from_tool_c
|
|||
ChatCompletionAssistantToolCall(
|
||||
id=combined,
|
||||
type="function",
|
||||
function=Function(
|
||||
name="get_weather",
|
||||
arguments='{"location": "Boston"}',
|
||||
),
|
||||
function=function,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
|
@ -871,6 +874,7 @@ def test_translate_openai_content_to_anthropic_strips_gemini_thought_from_tool_c
|
|||
assert THOUGHT_SIGNATURE_SEPARATOR not in result[0]["id"]
|
||||
assert result[0]["name"] == "get_weather"
|
||||
assert result[0]["input"] == {"location": "Boston"}
|
||||
assert result[0]["provider_specific_fields"] == {"signature": sig}
|
||||
|
||||
|
||||
def test_translate_openai_content_to_anthropic_sanitizes_colon_dot_tool_call_ids():
|
||||
|
|
|
|||
|
|
@ -1072,6 +1072,54 @@ async def test_messages_strips_provider_prefix_exactly_once(requested_model, exp
|
|||
assert captured["url"] == expected_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_messages_strips_replayed_provider_specific_fields_from_wire():
|
||||
captured = {}
|
||||
|
||||
async def fake_send(self, request, **kwargs):
|
||||
captured["body"] = json.loads(request.content)
|
||||
raise httpx.ConnectError("cut at the wire", request=request)
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_01",
|
||||
"name": "get_weather",
|
||||
"input": {"city": "Paris"},
|
||||
"provider_specific_fields": {"signature": "sig_abc"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_01",
|
||||
"content": "Sunny",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(httpx.AsyncClient, "send", fake_send),
|
||||
pytest.raises(litellm.exceptions.InternalServerError),
|
||||
):
|
||||
await litellm.anthropic.messages.acreate(
|
||||
max_tokens=100,
|
||||
messages=messages,
|
||||
model="anthropic/claude-haiku-4-5-20251001",
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
assert "provider_specific_fields" in messages[0]["content"][0]
|
||||
assert "provider_specific_fields" not in captured["body"]["messages"][0]["content"][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"requested_model, expected_reported_model",
|
||||
|
|
|
|||
|
|
@ -1265,6 +1265,7 @@ class TestTranslateResponse:
|
|||
assert block["id"] == "call_99"
|
||||
assert block["name"] == "get_weather"
|
||||
assert block["input"] == {"city": "NYC"}
|
||||
assert "provider_specific_fields" not in block
|
||||
|
||||
def test_function_call_sets_stop_reason_tool_use(self):
|
||||
"""Presence of a function_call sets stop_reason to 'tool_use'."""
|
||||
|
|
@ -1447,6 +1448,7 @@ class TestTranslateResponse:
|
|||
assert result["content"][0]["type"] == "tool_use"
|
||||
assert result["content"][0]["name"] == "search"
|
||||
assert result["content"][0]["input"] == {"query": "cats"}
|
||||
assert "provider_specific_fields" not in result["content"][0]
|
||||
assert result["stop_reason"] == "tool_use"
|
||||
|
||||
def test_mixed_reasoning_text_and_tool_use(self):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue