mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge 7a10559ea8 into e4706fa409
This commit is contained in:
commit
514d36c342
4 changed files with 247 additions and 11 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue