From 45169b10a98f0a675167e7033d81450066642d61 Mon Sep 17 00:00:00 2001 From: Chaitanya Laxman Date: Mon, 7 Sep 2026 18:16:04 +0400 Subject: [PATCH 1/4] fix(anthropic): keep vLLM tool ids intact on /v1/messages Native /v1/messages always rewrote tool ids (functions.Bash:0 -> functions_Bash_0). vLLM/Kimi echoes the original ids, so the next tool_result turn breaks (#32214). Skip the rewrite when api_base is not Anthropic, Bedrock, or Vertex. Empty api_base still sanitizes. Tests: skip for 127.0.0.1, still rewrite anthropic hosts, handler forwards api_base. Revert of production files: skip test TypeError, handler test KeyError. --- litellm/llms/anthropic/common_utils.py | 39 ++++++++--- .../messages/handler.py | 4 +- ...erimental_pass_through_messages_handler.py | 21 ++++++ .../anthropic/test_anthropic_common_utils.py | 66 +++++++++++++++++++ 4 files changed, 119 insertions(+), 11 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index d9424d6a243..34b2015f9eb 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -1240,17 +1240,38 @@ def _sanitize_tool_use_id_content_block(block: object) -> object: return block -def sanitize_tool_use_ids_in_anthropic_messages(messages: list[Any]) -> list[Any]: - """ - Return a new message list with ``tool_use`` / ``server_tool_use`` ``id`` and - ``tool_result`` ``tool_use_id`` values rewritten to satisfy Anthropic's - ``^[a-zA-Z0-9_-]+$`` requirement. +_ANTHROPIC_TOOL_ID_CHARSET_HOST_MARKERS: Final = frozenset( + ( + "api.anthropic.com", + "amazonaws.com", + "googleapis.com", + "cloud.google.com", + ) +) - Cross-provider clients (e.g. Claude Code routed through kimi) may replay - conversation history containing ids like ``functions.Bash:0`` with ``.`` - and ``:`` — valid on the upstream provider but rejected by Anthropic when - the session is switched to a native Anthropic deployment. + +def _upstream_enforces_anthropic_tool_id_charset(api_base: str | None) -> bool: + if api_base is None or not api_base.strip(): + return True + host: Final = api_base.casefold() + return any(marker in host for marker in _ANTHROPIC_TOOL_ID_CHARSET_HOST_MARKERS) + + +def sanitize_tool_use_ids_in_anthropic_messages( + messages: list[Any], + *, + api_base: 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 ``api_base`` is a host that is not Anthropic, Bedrock, or Vertex. + Those upstreams (vLLM, Kimi, SGLang) echo the original ids; rewriting them + breaks the next tool_result turn. See #32214. + """ + if not _upstream_enforces_anthropic_tool_id_charset(api_base): + return messages out: Final[list[Any]] = [] for m in messages: if not isinstance(m, dict) or not isinstance(m.get("content"), list): diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index 9d1e921cce4..bd9ec052e2d 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -263,7 +263,7 @@ async def anthropic_messages( messages = strip_empty_content_blocks_from_anthropic_messages(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) + messages = sanitize_tool_use_ids_in_anthropic_messages(messages, api_base=api_base) messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages) from litellm.integrations.anthropic_cache_control_hook import ( @@ -460,7 +460,7 @@ def anthropic_messages_handler( # full-messages scan. Pop it so it never leaks into provider params. 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) + messages = sanitize_tool_use_ids_in_anthropic_messages(messages, api_base=api_base) messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages) from litellm.integrations.anthropic_cache_control_hook import ( 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 01f7a2fb7ab..dbe96b322a5 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 @@ -702,6 +702,27 @@ def test_handler_strips_when_no_presanitized_flag(): assert result is not None +def test_handler_forwards_api_base_to_tool_id_sanitize(): + from litellm.llms.anthropic.experimental_pass_through.messages import handler + + with patch.object( + handler, + "sanitize_tool_use_ids_in_anthropic_messages", + wraps=handler.sanitize_tool_use_ids_in_anthropic_messages, + ) as spy: + result = handler.anthropic_messages_handler( + max_tokens=10, + messages=[{"role": "user", "content": "Hello"}], + model="anthropic/claude-3-5-sonnet-20241022", + custom_llm_provider="anthropic", + api_base="http://127.0.0.1:8000/v1", + mock_response="hi there", + ) + assert result is not None + assert spy.call_count == 1 + assert spy.call_args.kwargs["api_base"] == "http://127.0.0.1:8000/v1" + + def test_handler_skips_strip_when_presanitized(): """Async wrapper already sanitized -> handler must NOT rescan.""" from litellm.llms.anthropic.experimental_pass_through.messages import handler 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 794613942a1..d0dddea0ff0 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1794,6 +1794,72 @@ class TestAnthropicThinkingSignatureSelfHeal: assert out[1]["content"][0]["tool_use_id"] == "functions_Bash_0" assert msgs[0]["content"][0]["id"] == "functions.Bash:0" + def test_sanitize_tool_use_ids_skips_non_anthropic_api_base(self): + from litellm.llms.anthropic.common_utils import ( + sanitize_tool_use_ids_in_anthropic_messages, + ) + + msgs = [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "functions.Bash:0", + "name": "Bash", + "input": {}, + } + ], + }, + { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "functions.Bash:0", + "content": "ok", + } + ], + }, + ] + out = sanitize_tool_use_ids_in_anthropic_messages( + msgs, api_base="http://127.0.0.1:8000/v1" + ) + assert out is msgs + assert out[0]["content"][0]["id"] == "functions.Bash:0" + assert out[1]["content"][0]["tool_use_id"] == "functions.Bash:0" + + def test_sanitize_tool_use_ids_still_runs_for_anthropic_hosts(self): + from litellm.llms.anthropic.common_utils import ( + sanitize_tool_use_ids_in_anthropic_messages, + ) + + msgs = [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "functions.Bash:0", + "name": "Bash", + "input": {}, + } + ], + } + ] + anthropic_hosts = ( + "", + "https://api.anthropic.com", + "https://bedrock-runtime.us-east-1.amazonaws.com", + "https://us-east5-aiplatform.googleapis.com", + "https://aiplatform.googleapis.com", + "https://cloud.google.com/vertex-ai", + ) + for api_base in anthropic_hosts: + out = sanitize_tool_use_ids_in_anthropic_messages(msgs, api_base=api_base) + assert out[0]["content"][0]["id"] == "functions_Bash_0", api_base + assert msgs[0]["content"][0]["id"] == "functions.Bash:0" + def test_normalize_anthropic_tool_use_id_strips_thought_signature(self): from litellm.litellm_core_utils.prompt_templates.factory import ( THOUGHT_SIGNATURE_SEPARATOR, From 87d542f079539511ac4552469655ceba5602a4d0 Mon Sep 17 00:00:00 2001 From: Chaitanya Laxman Date: Mon, 7 Sep 2026 18:40:07 +0400 Subject: [PATCH 2/4] fix(anthropic): skip tool-id rewrite only for anthropic pass-through Host-only skip turned sanitization off for azure_ai and github_copilot, which still speak Anthropic's id charset (#32214). Skip only when custom_llm_provider is anthropic and api_base hostname is not api.anthropic.com. Keep rewriting for azure_ai, github_copilot, bedrock, vertex_ai, and empty api_base. Curl to localhost:4000: merge base forwarded functions_Bash_0; this branch forwards functions.Bash:0. --- litellm/llms/anthropic/common_utils.py | 36 ++++++----- .../messages/handler.py | 8 ++- ...erimental_pass_through_messages_handler.py | 64 +++++++++++++------ .../anthropic/test_anthropic_common_utils.py | 50 +++++++++++---- 4 files changed, 109 insertions(+), 49 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 34b2015f9eb..6fa0013b736 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -8,6 +8,7 @@ from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime, timezone from types import MappingProxyType from typing import Any, Final, Literal +from urllib.parse import urlparse import httpx from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError @@ -1240,37 +1241,42 @@ def _sanitize_tool_use_id_content_block(block: object) -> object: return block -_ANTHROPIC_TOOL_ID_CHARSET_HOST_MARKERS: Final = frozenset( - ( - "api.anthropic.com", - "amazonaws.com", - "googleapis.com", - "cloud.google.com", - ) -) +_ANTHROPIC_TOOL_ID_CHARSET_HOSTNAME: Final = "api.anthropic.com" -def _upstream_enforces_anthropic_tool_id_charset(api_base: str | None) -> bool: +def _should_sanitize_anthropic_tool_use_ids( + *, + api_base: str | None, + custom_llm_provider: str | None, +) -> bool: + if custom_llm_provider is not None and custom_llm_provider.casefold() != "anthropic": + return True if api_base is None or not api_base.strip(): return True - host: Final = api_base.casefold() - return any(marker in host for marker in _ANTHROPIC_TOOL_ID_CHARSET_HOST_MARKERS) + hostname: Final = urlparse(api_base).hostname + if hostname is None: + return True + return hostname.casefold() == _ANTHROPIC_TOOL_ID_CHARSET_HOSTNAME def sanitize_tool_use_ids_in_anthropic_messages( messages: list[Any], *, api_base: str | None = None, + custom_llm_provider: 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 ``api_base`` is a host that is not Anthropic, Bedrock, or Vertex. - Those upstreams (vLLM, Kimi, SGLang) echo the original ids; rewriting them - breaks the next tool_result turn. See #32214. + No-op when ``custom_llm_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 _upstream_enforces_anthropic_tool_id_charset(api_base): + if not _should_sanitize_anthropic_tool_use_ids( + api_base=api_base, + custom_llm_provider=custom_llm_provider, + ): return messages out: Final[list[Any]] = [] for m in messages: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py index bd9ec052e2d..93a898de533 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -263,7 +263,9 @@ async def anthropic_messages( messages = strip_empty_content_blocks_from_anthropic_messages(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) + messages = sanitize_tool_use_ids_in_anthropic_messages( + messages, api_base=api_base, custom_llm_provider=custom_llm_provider + ) messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages) from litellm.integrations.anthropic_cache_control_hook import ( @@ -460,7 +462,9 @@ def anthropic_messages_handler( # full-messages scan. Pop it so it never leaks into provider params. 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) + messages = sanitize_tool_use_ids_in_anthropic_messages( + messages, api_base=api_base, custom_llm_provider=custom_llm_provider + ) messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages) from litellm.integrations.anthropic_cache_control_hook import ( 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 dbe96b322a5..da9272dc12d 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 @@ -220,6 +220,49 @@ async def test_anthropic_messages_sanitizes_tool_use_ids_before_dispatch(): assert msgs[0]["content"][0]["id"] == "functions.Bash:0" +@pytest.mark.asyncio +async def test_anthropic_messages_keeps_tool_use_ids_for_non_anthropic_api_base(): + 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="anthropic/claude-sonnet-4-5-20250929", + custom_llm_provider="anthropic", + api_key="k", + api_base="http://127.0.0.1:8000/v1", + ) + + 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 @@ -702,27 +745,6 @@ def test_handler_strips_when_no_presanitized_flag(): assert result is not None -def test_handler_forwards_api_base_to_tool_id_sanitize(): - from litellm.llms.anthropic.experimental_pass_through.messages import handler - - with patch.object( - handler, - "sanitize_tool_use_ids_in_anthropic_messages", - wraps=handler.sanitize_tool_use_ids_in_anthropic_messages, - ) as spy: - result = handler.anthropic_messages_handler( - max_tokens=10, - messages=[{"role": "user", "content": "Hello"}], - model="anthropic/claude-3-5-sonnet-20241022", - custom_llm_provider="anthropic", - api_base="http://127.0.0.1:8000/v1", - mock_response="hi there", - ) - assert result is not None - assert spy.call_count == 1 - assert spy.call_args.kwargs["api_base"] == "http://127.0.0.1:8000/v1" - - def test_handler_skips_strip_when_presanitized(): """Async wrapper already sanitized -> handler must NOT rescan.""" from litellm.llms.anthropic.experimental_pass_through.messages import handler 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 d0dddea0ff0..8da42d534dd 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1823,12 +1823,38 @@ class TestAnthropicThinkingSignatureSelfHeal: }, ] out = sanitize_tool_use_ids_in_anthropic_messages( - msgs, api_base="http://127.0.0.1:8000/v1" + msgs, api_base="http://127.0.0.1:8000/v1", custom_llm_provider="anthropic" ) assert out is msgs assert out[0]["content"][0]["id"] == "functions.Bash:0" assert out[1]["content"][0]["tool_use_id"] == "functions.Bash:0" + def test_sanitize_tool_use_ids_uses_url_hostname_not_query_string(self): + from litellm.llms.anthropic.common_utils import ( + sanitize_tool_use_ids_in_anthropic_messages, + ) + + msgs = [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "functions.Bash:0", + "name": "Bash", + "input": {}, + } + ], + } + ] + out = sanitize_tool_use_ids_in_anthropic_messages( + msgs, + api_base="http://vllm:8000/v1?x=api.anthropic.com", + custom_llm_provider="anthropic", + ) + assert out is msgs + assert out[0]["content"][0]["id"] == "functions.Bash:0" + def test_sanitize_tool_use_ids_still_runs_for_anthropic_hosts(self): from litellm.llms.anthropic.common_utils import ( sanitize_tool_use_ids_in_anthropic_messages, @@ -1847,17 +1873,19 @@ class TestAnthropicThinkingSignatureSelfHeal: ], } ] - anthropic_hosts = ( - "", - "https://api.anthropic.com", - "https://bedrock-runtime.us-east-1.amazonaws.com", - "https://us-east5-aiplatform.googleapis.com", - "https://aiplatform.googleapis.com", - "https://cloud.google.com/vertex-ai", + 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"), ) - for api_base in anthropic_hosts: - out = sanitize_tool_use_ids_in_anthropic_messages(msgs, api_base=api_base) - assert out[0]["content"][0]["id"] == "functions_Bash_0", api_base + for custom_llm_provider, api_base in still_sanitize: + out = sanitize_tool_use_ids_in_anthropic_messages( + msgs, api_base=api_base, custom_llm_provider=custom_llm_provider + ) + assert out[0]["content"][0]["id"] == "functions_Bash_0", (custom_llm_provider, api_base) assert msgs[0]["content"][0]["id"] == "functions.Bash:0" def test_normalize_anthropic_tool_use_id_strips_thought_signature(self): From 2a1b0e400b5925e549439819b6ea09b3962fd1b4 Mon Sep 17 00:00:00 2001 From: Chaitanya Laxman Date: Mon, 7 Sep 2026 19:07:05 +0400 Subject: [PATCH 3/4] 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): From 7a10559ea8ee259b421a919e1c45fe4c5f7b06b9 Mon Sep 17 00:00:00 2001 From: Chaitanya Laxman Date: Mon, 7 Sep 2026 20:37:18 +0400 Subject: [PATCH 4/4] test(anthropic): capture tool ids on the forwarded HTTP body CI lint failed TQ008 on the two handler tests that patched anthropic_messages_handler. They now inject AsyncHTTPHandler with MockTransport and assert the id on the wire. --- ...erimental_pass_through_messages_handler.py | 105 +++++++++--------- 1 file changed, 50 insertions(+), 55 deletions(-) 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 07607d75bf5..1e1adbf04d8 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 @@ -220,11 +220,19 @@ async def test_anthropic_messages_sanitizes_tool_use_ids_before_dispatch(): assert msgs[0]["content"][0]["id"] == "functions.Bash:0" -@pytest.mark.asyncio -async def test_anthropic_messages_keeps_tool_use_ids_for_non_anthropic_api_base(): - from litellm.llms.anthropic.experimental_pass_through.messages import handler +_ANTHROPIC_MESSAGES_OK = { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, +} - msgs = [ + +def _tool_use_replay_messages(): + return [ { "role": "assistant", "content": [ @@ -237,29 +245,38 @@ async def test_anthropic_messages_keeps_tool_use_ids_for_non_anthropic_api_base( ], } ] + + +def _capturing_anthropic_client(): captured = {} - def fake_handler(*args, **kwargs): - captured["messages"] = kwargs.get("messages") - return "stub" + def capture_upstream(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_ANTHROPIC_MESSAGES_OK, request=request) - fake_loop = MagicMock() - fake_loop.run_in_executor = lambda _e, func: _async_return(func()) + upstream = AsyncHTTPHandler() + upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(capture_upstream)) + return captured, upstream - 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="anthropic/claude-sonnet-4-5-20250929", - custom_llm_provider="anthropic", - api_key="k", - api_base="http://127.0.0.1:8000/v1", - ) - assert captured["messages"][0]["content"][0]["id"] == "functions.Bash:0" +@pytest.mark.asyncio +async def test_anthropic_messages_keeps_tool_use_ids_for_non_anthropic_api_base(): + from litellm.llms.anthropic.experimental_pass_through.messages import handler + + msgs = _tool_use_replay_messages() + captured, upstream = _capturing_anthropic_client() + + await handler.anthropic_messages( + max_tokens=100, + messages=msgs, + model="anthropic/claude-sonnet-4-5-20250929", + custom_llm_provider="anthropic", + api_key="k", + api_base="http://127.0.0.1:8000/v1", + client=upstream, + ) + + assert captured["body"]["messages"][0]["content"][0]["id"] == "functions.Bash:0" assert msgs[0]["content"][0]["id"] == "functions.Bash:0" @@ -267,41 +284,19 @@ async def test_anthropic_messages_keeps_tool_use_ids_for_non_anthropic_api_base( 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 = {} + msgs = _tool_use_replay_messages() + captured, upstream = _capturing_anthropic_client() - def fake_handler(*args, **kwargs): - captured["messages"] = kwargs.get("messages") - return "stub" + 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", + client=upstream, + ) - 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 captured["body"]["messages"][0]["content"][0]["id"] == "functions_Bash_0" assert msgs[0]["content"][0]["id"] == "functions.Bash:0"