mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(anthropic): sanitize tool_use ids on native /v1/messages path (#31094)
This commit is contained in:
parent
e0c8a6b483
commit
8bca05d311
6 changed files with 192 additions and 12 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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": {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue