From 01680d7b42ce3933c72a2a07f1d199b0c6d71adb Mon Sep 17 00:00:00 2001 From: tin-berri Date: Sat, 5 Sep 2026 17:50:54 -0700 Subject: [PATCH] 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 --- litellm/llms/anthropic/common_utils.py | 19 ++++++++ .../adapters/transformation.py | 2 +- .../messages/handler.py | 3 +- .../responses_adapters/transformation.py | 4 +- ...al_pass_through_adapters_transformation.py | 12 +++-- ...erimental_pass_through_messages_handler.py | 48 +++++++++++++++++++ .../test_responses_adapters_transformation.py | 2 + 7 files changed, 82 insertions(+), 8 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 6079b709bcc..d9424d6a243 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -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 diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 594fac512e6..f450f3899f4 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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 diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index b82903d6f87..9d1e921cce4 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -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, diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 0eb0e38a46e..62fc06a7c30 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -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" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index ea3b19fba2b..0478a251057 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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(): diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index e819433c269..01f7a2fb7ab 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -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", diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index 5ecf604f096..0b2a9e655fd 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -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):