fix(anthropic): strip Gemini thought suffix from streaming tool_use id

Gemini tool call ids embed thought signatures as call_*__thought__*; the
Anthropic /v1/messages SSE adapter now exposes a clean id and moves the
signature to provider_specific_fields.signature for round-trip.

Fixes #25836.

Made-with: Cursor
This commit is contained in:
Sameer Kankute 2026-04-17 11:29:20 +05:30
parent f69b9d6564
commit d881a48220
No known key found for this signature in database
2 changed files with 70 additions and 7 deletions

View file

@ -72,6 +72,9 @@ from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingCho
from litellm.litellm_core_utils.prompt_templates.common_utils import (
parse_tool_call_arguments,
)
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.types.llms.anthropic import (
ANTHROPIC_HOSTED_TOOLS,
AllAnthropicToolsValues,
@ -1366,7 +1369,7 @@ class LiteLLMAnthropicMessagesAdapter:
"ContentBlockContentBlockDict",
]:
from litellm._uuid import uuid
from litellm.types.llms.anthropic import TextBlock, ToolUseBlock
from litellm.types.llms.anthropic import TextBlock
for choice in choices:
if (
@ -1374,12 +1377,25 @@ class LiteLLMAnthropicMessagesAdapter:
and len(choice.delta.tool_calls) > 0
and choice.delta.tool_calls[0].function is not None
):
return "tool_use", ToolUseBlock(
type="tool_use",
id=choice.delta.tool_calls[0].id or str(uuid.uuid4()),
name=choice.delta.tool_calls[0].function.name or "",
input={}, # type: ignore[typeddict-item]
)
raw_id = choice.delta.tool_calls[0].id or str(uuid.uuid4())
tool_name = choice.delta.tool_calls[0].function.name or ""
base_id = raw_id
thought_sig: Optional[str] = None
if THOUGHT_SIGNATURE_SEPARATOR in raw_id:
parts = raw_id.split(THOUGHT_SIGNATURE_SEPARATOR, 1)
base_id = parts[0]
thought_sig = parts[1] if len(parts) > 1 else None
tool_block: Dict[str, Any] = {
"type": "tool_use",
"id": base_id,
"name": tool_name,
"input": {},
}
if thought_sig:
tool_block["provider_specific_fields"] = {
"signature": thought_sig,
}
return "tool_use", cast("ContentBlockContentBlockDict", tool_block)
elif choice.delta.content is not None and len(choice.delta.content) > 0:
return "text", TextBlock(type="text", text="")
elif isinstance(choice, StreamingChoices) and hasattr(

View file

@ -7,6 +7,9 @@ import pytest
sys.path.insert(0, os.path.abspath("../../../../.."))
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
OPENAI_MAX_TOOL_NAME_LENGTH,
LiteLLMAnthropicMessagesAdapter,
@ -74,6 +77,50 @@ 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."""
base = "call_3e9417b7925e49aca9a71dc1885e"
sig = "CiIBDDnWx"
combined = f"{base}{THOUGHT_SIGNATURE_SEPARATOR}{sig}"
choices = [
StreamingChoices(
finish_reason=None,
index=0,
delta=Delta(
provider_specific_fields=None,
content=None,
role="assistant",
function_call=None,
tool_calls=[
ChatCompletionDeltaToolCall(
id=combined,
function=Function(
arguments='{"a": 17, "b": 25}', name="add_numbers"
),
type="function",
index=0,
)
],
audio=None,
),
logprobs=None,
)
]
(
block_type,
content_block_start,
) = 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["name"] == "add_numbers"
assert content_block_start["input"] == {}
assert content_block_start["provider_specific_fields"]["signature"] == sig
def test_translate_streaming_openai_chunk_to_anthropic_thinking_content_block():
choices = [
StreamingChoices(