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:
tin-berri 2026-09-05 17:50:54 -07:00 committed by GitHub
parent a9f8a8d794
commit 01680d7b42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 82 additions and 8 deletions

View file

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

View file

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

View file

@ -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,

View file

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

View file

@ -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():

View file

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

View file

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