Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions litellm/llms/anthropic/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import copy
import re
from typing import Any, Dict, List, Optional, Union

import httpx
Expand All @@ -11,6 +12,9 @@
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 (
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def create_tool_name_mapping(
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,
)
Expand Down Expand Up @@ -1363,18 +1364,12 @@ def _translate_openai_content_to_anthropic(
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,
Expand Down Expand Up @@ -1501,15 +1496,13 @@ def _translate_streaming_openai_chunk_to_anthropic_content_block(
):
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": {},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,48 @@ def fake_handler(*args, **kwargs):
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

Expand Down
46 changes: 46 additions & 0 deletions tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1329,6 +1329,52 @@ def test_strip_empty_text_blocks_treats_non_string_text_value_as_empty(self):
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

Expand Down
Loading