This commit is contained in:
Li-john1021 2026-10-19 19:55:09 +00:00 • committed by GitHub
commit d17b133a7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 252 additions and 33 deletions

View file

@ -2,6 +2,7 @@
This file contains common utils for anthropic calls.
"""
import base64
import copy
import json
import re
@ -1834,6 +1835,58 @@ def normalize_anthropic_tool_use_id(raw_id: str) -> str:
return sanitized or "tool_use_id"
def encode_thought_signature_for_anthropic_tool_id(signature: str) -> str:
"""
Encode an opaque Gemini thought signature so it may be embedded in an
Anthropic ``tool_use.id``.
Anthropic requires ``^[a-zA-Z0-9_-]+$``. Raw Base64 signatures contain
``+``, ``/``, and ``=``, which clients and ``normalize_anthropic_tool_use_id``
would strip or corrupt. URL-safe Base64 of the signature *bytes* (no
padding) stays inside that charset and is reversible.
"""
return base64.urlsafe_b64encode(signature.encode("utf-8")).decode("ascii").rstrip("=")
def decode_thought_signature_from_anthropic_tool_id(encoded: str) -> str:
"""Inverse of ``encode_thought_signature_for_anthropic_tool_id``."""
pad: Final[int] = (-len(encoded)) % 4
return base64.urlsafe_b64decode(encoded + ("=" * pad)).decode("utf-8")
def format_anthropic_tool_use_id_with_signature(raw_id: str, thought_signature: str | None) -> str:
"""
Build an Anthropic-safe ``tool_use.id`` that still carries a Gemini
``thought_signature`` for clients that drop non-standard fields.
Spec-compliant Anthropic clients (Claude Code, typed SDKs) echo ``id`` but
discard ``provider_specific_fields``. Embedding a URL-safe encoding of the
signature after ``__thought__`` lets those clients round-trip the real
signature the same way OpenAI-compatible clients do via ``tool_calls[].id``.
"""
base_id: Final[str] = normalize_anthropic_tool_use_id(raw_id)
if not thought_signature:
return base_id
return f"{base_id}{THOUGHT_SIGNATURE_SEPARATOR}{encode_thought_signature_for_anthropic_tool_id(thought_signature)}"
def split_anthropic_tool_use_id_signature(tool_use_id: str) -> tuple[str, str | None]:
"""
Split an Anthropic ``tool_use.id`` / ``tool_result.tool_use_id`` into the
sanitized base id and, when present, the decoded Gemini thought signature.
"""
if THOUGHT_SIGNATURE_SEPARATOR not in tool_use_id:
return tool_use_id, None
base_id, encoded = tool_use_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)
if not encoded:
return base_id, None
try:
return base_id, decode_thought_signature_from_anthropic_tool_id(encoded)
except (ValueError, UnicodeDecodeError):
# Legacy / non-encoded suffixes: pass through so callers can still try.
return base_id, encoded
def _sanitize_tool_use_id_content_block(block: object) -> object:
if not isinstance(block, dict):
return block

View file

@ -133,8 +133,9 @@ from litellm.litellm_core_utils.reasoning_effort_utils import (
)
from litellm.llms.anthropic.common_utils import (
eager_input_streaming_flag,
format_anthropic_tool_use_id_with_signature,
is_empty_unsigned_thinking_block,
normalize_anthropic_tool_use_id,
split_anthropic_tool_use_id_signature,
strip_encrypted_reasoning_blocks_from_anthropic_messages,
)
from litellm.llms.anthropic.pass_through.context_management import (
@ -351,6 +352,11 @@ class AnthropicAdapter:
return anthropic_wrapper.anthropic_sse_wrapper()
def _base_tool_use_id(raw_id: object) -> str:
"""Strip an embedded thought signature from a tool_use / tool_result id."""
return split_anthropic_tool_use_id_signature(raw_id)[0] if isinstance(raw_id, str) else ""
_BlockT: Final = TypeVar("_BlockT", bound=Mapping[str, object])
@ -362,27 +368,56 @@ class LiteLLMAnthropicMessagesAdapter:
def _extract_signature_from_tool_call(self, tool_call: object) -> str | None:
"""
Extract signature from a tool call's provider_specific_fields.
Only checks provider_specific_fields, not thinking blocks.
"""
fields: Final = _optional_attr(tool_call, "provider_specific_fields")
if fields:
return _thought_signature(fields)
Extract a Gemini thought signature from an OpenAI-shaped tool call.
function_fields: Final = _optional_attr(_optional_attr(tool_call, "function"), "provider_specific_fields")
Prefer ``provider_specific_fields``, then a raw signature embedded in
``tool_call.id`` after ``__thought__`` (OpenAI-compatible clients echo
the id verbatim). Do not URL-decode here — that encoding is only used
on Anthropic ``tool_use.id`` values.
"""
fields: Final[object] = _optional_attr(tool_call, "provider_specific_fields")
if fields:
signature: Final[str | None] = _thought_signature(fields)
if signature:
return signature
function_fields: Final[object] = _optional_attr(
_optional_attr(tool_call, "function"), "provider_specific_fields"
)
if function_fields:
return _thought_signature(function_fields)
function_signature: Final[str | None] = _thought_signature(function_fields)
if function_signature:
return function_signature
raw_id: Final[object] = _optional_attr(tool_call, "id")
if isinstance(raw_id, str) and THOUGHT_SIGNATURE_SEPARATOR in raw_id:
_, _, embedded = raw_id.partition(THOUGHT_SIGNATURE_SEPARATOR)
if embedded:
return embedded
return None
def _extract_signature_from_tool_use_content(self, content: Mapping[str, object]) -> str | None:
"""
Extract signature from a tool_use content block's provider_specific_fields.
Extract a Gemini thought signature from a tool_use content block.
Prefer the non-standard ``provider_specific_fields.signature`` when a
client preserved it, then recover a signature embedded in ``id``
(URL-safe encoded so the id stays inside Anthropic's charset). Clients
that drop unknown fields, such as Claude Code, only echo ``id``.
"""
provider_specific_fields: Final = _as_string_mapping(content.get("provider_specific_fields", {}))
provider_specific_fields: Final[Mapping[str, object] | None] = _as_string_mapping(
content.get("provider_specific_fields", {})
)
if provider_specific_fields:
signature: Final = provider_specific_fields.get("signature")
return signature if isinstance(signature, str) else None
signature: Final[object] = provider_specific_fields.get("signature")
if isinstance(signature, str) and signature:
return signature
raw_id: Final[object] = content.get("id")
if isinstance(raw_id, str):
_, embedded = split_anthropic_tool_use_id_signature(raw_id)
if embedded:
return embedded
return None
def _add_cache_control_if_applicable(
@ -532,7 +567,7 @@ class LiteLLMAnthropicMessagesAdapter:
elif content.get("type") == "tool_result":
tool_result = ChatCompletionToolMessage(
role="tool",
tool_call_id=content.get("tool_use_id", ""),
tool_call_id=_base_tool_use_id(content.get("tool_use_id", "")),
content=self._tool_result_content(content.get("content")),
)
self._add_cache_control_if_applicable(content, tool_result, model)
@ -589,7 +624,7 @@ class LiteLLMAnthropicMessagesAdapter:
function_chunk["provider_specific_fields"] = provider_specific_fields
tool_call = ChatCompletionAssistantToolCall(
id=content.get("id", ""),
id=_base_tool_use_id(content.get("id", "")),
type="function",
function=function_chunk,
)
@ -1419,12 +1454,14 @@ class LiteLLMAnthropicMessagesAdapter:
tool_name_mapping.get(truncated_name, truncated_name) if tool_name_mapping else truncated_name
)
# Strip Gemini thought-signature suffix and normalize id chars
# (e.g. ``functions.Bash:0`` from cross-provider clients).
# Anthropic ids cannot carry raw Base64 (``+ / =``). Re-embed
# the signature as URL-safe Base64 after ``__thought__`` so
# spec-compliant clients that drop provider_specific_fields
# still echo it back on the next turn (#46369).
raw_id = tool_call.id or ""
tool_use_block = AnthropicResponseContentBlockToolUse(
type="tool_use",
id=normalize_anthropic_tool_use_id(raw_id),
id=format_anthropic_tool_use_id_with_signature(raw_id, signature),
name=original_name,
input=parse_tool_call_arguments(
tool_call.function.arguments,
@ -1645,14 +1682,14 @@ class LiteLLMAnthropicMessagesAdapter:
and choice.delta.tool_calls[0].function is not None
):
raw_id = choice.delta.tool_calls[0].id or str(uuid.uuid4())
# Keep `.function` on the same narrowed expression as the guard
# (`choice.delta.tool_calls[0].function is not None`) so
# reportOptionalMemberAccess stays flat.
tool_name = choice.delta.tool_calls[0].function.name or ""
thought_sig: str | None = None
if THOUGHT_SIGNATURE_SEPARATOR in raw_id:
parts = raw_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)
thought_sig = parts[1] if len(parts) > 1 else None
thought_sig = self._extract_signature_from_tool_call(choice.delta.tool_calls[0])
tool_block: dict[str, object] = {
"type": "tool_use",
"id": normalize_anthropic_tool_use_id(raw_id),
"id": format_anthropic_tool_use_id_with_signature(raw_id, thought_sig),
"name": tool_name,
"input": {},
}

