Add thought signature support to v1/messages api (#16812)

* Add thought signature support to v1/messages api

* update the thinking level handling logic

* update the thinking level handling logic

* Add streaming support

* fix intalling litellm error
This commit is contained in:
Sameer Kankute
2025-11-19 20:24:31 -08:00
committed by GitHub
parent 87be419559
commit c3143e388e
13 changed files with 481 additions and 46 deletions
@@ -1162,16 +1162,58 @@ def _gemini_tool_call_invoke_helper(
return function_call
def _get_thought_signature_from_tool(tool: dict) -> Optional[str]:
"""Extract thought signature from tool call's provider_specific_fields"""
def _get_thought_signature_from_tool(tool: dict, model: Optional[str] = None) -> Optional[str]:
"""Extract thought signature from tool call's provider_specific_fields.
Checks both tool.provider_specific_fields and tool.function.provider_specific_fields.
If no signature is found and model is gemini-3, returns a dummy signature.
"""
# First check tool's provider_specific_fields
provider_fields = tool.get("provider_specific_fields") or {}
if isinstance(provider_fields, dict):
return provider_fields.get("thought_signature")
signature = provider_fields.get("thought_signature")
if signature:
return signature
# Then check function's provider_specific_fields
function = tool.get("function")
if function:
if isinstance(function, dict):
func_provider_fields = function.get("provider_specific_fields") or {}
if isinstance(func_provider_fields, dict):
signature = func_provider_fields.get("thought_signature")
if signature:
return signature
elif hasattr(function, "provider_specific_fields") and function.provider_specific_fields:
if isinstance(function.provider_specific_fields, dict):
signature = function.provider_specific_fields.get("thought_signature")
if signature:
return signature
# If no signature found and model is gemini-3, return dummy signature
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
if model and VertexGeminiConfig._is_gemini_3_or_newer(model):
return _get_dummy_thought_signature()
return None
def _get_dummy_thought_signature() -> str:
"""Generate a dummy thought signature for models that require it.
This is used when transferring conversation history from older models
(like gemini-2.5-flash) to gemini-3, which requires thought_signature
for strict validation.
"""
# Return a base64-encoded dummy signature string
# Below dummy signature is recommended by google - https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
dummy_data = b"skip_thought_signature_validator"
return base64.b64encode(dummy_data).decode("utf-8")
def convert_to_gemini_tool_call_invoke(
message: ChatCompletionAssistantMessage,
model: Optional[str] = None,
) -> List[VertexPartType]:
"""
OpenAI tool invokes:
@@ -1229,7 +1271,7 @@ def convert_to_gemini_tool_call_invoke(
part_dict: VertexPartType = {
"function_call": gemini_function_call
}
thought_signature = _get_thought_signature_from_tool(dict(tool))
thought_signature = _get_thought_signature_from_tool(dict(tool), model=model)
if thought_signature:
part_dict["thoughtSignature"] = thought_signature
@@ -1250,11 +1292,18 @@ def convert_to_gemini_tool_call_invoke(
}
# Extract thought signature from function_call's provider_specific_fields
thought_signature = None
provider_fields = function_call.get("provider_specific_fields") if isinstance(function_call, dict) else {}
if isinstance(provider_fields, dict):
thought_signature = provider_fields.get("thought_signature")
if thought_signature:
part_dict_function["thoughtSignature"] = thought_signature
# If no signature found and model is gemini-3, use dummy signature
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig
if not thought_signature and model and VertexGeminiConfig._is_gemini_3_or_newer(model):
thought_signature = _get_dummy_thought_signature()
if thought_signature:
part_dict_function["thoughtSignature"] = thought_signature
_parts_list.append(part_dict_function)
else: # don't silently drop params. Make it clear to user what's happening.
@@ -137,6 +137,7 @@ class ChunkProcessor:
"name": None,
"type": None,
"arguments": [],
"provider_specific_fields": None,
}
if hasattr(tool_call, "id") and tool_call.id:
@@ -156,22 +157,48 @@ class ChunkProcessor:
tool_call_map[index]["arguments"].append(
tool_call.function.arguments
)
# Preserve provider_specific_fields from streaming chunks
provider_fields = None
if hasattr(tool_call, "provider_specific_fields") and tool_call.provider_specific_fields:
provider_fields = tool_call.provider_specific_fields
elif hasattr(tool_call, "function") and hasattr(tool_call.function, "provider_specific_fields") and tool_call.function.provider_specific_fields:
provider_fields = tool_call.function.provider_specific_fields
if provider_fields:
# Merge provider_specific_fields if multiple chunks have them
if tool_call_map[index]["provider_specific_fields"] is None:
tool_call_map[index]["provider_specific_fields"] = {}
if isinstance(provider_fields, dict):
tool_call_map[index]["provider_specific_fields"].update(
provider_fields
)
# Convert the map to a list of tool calls
for index in sorted(tool_call_map.keys()):
tool_call_data = tool_call_map[index]
if tool_call_data["id"] and tool_call_data["name"]:
combined_arguments = "".join(tool_call_data["arguments"]) or "{}"
tool_calls_list.append(
ChatCompletionMessageToolCall(
id=tool_call_data["id"],
function=Function(
arguments=combined_arguments,
name=tool_call_data["name"],
),
type=tool_call_data["type"] or "function",
)
# Build function - provider_specific_fields should be on tool_call level, not function level
function = Function(
arguments=combined_arguments,
name=tool_call_data["name"],
)
# Prepare params for ChatCompletionMessageToolCall
tool_call_params = {
"id": tool_call_data["id"],
"function": function,
"type": tool_call_data["type"] or "function",
}
# Add provider_specific_fields if present (for thought signatures in Gemini 3)
if tool_call_data.get("provider_specific_fields"):
tool_call_params["provider_specific_fields"] = tool_call_data["provider_specific_fields"]
tool_call = ChatCompletionMessageToolCall(**tool_call_params)
tool_calls_list.append(tool_call)
return tool_calls_list
@@ -3,6 +3,7 @@ from typing import (
TYPE_CHECKING,
Any,
AsyncIterator,
Dict,
List,
Literal,
Optional,
@@ -129,6 +130,39 @@ class LiteLLMAnthropicMessagesAdapter:
### FOR [BETA] `/v1/messages` endpoint support
def _extract_signature_from_tool_call(
self, tool_call: Any
) -> Optional[str]:
"""
Extract signature from a tool call's provider_specific_fields.
Only checks provider_specific_fields, not thinking blocks.
"""
signature = None
if hasattr(tool_call, "provider_specific_fields") and tool_call.provider_specific_fields:
if "thought_signature" in tool_call.provider_specific_fields:
signature = tool_call.provider_specific_fields["thought_signature"]
elif (
hasattr(tool_call.function, "provider_specific_fields")
and tool_call.function.provider_specific_fields
):
if "thought_signature" in tool_call.function.provider_specific_fields:
signature = tool_call.function.provider_specific_fields["thought_signature"]
return signature
def _extract_signature_from_tool_use_content(
self, content: Dict[str, Any]
) -> Optional[str]:
"""
Extract signature from a tool_use content block's provider_specific_fields.
"""
provider_specific_fields = content.get("provider_specific_fields", {})
if provider_specific_fields:
return provider_specific_fields.get("signature")
return None
def translatable_anthropic_params(self) -> List:
"""
Which anthropic params, we need to translate to the openai format.
@@ -263,10 +297,18 @@ class LiteLLMAnthropicMessagesAdapter:
else:
assistant_message_str += content.get("text", "")
elif content.get("type") == "tool_use":
function_chunk = ChatCompletionToolCallFunctionChunk(
name=content.get("name", ""),
arguments=json.dumps(content.get("input", {})),
)
function_chunk: ChatCompletionToolCallFunctionChunk = {
"name": content.get("name", ""),
"arguments": json.dumps(content.get("input", {})),
}
signature = self._extract_signature_from_tool_use_content(content)
if signature:
provider_specific_fields: Dict[str, Any] = (
function_chunk.get("provider_specific_fields") or {}
)
provider_specific_fields["thought_signature"] = signature
function_chunk["provider_specific_fields"] = provider_specific_fields
tool_calls.append(
ChatCompletionAssistantToolCall(
@@ -512,18 +554,27 @@ class LiteLLMAnthropicMessagesAdapter:
and len(choice.message.tool_calls) > 0
):
for tool_call in choice.message.tool_calls:
new_content.append(
AnthropicResponseContentBlockToolUse(
type="tool_use",
id=tool_call.id,
name=tool_call.function.name or "",
input=(
json.loads(tool_call.function.arguments)
if tool_call.function.arguments
else {}
),
)
# Extract signature from provider_specific_fields only
signature = self._extract_signature_from_tool_call(tool_call)
provider_specific_fields = {}
if signature:
provider_specific_fields["signature"] = signature
tool_use_block = AnthropicResponseContentBlockToolUse(
type="tool_use",
id=tool_call.id,
name=tool_call.function.name or "",
input=(
json.loads(tool_call.function.arguments)
if tool_call.function.arguments
else {}
),
)
# Add provider_specific_fields if signature is present
if provider_specific_fields:
tool_use_block.provider_specific_fields = provider_specific_fields
new_content.append(tool_use_block)
# Handle text content
elif choice.message.content is not None:
new_content.append(
+1 -1
View File
@@ -140,4 +140,4 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig):
except Exception:
# If conversion fails, leave as is and let the API handle it
pass
return _gemini_convert_messages_with_history(messages=messages)
return _gemini_convert_messages_with_history(messages=messages, model=model)
@@ -173,7 +173,7 @@ def transform_openai_messages_to_gemini_context_caching(
supports_system_message=supports_system_message, messages=messages
)
transformed_messages = _gemini_convert_messages_with_history(messages=new_messages)
transformed_messages = _gemini_convert_messages_with_history(messages=new_messages, model=model)
model_name = "models/{}".format(model)
@@ -195,6 +195,7 @@ def check_if_part_exists_in_parts(
def _gemini_convert_messages_with_history( # noqa: PLR0915
messages: List[AllMessageValues],
model: Optional[str] = None,
) -> List[ContentType]:
"""
Converts given messages from OpenAI format to Gemini format
@@ -379,7 +380,7 @@ def _gemini_convert_messages_with_history( # noqa: PLR0915
or assistant_msg.get("function_call") is not None
): # support assistant tool invoke conversion
gemini_tool_call_parts = convert_to_gemini_tool_call_invoke(
assistant_msg
assistant_msg, model=model
)
## check if gemini_tool_call already exists in assistant_content
for gemini_tool_call_part in gemini_tool_call_parts:
@@ -898,11 +898,11 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
if VertexGeminiConfig._is_gemini_3_or_newer(model):
if "temperature" not in optional_params:
optional_params["temperature"] = 1.0
thinking_config = optional_params.get("thinkingConfig", {})
if (
"thinkingConfig" not in optional_params
or "thinkingLevel" not in optional_params.get("thinkingConfig", {})
"thinkingLevel" not in thinking_config
and "thinkingBudget" not in thinking_config
):
thinking_config = optional_params.get("thinkingConfig", {})
thinking_config["thinkingLevel"] = "low"
optional_params["thinkingConfig"] = thinking_config
@@ -1892,7 +1892,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
def _transform_messages(
self, messages: List[AllMessageValues], model: Optional[str] = None
) -> List[ContentType]:
return _gemini_convert_messages_with_history(messages=messages)
return _gemini_convert_messages_with_history(messages=messages, model=model)
def get_error_class(
self, error_message: str, status_code: int, headers: Union[Dict, httpx.Headers]
@@ -11773,10 +11773,12 @@
"supports_web_search": true
},
"gemini-3-pro-preview": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"cache_creation_input_token_cost_above_200k_tokens": 2.5e-07,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"input_cost_per_token_batches": 1e-06,
"litellm_provider": "vertex_ai-language-models",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
@@ -11790,10 +11792,60 @@
"mode": "chat",
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_above_200k_tokens": 1.8e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions"
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true
},
"vertex_ai/gemini-3-pro-preview": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"cache_creation_input_token_cost_above_200k_tokens": 2.5e-07,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"input_cost_per_token_batches": 1e-06,
"litellm_provider": "vertex_ai",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_above_200k_tokens": 1.8e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
@@ -13476,9 +13528,11 @@
"tpm": 800000
},
"gemini/gemini-3-pro-preview": {
"cache_read_input_token_cost": 3.125e-07,
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"input_cost_per_token_batches": 1e-06,
"litellm_provider": "gemini",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
@@ -13492,11 +13546,13 @@
"mode": "chat",
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_above_200k_tokens": 1.8e-05,
"output_cost_per_token_batches": 6e-06,
"rpm": 2000,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions"
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
+4
View File
@@ -458,6 +458,10 @@ class AnthropicResponseContentBlockToolUse(BaseModel):
id: str
name: str
input: dict
provider_specific_fields: Optional[Dict[str, Any]] = None
class Config:
extra = "allow" # Allow provider_specific_fields
class AnthropicResponseContentBlockThinking(BaseModel):
+2 -1
View File
@@ -1,6 +1,6 @@
from enum import Enum
from os import PathLike
from typing import IO, Any, Iterable, List, Literal, Mapping, Optional, Tuple, Union
from typing import IO, Any, Dict, Iterable, List, Literal, Mapping, Optional, Tuple, Union
import httpx
from openai._legacy_response import (
@@ -453,6 +453,7 @@ class ChatCompletionAudioDelta(TypedDict, total=False):
class ChatCompletionToolCallFunctionChunk(TypedDict, total=False):
name: Optional[str]
arguments: str
provider_specific_fields: Optional[Dict[str, Any]]
class ChatCompletionAssistantToolCall(TypedDict):
+60 -4
View File
@@ -11773,10 +11773,12 @@
"supports_web_search": true
},
"gemini-3-pro-preview": {
"cache_read_input_token_cost": 1.25e-07,
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"cache_creation_input_token_cost_above_200k_tokens": 2.5e-07,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"input_cost_per_token_batches": 1e-06,
"litellm_provider": "vertex_ai-language-models",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
@@ -11790,10 +11792,60 @@
"mode": "chat",
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_above_200k_tokens": 1.8e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions"
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
"image",
"audio",
"video"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_video_input": true,
"supports_vision": true,
"supports_web_search": true
},
"vertex_ai/gemini-3-pro-preview": {
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"cache_creation_input_token_cost_above_200k_tokens": 2.5e-07,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"input_cost_per_token_batches": 1e-06,
"litellm_provider": "vertex_ai",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
"max_images_per_prompt": 3000,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_pdf_size_mb": 30,
"max_tokens": 65535,
"max_video_length": 1,
"max_videos_per_prompt": 10,
"mode": "chat",
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_above_200k_tokens": 1.8e-05,
"output_cost_per_token_batches": 6e-06,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
@@ -13476,9 +13528,11 @@
"tpm": 800000
},
"gemini/gemini-3-pro-preview": {
"cache_read_input_token_cost": 3.125e-07,
"cache_read_input_token_cost": 2e-07,
"cache_read_input_token_cost_above_200k_tokens": 4e-07,
"input_cost_per_token": 2e-06,
"input_cost_per_token_above_200k_tokens": 4e-06,
"input_cost_per_token_batches": 1e-06,
"litellm_provider": "gemini",
"max_audio_length_hours": 8.4,
"max_audio_per_prompt": 1,
@@ -13492,11 +13546,13 @@
"mode": "chat",
"output_cost_per_token": 1.2e-05,
"output_cost_per_token_above_200k_tokens": 1.8e-05,
"output_cost_per_token_batches": 6e-06,
"rpm": 2000,
"source": "https://cloud.google.com/vertex-ai/generative-ai/pricing",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/completions"
"/v1/completions",
"/v1/batch"
],
"supported_modalities": [
"text",
@@ -790,3 +790,45 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image():
# Check final text
assert result[0]["content"][4]["type"] == "text"
assert result[0]["content"][4]["text"] == "What's the difference?"
def test_translate_anthropic_messages_to_openai_tool_use_with_signature():
"""Test that thought signatures from tool_use blocks are correctly extracted and placed in provider_specific_fields."""
test_signature = "EpYECpMEAdHtim9iBECdK1l5uVIIXoZZmq+PUBH9nz3Q6EMeIdEqWwVb5GlxSNtxuSkFoseFco5U4zxN/lacJxD2WUjFvEyL2GOkbPgXFeCcgNBMEYVRg7UAr45KGeWJJmJMoheLHezKawI1L94vi2PsB9TDpWv4vyAx1vKG2PByiVmWWtd0rondsdbENNp2Rrz3ol1zha+XhOtyhTCdSWce8GVD/zElklL3C0h9HrsTQrnNyouaZa9KlXZJ72XDCIkIlV0m6EtxbzdMwbH4sLFOpifRlRn+AmzXjxvLovRtn2bXh/X3bUgPxqypaST57Dlpddlk1Mt0oJmGFtwB/FH1JmK21cIC06uXtlUc8lm/9cTQLd5hcEUX+XRrmTdzqxDgRttN8CRfVUAGE7Er+prN4yCIdNtEQdZm8zymEpHTkYplJ/hK7SMf9Iu1k+eCDFYCzvQuzLcJtNpRaGS1BbVA3va5JKrEu96G7a3Wl3DyzmrH8N3+RA+UIHvP6P5v93tI/eTyfMY54rKpLGkfFeeSMAr5aSoUZVYkvFI8xGEcIrqLWPDF91MclLZa7USSVql0wYu1G9KD10IkopeKkTIAl81WfoY5+Kw1o4CHo7bEQ6tfTuTB4IEywf1XKMBYHmsfAe5B9ferkLYtnAzzt1hoiK1m/2CjX8yQAknRLsnAuyeXfJZRZidVKYOKaSDftddbXJpIlJApC"
anthropic_messages = [
AnthropicMessagesUserMessageParam(
role="user",
content=[{"type": "text", "text": "What's the weather like in London?"}],
),
AnthopicMessagesAssistantMessageParam(
role="assistant",
content=[
{
"type": "tool_use",
"id": "call_386f67af31f9415781bc35071405",
"name": "get_weather",
"input": {"location": "London"},
"provider_specific_fields": {
"signature": test_signature,
},
}
],
),
]
adapter = LiteLLMAnthropicMessagesAdapter()
result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages)
assert len(result) == 2
assert result[1]["role"] == "assistant"
assert "tool_calls" in result[1]
assert len(result[1]["tool_calls"]) == 1
# Verify thought signature is extracted and placed in provider_specific_fields
tool_call = result[1]["tool_calls"][0]
assert tool_call["id"] == "call_386f67af31f9415781bc35071405"
assert "function" in tool_call
assert "provider_specific_fields" in tool_call["function"]
assert tool_call["function"]["provider_specific_fields"]["thought_signature"] == test_signature
@@ -390,3 +390,151 @@ def test_thought_signature_with_function_call_mode():
assert "provider_specific_fields" in function
assert function["provider_specific_fields"]["thought_signature"] == test_signature
assert tools is None
def test_dummy_signature_added_for_gemini_3_conversation_history():
"""Test that dummy signatures are added when transferring conversation history from older models (like gemini-2.5-flash) to gemini-3."""
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_invoke,
)
import base64
# Simulate conversation history from gemini-2.5-flash (no thought signature)
assistant_message_from_older_model = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_current_temperature",
"arguments": '{"location": "Paris"}',
},
"index": 0,
# No provider_specific_fields - older model doesn't provide signatures
},
],
}
# Convert to Gemini format for gemini-3-pro-preview (should add dummy signature)
gemini_parts = convert_to_gemini_tool_call_invoke(
assistant_message_from_older_model, model="gemini-3-pro-preview"
)
# Verify dummy signature is added
assert len(gemini_parts) == 1
assert "function_call" in gemini_parts[0]
assert "thoughtSignature" in gemini_parts[0]
# Verify it's the expected dummy signature (base64 encoded "skip_thought_signature_validator")
expected_dummy = base64.b64encode(b"skip_thought_signature_validator").decode("utf-8")
assert gemini_parts[0]["thoughtSignature"] == expected_dummy
def test_dummy_signature_not_added_for_gemini_2_5():
"""Test that dummy signatures are NOT added when target model is not gemini-3."""
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_invoke,
)
# Simulate conversation history from gemini-2.5-flash (no thought signature)
assistant_message = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_current_temperature",
"arguments": '{"location": "Paris"}',
},
"index": 0,
# No provider_specific_fields
},
],
}
# Convert to Gemini format for gemini-2.5-flash (should NOT add dummy signature)
gemini_parts = convert_to_gemini_tool_call_invoke(
assistant_message, model="gemini-2.5-flash"
)
# Verify no dummy signature is added for non-gemini-3 models
assert len(gemini_parts) == 1
assert "function_call" in gemini_parts[0]
assert "thoughtSignature" not in gemini_parts[0]
def test_dummy_signature_not_added_when_signature_exists():
"""Test that dummy signatures are NOT added when a real signature already exists."""
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_invoke,
)
real_signature = "Co4CAdHtim/rWgXbz2Ghp4tShzLeMASrPw6JJyYIC3cbVyZnKzU3uv8/wVzyS2sKRPL2m8QQHHXbNQhEEz500G7n/4ZMmksdTtfQcJMoT76S1DGwhnAiLwTgWCNXs3lEb4M19EVYoWFxhrH5Lr9YMIquoU9U4paydGwvZyIyigamIg4B6WnxrRsf0KZV12gJed0DZuKczvOFtHz3zUnmZRlOiTzd5gBVyQM+5jv1VI8m4WUKd6cN/5a5ZvaA0ggiO6kdVhlpIVs7GczSEVJD8KH4u02X7VSnb7CvykqDntZzV0y8rZFBEFGKrChmeHlWXP4D1IB3F9KQyhuLgWImMzg4BajKVxxMU737JGnNISy5"
# Assistant message with existing thought signature
assistant_message_with_signature = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_current_temperature",
"arguments": '{"location": "Paris"}',
"provider_specific_fields": {
"thought_signature": real_signature,
},
},
"index": 0,
},
],
}
# Convert to Gemini format for gemini-3-pro-preview
gemini_parts = convert_to_gemini_tool_call_invoke(
assistant_message_with_signature, model="gemini-3-pro-preview"
)
# Verify real signature is preserved, not replaced with dummy
assert len(gemini_parts) == 1
assert "function_call" in gemini_parts[0]
assert "thoughtSignature" in gemini_parts[0]
assert gemini_parts[0]["thoughtSignature"] == real_signature
def test_dummy_signature_with_function_call_mode():
"""Test that dummy signatures are added for function_call mode when converting to gemini-3."""
from litellm.litellm_core_utils.prompt_templates.factory import (
convert_to_gemini_tool_call_invoke,
)
import base64
# Assistant message with function_call (not tool_calls) and no signature
assistant_message_function_call = {
"role": "assistant",
"content": None,
"function_call": {
"name": "get_current_temperature",
"arguments": '{"location": "Paris"}',
# No provider_specific_fields
},
}
# Convert to Gemini format for gemini-3-pro-preview
gemini_parts = convert_to_gemini_tool_call_invoke(
assistant_message_function_call, model="gemini-3-pro-preview"
)
# Verify dummy signature is added
assert len(gemini_parts) == 1
assert "function_call" in gemini_parts[0]
assert "thoughtSignature" in gemini_parts[0]
# Verify it's the expected dummy signature
expected_dummy = base64.b64encode(b"skip_thought_signature_validator").decode("utf-8")
assert gemini_parts[0]["thoughtSignature"] == expected_dummy