diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 9b1cbbd577..44987bd1d0 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -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. diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 2f85c7aef6..ddcf81b5ba 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -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 diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index a786f06921..0e905014fe 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -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( diff --git a/litellm/llms/gemini/chat/transformation.py b/litellm/llms/gemini/chat/transformation.py index f7ce03a34d..c5e2d8b3da 100644 --- a/litellm/llms/gemini/chat/transformation.py +++ b/litellm/llms/gemini/chat/transformation.py @@ -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) diff --git a/litellm/llms/vertex_ai/context_caching/transformation.py b/litellm/llms/vertex_ai/context_caching/transformation.py index bb40b7665c..bc5c1b451f 100644 --- a/litellm/llms/vertex_ai/context_caching/transformation.py +++ b/litellm/llms/vertex_ai/context_caching/transformation.py @@ -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) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index a971dab942..e4fcd35b95 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -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: diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index ae32eb2894..d49d86f8ca 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -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] diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 6c38abdabb..b4b4f76386 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -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", diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 94cdb2775b..d216c2a8d5 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -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): diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index fd2f9b9d9c..c29b2f32ea 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -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): diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 6c38abdabb..b4b4f76386 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -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", diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 2db9cacb06..04e901d7be 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -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 diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py index b5474ab185..f7e9d77937 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_ai_gemini_transformation.py @@ -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