fix(anthropic): sanitize tool_use ids on native /v1/messages path (#31094)

This commit is contained in:
Sameer Kankute 2026-06-24 20:27:46 +05:30 • committed by GitHub
parent e0c8a6b483
commit 8bca05d311
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 192 additions and 12 deletions

View file

@ -3,6 +3,7 @@ This file contains common utils for anthropic calls.
"""
import copy
import re
from typing import Any, Dict, List, Optional, Union
import httpx
@ -11,6 +12,9 @@ import litellm
from litellm.litellm_core_utils.prompt_templates.common_utils import (
get_file_ids_from_messages,
)
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.anthropic import (
@ -999,6 +1003,67 @@ def _is_empty_text_block(block: Any) -> bool:
return not isinstance(text, str) or not text.strip()
def normalize_anthropic_tool_use_id(raw_id: str) -> str:
"""
Normalize a tool_use / tool_result id for Anthropic's ``^[a-zA-Z0-9_-]+$``
pattern.
Strips Gemini thought-signature suffixes (``__thought__``) first, then
replaces any remaining invalid characters with underscores.
"""
base_id = (
raw_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
if THOUGHT_SIGNATURE_SEPARATOR in raw_id
else raw_id
)
sanitized = re.sub(r"[^a-zA-Z0-9_-]", "_", base_id)
return sanitized or "tool_use_id"
def _sanitize_tool_use_id_content_block(block: Any) -> Any:
if not isinstance(block, dict):
return block
block_type = block.get("type")
if block_type in ("tool_use", "server_tool_use"):
raw_id = block.get("id")
if isinstance(raw_id, str):
normalized = normalize_anthropic_tool_use_id(raw_id)
if normalized != raw_id:
return {**block, "id": normalized}
elif block_type == "tool_result":
raw_id = block.get("tool_use_id")
if isinstance(raw_id, str):
normalized = normalize_anthropic_tool_use_id(raw_id)
if normalized != raw_id:
return {**block, "tool_use_id": normalized}
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.
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.
"""
out: list[Any] = []
for m in messages:
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
out.append(m)
continue
content = m["content"]
new_content = [_sanitize_tool_use_id_content_block(b) for b in content]
if new_content == content:
out.append(m)
else:
out.append({**m, "content": new_content})
return out
def process_anthropic_headers(headers: Union[httpx.Headers, dict]) -> dict:
openai_headers = {}
if "anthropic-ratelimit-requests-limit" in headers:

View file

@ -76,6 +76,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.llms.anthropic.common_utils import normalize_anthropic_tool_use_id
from litellm.llms.anthropic.experimental_pass_through.context_management import (
PolyfillResult,
)
@ -1363,18 +1364,12 @@ class LiteLLMAnthropicMessagesAdapter:
else truncated_name
)
# Strip Gemini thought-signature suffix from id (mirrors streaming
# path below); base64 chars (+ / =) violate Anthropic's
# `^[a-zA-Z0-9_-]+$` tool_use.id pattern when replayed.
# Strip Gemini thought-signature suffix and normalize id chars
# (e.g. ``functions.Bash:0`` from cross-provider clients).
raw_id = tool_call.id or ""
base_id = (
raw_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)[0]
if THOUGHT_SIGNATURE_SEPARATOR in raw_id
else raw_id
)
tool_use_block = AnthropicResponseContentBlockToolUse(
type="tool_use",
id=base_id,
id=normalize_anthropic_tool_use_id(raw_id),
name=original_name,
input=parse_tool_call_arguments(
tool_call.function.arguments,
@ -1501,15 +1496,13 @@ class LiteLLMAnthropicMessagesAdapter:
):
raw_id = choice.delta.tool_calls[0].id or str(uuid.uuid4())
tool_name = choice.delta.tool_calls[0].function.name or ""
base_id = raw_id
thought_sig: Optional[str] = None
if THOUGHT_SIGNATURE_SEPARATOR in raw_id:
parts = raw_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)
base_id = parts[0]
thought_sig = parts[1] if len(parts) > 1 else None
tool_block: Dict[str, Any] = {
"type": "tool_use",
"id": base_id,
"id": normalize_anthropic_tool_use_id(raw_id),
"name": tool_name,
"input": {},
}

View file

@ -23,6 +23,7 @@ from typing import (
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.common_utils import (
sanitize_tool_use_ids_in_anthropic_messages,
strip_empty_text_blocks_from_anthropic_messages,
)
from litellm.llms.base_llm.anthropic_messages.transformation import (
@ -214,6 +215,9 @@ async def anthropic_messages(
# already handles this in anthropic_messages_pt; sanitize the native
# Anthropic Messages path here for the same guarantee. See #22930.
messages = strip_empty_text_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)
original_stream = stream or kwargs.get(
"_websearch_interception_converted_stream", False
@ -397,6 +401,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_text_blocks_from_anthropic_messages(messages)
messages = sanitize_tool_use_ids_in_anthropic_messages(messages)
metadata = validate_anthropic_api_metadata(metadata)

View file

@ -508,6 +508,35 @@ def test_translate_openai_content_to_anthropic_strips_gemini_thought_from_tool_c
assert result[0]["input"] == {"location": "Boston"}
def test_translate_openai_content_to_anthropic_sanitizes_colon_dot_tool_call_ids():
"""Cross-provider ids like ``functions.Bash:0`` must be normalized for Anthropic replay."""
openai_choices = [
Choices(
message=Message(
role="assistant",
content=None,
tool_calls=[
ChatCompletionAssistantToolCall(
id="functions.Bash:0",
type="function",
function=Function(
name="Bash",
arguments='{"command": "ls"}',
),
)
],
)
)
]
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter._translate_openai_content_to_anthropic(choices=openai_choices)
assert len(result) == 1
assert result[0]["type"] == "tool_use"
assert result[0]["id"] == "functions_Bash_0"
def test_translate_openai_response_to_anthropic_text_and_tool_calls():
"""`translate_openai_response_to_anthropic` should surface assistant text even when tools fire."""
openai_response = ModelResponse(

View file

@ -105,6 +105,48 @@ async def test_anthropic_messages_sanitizes_empty_text_blocks_before_dispatch():
assert len(msgs[0]["content"]) == 2 # caller untouched
@pytest.mark.asyncio
async def test_anthropic_messages_sanitizes_tool_use_ids_before_dispatch():
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",
)
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

View file

@ -1329,6 +1329,52 @@ class TestAnthropicThinkingSignatureSelfHeal:
out = strip_empty_text_blocks_from_anthropic_messages(msgs)
assert [b["type"] for b in out[0]["content"]] == ["tool_result"]
def test_sanitize_tool_use_ids_in_anthropic_messages(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)
assert out[0]["content"][0]["id"] == "functions_Bash_0"
assert out[1]["content"][0]["tool_use_id"] == "functions_Bash_0"
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 litellm.llms.anthropic.common_utils import normalize_anthropic_tool_use_id
base = "call_abc123"
sig = "CiIBDDnWx+/a=="
assert (
normalize_anthropic_tool_use_id(f"{base}{THOUGHT_SIGNATURE_SEPARATOR}{sig}")
== base
)
def test_anthropic_messages_config_http_retry_helpers(self):
import httpx