From 2a1b0e400b5925e549439819b6ea09b3962fd1b4 Mon Sep 17 00:00:00 2001 From: Chaitanya Laxman Date: Mon, 7 Sep 2026 19:07:05 +0400 Subject: [PATCH] fix(anthropic): resolve tool-id sanitize provider from model prefix azure_ai/claude-sonnet-4-5 and github_copilot/claude-sonnet-4-5 with only api_base set were treated as anthropic pass-through and skipped the rewrite. Read the provider from custom_llm_provider, else the model prefix. Do not call get_llm_provider (github_copilot that path prompts device login). --- litellm/llms/anthropic/common_utils.py | 24 ++++++++++- .../messages/handler.py | 10 ++++- ...erimental_pass_through_messages_handler.py | 42 +++++++++++++++++++ .../anthropic/test_anthropic_common_utils.py | 23 ++++++---- 4 files changed, 86 insertions(+), 13 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 6fa0013b736..a1b657e8a9e 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -1244,12 +1244,30 @@ def _sanitize_tool_use_id_content_block(block: object) -> object: _ANTHROPIC_TOOL_ID_CHARSET_HOSTNAME: Final = "api.anthropic.com" +def _llm_provider_for_tool_id_sanitize( + *, + custom_llm_provider: str | None, + model: str | None, +) -> str | None: + if custom_llm_provider is not None and custom_llm_provider.strip(): + return custom_llm_provider.casefold() + if model is None or "/" not in model: + return None + prefix: Final = model.split("/", 1)[0].casefold() + return prefix or None + + def _should_sanitize_anthropic_tool_use_ids( *, api_base: str | None, custom_llm_provider: str | None, + model: str | None, ) -> bool: - if custom_llm_provider is not None and custom_llm_provider.casefold() != "anthropic": + provider: Final = _llm_provider_for_tool_id_sanitize( + custom_llm_provider=custom_llm_provider, + model=model, + ) + if provider is not None and provider != "anthropic": return True if api_base is None or not api_base.strip(): return True @@ -1264,18 +1282,20 @@ def sanitize_tool_use_ids_in_anthropic_messages( *, api_base: str | None = None, custom_llm_provider: str | None = None, + model: str | None = None, ) -> list[Any]: """ Rewrite ``tool_use`` / ``server_tool_use`` ``id`` and ``tool_result`` ``tool_use_id`` values to Anthropic's ``^[a-zA-Z0-9_-]+$`` pattern. - No-op when ``custom_llm_provider`` is ``anthropic`` and ``api_base`` is a + No-op when the resolved provider is ``anthropic`` and ``api_base`` is a non-Anthropic host. vLLM/Kimi echo the original ids; rewriting them breaks the next tool_result turn. See #32214. """ if not _should_sanitize_anthropic_tool_use_ids( api_base=api_base, custom_llm_provider=custom_llm_provider, + model=model, ): return messages out: Final[list[Any]] = [] diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index 93a898de533..f7a3a4198da 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -264,7 +264,10 @@ async def anthropic_messages( # Replay of cross-provider tool history (e.g. kimi -> Anthropic) may carry # ids like ``functions.Bash:0`` that violate Anthropic's id pattern. messages = sanitize_tool_use_ids_in_anthropic_messages( - messages, api_base=api_base, custom_llm_provider=custom_llm_provider + messages, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + model=model, ) messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages) @@ -463,7 +466,10 @@ def anthropic_messages_handler( if not kwargs.pop("_litellm_messages_presanitized", False): messages = strip_empty_content_blocks_from_anthropic_messages(messages) messages = sanitize_tool_use_ids_in_anthropic_messages( - messages, api_base=api_base, custom_llm_provider=custom_llm_provider + messages, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + model=model, ) messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages) 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 da9272dc12d..07607d75bf5 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 @@ -263,6 +263,48 @@ async def test_anthropic_messages_keeps_tool_use_ids_for_non_anthropic_api_base( assert msgs[0]["content"][0]["id"] == "functions.Bash:0" +@pytest.mark.asyncio +async def test_anthropic_messages_sanitizes_azure_ai_model_prefix_without_provider(): + from litellm.llms.anthropic.experimental_pass_through.messages import handler + + msgs = [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "functions.Bash:0", + "name": "Bash", + "input": {}, + } + ], + } + ] + captured = {} + + def fake_handler(*args, **kwargs): + captured["messages"] = kwargs.get("messages") + return "stub" + + fake_loop = MagicMock() + fake_loop.run_in_executor = lambda _e, func: _async_return(func()) + + with ( + patch.object(handler, "anthropic_messages_handler", side_effect=fake_handler), + patch("asyncio.get_event_loop", return_value=fake_loop), + ): + await handler.anthropic_messages( + max_tokens=100, + messages=msgs, + model="azure_ai/claude-sonnet-4-5", + api_key="k", + api_base="https://myres.services.ai.azure.com/anthropic", + ) + + assert captured["messages"][0]["content"][0]["id"] == "functions_Bash_0" + assert msgs[0]["content"][0]["id"] == "functions.Bash:0" + + async def _async_return(value): return value diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index 8da42d534dd..03b8ec12c24 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1874,18 +1874,23 @@ class TestAnthropicThinkingSignatureSelfHeal: } ] still_sanitize = ( - ("anthropic", ""), - ("anthropic", "https://api.anthropic.com"), - ("azure_ai", "https://myres.services.ai.azure.com/anthropic"), - ("github_copilot", "https://api.githubcopilot.com"), - ("bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com"), - ("vertex_ai", "https://us-east5-aiplatform.googleapis.com"), + ("anthropic", "", None), + ("anthropic", "https://api.anthropic.com", None), + ("azure_ai", "https://myres.services.ai.azure.com/anthropic", None), + ("github_copilot", "https://api.githubcopilot.com", None), + ("bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com", None), + ("vertex_ai", "https://us-east5-aiplatform.googleapis.com", None), + (None, "https://myres.services.ai.azure.com/anthropic", "azure_ai/claude-sonnet-4-5"), + (None, "https://api.githubcopilot.com", "github_copilot/claude-sonnet-4-5"), ) - for custom_llm_provider, api_base in still_sanitize: + for custom_llm_provider, api_base, model in still_sanitize: out = sanitize_tool_use_ids_in_anthropic_messages( - msgs, api_base=api_base, custom_llm_provider=custom_llm_provider + msgs, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + model=model, ) - assert out[0]["content"][0]["id"] == "functions_Bash_0", (custom_llm_provider, api_base) + assert out[0]["content"][0]["id"] == "functions_Bash_0", (custom_llm_provider, api_base, model) assert msgs[0]["content"][0]["id"] == "functions.Bash:0" def test_normalize_anthropic_tool_use_id_strips_thought_signature(self):