This commit is contained in:
Chaitanya Laxman 2026-09-11 20:54:57 -03:00 committed by GitHub
commit 514d36c342
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 247 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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