View file

@ -18,6 +18,10 @@ from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
_bedrock_converse_messages_pt,
)
from litellm.llms.anthropic.common_utils import (
format_anthropic_tool_use_id_with_signature,
split_anthropic_tool_use_id_signature,
)
from litellm.llms.anthropic.pass_through.adapters.transformation import (
OPENAI_MAX_TOOL_NAME_LENGTH,
AnthropicAdapter,
@ -204,8 +208,24 @@ def test_translate_streaming_openai_chunk_to_anthropic_content_block():
}
def test_translate_streaming_openai_chunk_strips_gemini_thought_from_tool_call_id():
"""Gemini embeds thought signatures in OpenAI tool ids; Anthropic SSE should expose a clean id."""
def test_format_anthropic_tool_use_id_with_signature_stays_in_anthropic_charset():
import re
base = "functions.Bash:0"
sig = "CiIBDDnWx+/a=="
formatted = format_anthropic_tool_use_id_with_signature(base, sig)
assert re.fullmatch(r"[a-zA-Z0-9_-]+", formatted)
assert split_anthropic_tool_use_id_signature(formatted) == ("functions_Bash_0", sig)
assert format_anthropic_tool_use_id_with_signature(base, None) == "functions_Bash_0"
def test_translate_streaming_openai_chunk_embeds_gemini_thought_in_anthropic_tool_use_id():
"""Gemini embeds thought signatures in OpenAI tool ids; Anthropic SSE must keep a round-trippable id.
Raw Base64 uses ``+ / =``, which violate Anthropic's ``^[a-zA-Z0-9_-]+$``
pattern, so the adapter URL-safe-encodes the signature into ``tool_use.id``
(and still sets provider_specific_fields for clients that preserve extras).
"""
base = "call_3e9417b7925e49aca9a71dc1885e"
sig = "CiIBDDnWx"
combined = f"{base}{THOUGHT_SIGNATURE_SEPARATOR}{sig}"
@ -240,10 +260,12 @@ def test_translate_streaming_openai_chunk_strips_gemini_thought_from_tool_call_i
) = LiteLLMAnthropicMessagesAdapter().translate_streaming_openai_chunk_to_anthropic_content_block(choices=choices)
assert block_type == "tool_use"
assert content_block_start["id"] == base
assert content_block_start["id"] == format_anthropic_tool_use_id_with_signature(base, sig)
assert THOUGHT_SIGNATURE_SEPARATOR in content_block_start["id"]
assert content_block_start["name"] == "add_numbers"
assert content_block_start["input"] == {}
assert content_block_start["provider_specific_fields"]["signature"] == sig
assert split_anthropic_tool_use_id_signature(content_block_start["id"]) == (base, sig)
def test_translate_streaming_openai_chunk_to_anthropic_thinking_content_block():
@ -1172,12 +1194,14 @@ def test_translate_openai_content_to_anthropic_text_and_tool_calls():
assert result[1]["input"] == {"location": "Boston"}
def test_translate_openai_content_to_anthropic_strips_gemini_thought_from_tool_call_id():
def test_translate_openai_content_to_anthropic_embeds_gemini_thought_in_tool_use_id():
"""
Non-streaming path must strip the Gemini thought-signature suffix from
tool_call.id, same as the streaming path. The base64 signature contains
`+ / =` which violate Anthropic's `^[a-zA-Z0-9_-]+$` tool_use.id pattern
and 400 when the history is replayed to an Anthropic-native provider.
Non-streaming path must re-embed the Gemini thought signature into
``tool_use.id`` with a URL-safe encoding. Spec-compliant Anthropic clients
drop ``provider_specific_fields``, so a clean id alone loses the signature
and every later turn falls back to the dummy (#46369). Raw Base64 still
cannot sit in the id verbatim (``+ / =``), and native Anthropic sanitization
continues to strip ``__thought__`` suffixes before Anthropic-native replay.
"""
base = "call_3e9417b7925e49aca9a71dc1885e"
sig = "CiIBDDnWx+/a=="
@ -1208,11 +1232,12 @@ def test_translate_openai_content_to_anthropic_strips_gemini_thought_from_tool_c
assert len(result) == 1
assert result[0]["type"] == "tool_use"
assert result[0]["id"] == base
assert THOUGHT_SIGNATURE_SEPARATOR not in result[0]["id"]
assert result[0]["id"] == format_anthropic_tool_use_id_with_signature(base, sig)
assert THOUGHT_SIGNATURE_SEPARATOR in result[0]["id"]
assert result[0]["name"] == "get_weather"
assert result[0]["input"] == {"location": "Boston"}
assert result[0]["provider_specific_fields"] == {"signature": sig}
assert split_anthropic_tool_use_id_signature(result[0]["id"]) == (base, sig)
def test_translate_openai_content_to_anthropic_sanitizes_colon_dot_tool_call_ids():
@ -1843,6 +1868,95 @@ def test_translate_anthropic_messages_to_openai_tool_use_with_signature():
)
def test_translate_anthropic_messages_to_openai_recovers_signature_from_tool_use_id():
"""Claude Code drops provider_specific_fields; the signature must ride in tool_use.id (#46369)."""
test_signature = "CiIBDDnWx+/a=="
anthropic_id = format_anthropic_tool_use_id_with_signature(
"call_386f67af31f9415781bc35071405", test_signature
)
anthropic_messages = [
AnthropicMessagesUserMessageParam(
role="user",
content=[{"type": "text", "text": "What's the weather like in London?"}],
),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[
{
"type": "tool_use",
"id": anthropic_id,
"name": "get_weather",
"input": {"location": "London"},
}
],
),
AnthropicMessagesUserMessageParam(
role="user",
content=[
{
"type": "tool_result",
"tool_use_id": anthropic_id,
"content": "rainy",
}
],
),
]
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
assert result[1]["tool_calls"][0]["id"] == "call_386f67af31f9415781bc35071405"
assert (
result[1]["tool_calls"][0]["function"]["provider_specific_fields"]["thought_signature"]
== test_signature
)
assert result[2]["tool_call_id"] == "call_386f67af31f9415781bc35071405"
def test_messages_adapter_thought_signature_round_trip_without_provider_fields():
"""OpenAI→Anthropic→OpenAI keeps the real signature when clients only echo id/name/input."""
base = "call_roundtrip_abc"
sig = "EpYECpMEAdHtim9iBECdK1l5uVIIXoZZ+/=="
function = Function(name="Bash", arguments='{"command": "ls"}')
function.provider_specific_fields = {"thought_signature": sig}
openai_choices = [
Choices(
message=Message(
role="assistant",
content=None,
tool_calls=[
ChatCompletionAssistantToolCall(
id=f"{base}{THOUGHT_SIGNATURE_SEPARATOR}{sig}",
type="function",
function=function,
)
],
)
)
]
adapter = LiteLLMAnthropicMessagesAdapter()
anthropic_content = adapter._translate_openai_content_to_anthropic(choices=openai_choices)
# Simulate a spec-compliant client: keep only standard Anthropic fields.
echoed = {
"type": anthropic_content[0]["type"],
"id": anthropic_content[0]["id"],
"name": anthropic_content[0]["name"],
"input": anthropic_content[0]["input"],
}
assert "provider_specific_fields" not in echoed
assert split_anthropic_tool_use_id_signature(echoed["id"]) == (base, sig)
result = adapter.translate_anthropic_messages_to_openai(
messages=[
AnthopicMessagesAssistantMessageParam(role="assistant", content=[echoed]),
]
)
assert result[0]["tool_calls"][0]["function"]["provider_specific_fields"]["thought_signature"] == sig
assert result[0]["tool_calls"][0]["id"] == base
def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_content_items():
"""
Test that tool_result with multiple content items creates a single tool message

View file

@ -2040,6 +2040,21 @@ class TestAnthropicThinkingSignatureSelfHeal:
sig = "CiIBDDnWx+/a=="
assert normalize_anthropic_tool_use_id(f"{base}{THOUGHT_SIGNATURE_SEPARATOR}{sig}") == base
def test_format_anthropic_tool_use_id_with_signature_round_trips(self):
import re
from litellm.llms.anthropic.common_utils import (
format_anthropic_tool_use_id_with_signature,
split_anthropic_tool_use_id_signature,
)
base = "functions.Bash:0"
sig = "CiIBDDnWx+/a=="
formatted = format_anthropic_tool_use_id_with_signature(base, sig)
assert re.fullmatch(r"[a-zA-Z0-9_-]+", formatted)
assert split_anthropic_tool_use_id_signature(formatted) == ("functions_Bash_0", sig)
assert format_anthropic_tool_use_id_with_signature(base, None) == "functions_Bash_0"
def test_anthropic_messages_config_http_retry_helpers(self):
import httpx