diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 87c4ec8938e..98b205be010 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 @@ -1362,17 +1363,63 @@ 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_HOSTNAME: Final = "api.anthropic.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 _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: + 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 + 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, + 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 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]] = [] 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..f7a3a4198da 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/handler.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/handler.py @@ -263,7 +263,12 @@ 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, + custom_llm_provider=custom_llm_provider, + model=model, + ) messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages) from litellm.integrations.anthropic_cache_control_hook import ( @@ -460,7 +465,12 @@ 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, + custom_llm_provider=custom_llm_provider, + model=model, + ) 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..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,6 +220,86 @@ async def test_anthropic_messages_sanitizes_tool_use_ids_before_dispatch(): assert msgs[0]["content"][0]["id"] == "functions.Bash:0" +_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}, +} + + +def _tool_use_replay_messages(): + return [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "functions.Bash:0", + "name": "Bash", + "input": {}, + } + ], + } + ] + + +def _capturing_anthropic_client(): + captured = {} + + def capture_upstream(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=_ANTHROPIC_MESSAGES_OK, request=request) + + upstream = AsyncHTTPHandler() + upstream.client = httpx.AsyncClient(transport=httpx.MockTransport(capture_upstream)) + return captured, upstream + + +@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" + + +@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 = _tool_use_replay_messages() + captured, upstream = _capturing_anthropic_client() + + 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, + ) + + assert captured["body"]["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 e1b39c4ba13..9d3034de621 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1886,6 +1886,105 @@ 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", 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, + ) + + msgs = [ + { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": "functions.Bash:0", + "name": "Bash", + "input": {}, + } + ], + } + ] + still_sanitize = ( + ("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, model in still_sanitize: + out = sanitize_tool_use_ids_in_anthropic_messages( + 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, model) + 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,