fix(anthropic): resolve tool-id sanitize provider from model prefix

azure_ai/claude-sonnet-4-5 and github_copilot/claude-sonnet-4-5 with only
api_base set were treated as anthropic pass-through and skipped the rewrite.

Read the provider from custom_llm_provider, else the model prefix. Do not call
get_llm_provider (github_copilot that path prompts device login).
This commit is contained in:
Chaitanya Laxman 2026-09-07 19:07:05 +04:00
parent 87d542f079
commit 2a1b0e400b
4 changed files with 86 additions and 13 deletions

View file

@ -1244,12 +1244,30 @@ def _sanitize_tool_use_id_content_block(block: object) -> object:
_ANTHROPIC_TOOL_ID_CHARSET_HOSTNAME: Final = "api.anthropic.com"
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:
if custom_llm_provider is not None and custom_llm_provider.casefold() != "anthropic":
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
@ -1264,18 +1282,20 @@ def sanitize_tool_use_ids_in_anthropic_messages(
*,
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 ``custom_llm_provider`` is ``anthropic`` and ``api_base`` is a
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]] = []

View file

@ -264,7 +264,10 @@ async def anthropic_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, api_base=api_base, custom_llm_provider=custom_llm_provider
messages,
api_base=api_base,
custom_llm_provider=custom_llm_provider,
model=model,
)
messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages)
@ -463,7 +466,10 @@ def anthropic_messages_handler(
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, api_base=api_base, custom_llm_provider=custom_llm_provider
messages,
api_base=api_base,
custom_llm_provider=custom_llm_provider,
model=model,
)
messages = flatten_unencrypted_web_search_results_in_anthropic_messages(messages)

View file

@ -263,6 +263,48 @@ async def test_anthropic_messages_keeps_tool_use_ids_for_non_anthropic_api_base(
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 = [
{
"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="azure_ai/claude-sonnet-4-5",
api_key="k",
api_base="https://myres.services.ai.azure.com/anthropic",
)
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

@ -1874,18 +1874,23 @@ class TestAnthropicThinkingSignatureSelfHeal:
}
]
still_sanitize = (
("anthropic", ""),
("anthropic", "https://api.anthropic.com"),
("azure_ai", "https://myres.services.ai.azure.com/anthropic"),
("github_copilot", "https://api.githubcopilot.com"),
("bedrock", "https://bedrock-runtime.us-east-1.amazonaws.com"),
("vertex_ai", "https://us-east5-aiplatform.googleapis.com"),
("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 in still_sanitize:
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
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)
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):