diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index dd9b22f3d5d..6bbc9b17ff1 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -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 diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 0d3e48c49fd..9b3b5071cdb 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -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": {}, } diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 9e0bd5c60b8..2a80491bb62 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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 diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index f1d8d46216b..9ceeec8dfa3 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -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