mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-25 05:54:53 +00:00
Merge 56ff4548e2 into 2e427c6247
This commit is contained in:
commit
d17b133a7f
4 changed files with 252 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue