diff --git a/litellm/google_genai/adapters/transformation.py b/litellm/google_genai/adapters/transformation.py index c5d9fd124fa..63b521465e8 100644 --- a/litellm/google_genai/adapters/transformation.py +++ b/litellm/google_genai/adapters/transformation.py @@ -1,4 +1,5 @@ import json +import uuid from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Union, cast from litellm import verbose_logger @@ -380,124 +381,143 @@ class GoogleGenAIAdapter: ) ) + # Track tool_call_ids assigned by model-role functionCall parts so that + # user-role functionResponse parts can reference the correct id. + # Key: function name, Value: list of assigned ids (FIFO consumed). + pending_tool_call_ids: Dict[str, List[str]] = {} + for content in contents: role = content.get("role", "user") parts = content.get("parts", []) if role == "user": - # Handle user messages with potential function responses - content_parts: List[ - Union[ChatCompletionTextObject, ChatCompletionImageObject] - ] = [] - tool_messages: List[ChatCompletionToolMessage] = [] - - for part in parts: - if isinstance(part, dict): - if "text" in part: - content_parts.append( - cast( - ChatCompletionTextObject, - {"type": "text", "text": part["text"]}, - ) - ) - elif "inline_data" in part: - # Handle Base64 image data - inline_data = part["inline_data"] - mime_type = inline_data.get("mime_type", "image/jpeg") - data = inline_data.get("data", "") - content_parts.append( - cast( - ChatCompletionImageObject, - { - "type": "image_url", - "image_url": { - "url": f"data:{mime_type};base64,{data}" - }, - }, - ) - ) - elif "functionResponse" in part: - # Transform function response to tool message - func_response = part["functionResponse"] - tool_message = ChatCompletionToolMessage( - role="tool", - tool_call_id=f"call_{func_response.get('name', 'unknown')}", - content=json.dumps(func_response.get("response", {})), - ) - tool_messages.append(tool_message) - elif isinstance(part, str): - content_parts.append( - cast( - ChatCompletionTextObject, {"type": "text", "text": part} - ) - ) - - # Add user message if there's content - if content_parts: - # If only one text part, use simple string format for backward compatibility - if ( - len(content_parts) == 1 - and isinstance(content_parts[0], dict) - and content_parts[0].get("type") == "text" - ): - text_part = cast(ChatCompletionTextObject, content_parts[0]) - messages.append( - ChatCompletionUserMessage( - role="user", content=text_part["text"] - ) - ) - else: - # Use multimodal format (array of content parts) - messages.append( - ChatCompletionUserMessage( - role="user", content=content_parts - ) - ) - - # Add tool messages - messages.extend(tool_messages) - + self._transform_user_parts(parts, messages, pending_tool_call_ids) elif role == "model": - # Handle assistant messages with potential function calls - combined_text = "" - tool_calls: List[ChatCompletionAssistantToolCall] = [] - - for part in parts: - if isinstance(part, dict): - if "text" in part: - combined_text += part["text"] - elif "functionCall" in part: - # Transform function call to tool call - func_call = part["functionCall"] - tool_call = ChatCompletionAssistantToolCall( - id=f"call_{func_call.get('name', 'unknown')}", - type="function", - function=ChatCompletionToolCallFunctionChunk( - name=func_call.get("name", ""), - arguments=json.dumps(func_call.get("args", {})), - ), - ) - tool_calls.append(tool_call) - elif isinstance(part, str): - combined_text += part - - # Create assistant message - if tool_calls: - assistant_message = ChatCompletionAssistantMessage( - role="assistant", - content=combined_text if combined_text else None, - tool_calls=tool_calls, - ) - else: - assistant_message = ChatCompletionAssistantMessage( - role="assistant", - content=combined_text if combined_text else None, - ) - - messages.append(assistant_message) + self._transform_model_parts(parts, messages, pending_tool_call_ids) return messages + def _transform_user_parts( + self, + parts: List[Any], + messages: List[AllMessageValues], + pending_tool_call_ids: Dict[str, List[str]], + ) -> None: + """Transform user-role parts including functionResponse matching.""" + content_parts: List[ + Union[ChatCompletionTextObject, ChatCompletionImageObject] + ] = [] + tool_messages: List[ChatCompletionToolMessage] = [] + + for part in parts: + if isinstance(part, dict): + if "text" in part: + content_parts.append( + cast( + ChatCompletionTextObject, + {"type": "text", "text": part["text"]}, + ) + ) + elif "inline_data" in part: + inline_data = part["inline_data"] + mime_type = inline_data.get("mime_type", "image/jpeg") + data = inline_data.get("data", "") + content_parts.append( + cast( + ChatCompletionImageObject, + { + "type": "image_url", + "image_url": {"url": f"data:{mime_type};base64,{data}"}, + }, + ) + ) + elif "functionResponse" in part: + # Match the tool_call_id from the preceding model + # turn's functionCall with the same name (FIFO). + func_response = part["functionResponse"] + func_name = func_response.get("name", "unknown") + pending_ids = pending_tool_call_ids.get(func_name, []) + if pending_ids: + matched_id = pending_ids.pop(0) + else: + matched_id = f"call_{uuid.uuid4().hex[:24]}" + tool_messages.append( + ChatCompletionToolMessage( + role="tool", + tool_call_id=matched_id, + content=json.dumps(func_response.get("response", {})), + ) + ) + elif isinstance(part, str): + content_parts.append( + cast(ChatCompletionTextObject, {"type": "text", "text": part}) + ) + + if content_parts: + if ( + len(content_parts) == 1 + and isinstance(content_parts[0], dict) + and content_parts[0].get("type") == "text" + ): + text_part = cast(ChatCompletionTextObject, content_parts[0]) + messages.append( + ChatCompletionUserMessage(role="user", content=text_part["text"]) + ) + else: + messages.append( + ChatCompletionUserMessage(role="user", content=content_parts) + ) + + messages.extend(tool_messages) + + def _transform_model_parts( + self, + parts: List[Any], + messages: List[AllMessageValues], + pending_tool_call_ids: Dict[str, List[str]], + ) -> None: + """Transform model-role parts including unique functionCall id generation.""" + combined_text = "" + tool_calls: List[ChatCompletionAssistantToolCall] = [] + + for part in parts: + if isinstance(part, dict): + if "text" in part: + combined_text += part["text"] + elif "functionCall" in part: + func_call = part["functionCall"] + func_name = func_call.get("name", "unknown") + call_id = f"call_{uuid.uuid4().hex[:24]}" + pending_tool_call_ids.setdefault(func_name, []).append(call_id) + tool_calls.append( + ChatCompletionAssistantToolCall( + id=call_id, + type="function", + function=ChatCompletionToolCallFunctionChunk( + name=func_name, + arguments=json.dumps(func_call.get("args", {})), + ), + ) + ) + elif isinstance(part, str): + combined_text += part + + if tool_calls: + messages.append( + ChatCompletionAssistantMessage( + role="assistant", + content=combined_text if combined_text else None, + tool_calls=tool_calls, + ) + ) + else: + messages.append( + ChatCompletionAssistantMessage( + role="assistant", + content=combined_text if combined_text else None, + ) + ) + def translate_completion_to_generate_content( self, response: ModelResponse, diff --git a/tests/test_litellm/google_genai/test_google_genai_adapter.py b/tests/test_litellm/google_genai/test_google_genai_adapter.py index f21564546a8..bd951df5bfb 100644 --- a/tests/test_litellm/google_genai/test_google_genai_adapter.py +++ b/tests/test_litellm/google_genai/test_google_genai_adapter.py @@ -306,7 +306,7 @@ def test_function_response_message_transformation(): # Check tool message tool_msg = messages[1] assert tool_msg["role"] == "tool" - assert "call_get_weather" in tool_msg["tool_call_id"] + assert tool_msg["tool_call_id"].startswith("call_") # Verify function response content response_content = json.loads(tool_msg["content"]) diff --git a/tests/test_litellm/google_genai/test_google_genai_adapter_tool_call_id.py b/tests/test_litellm/google_genai/test_google_genai_adapter_tool_call_id.py new file mode 100644 index 00000000000..ec7db965f40 --- /dev/null +++ b/tests/test_litellm/google_genai/test_google_genai_adapter_tool_call_id.py @@ -0,0 +1,505 @@ +""" +Tests for unique tool_call_id generation in the Google GenAI adapter. + +Covers: +- Unique IDs for repeated calls to the same function +- FIFO matching between functionCall and functionResponse +- Multi-turn conversations with interleaved tool calls +- Fallback ID generation when no preceding functionCall exists + +Related issue: functionCall/functionResponse parts in Gemini-native +contents caused tool_call_id collisions when the same function was +called multiple times (e.g. get_weather for two cities). The adapter +now generates uuid-based IDs and matches responses via FIFO ordering. +""" + +import json +import os +import sys + +import pytest + +sys.path.insert(0, os.path.abspath("../../..")) + +from litellm.google_genai.adapters.transformation import GoogleGenAIAdapter + + +@pytest.fixture +def adapter(): + return GoogleGenAIAdapter() + + +class TestToolCallIdUniqueness: + """tool_call_ids must be globally unique, even for repeated function names.""" + + def test_single_function_call_gets_unique_id(self, adapter): + """A single functionCall should produce a unique call_* id.""" + contents = [ + {"role": "user", "parts": [{"text": "What's the weather?"}]}, + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "London"}, + } + } + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + assistant_msg = messages[1] + + assert assistant_msg["role"] == "assistant" + tool_calls = assistant_msg.get("tool_calls", []) + assert len(tool_calls) == 1 + assert tool_calls[0]["id"].startswith("call_") + assert len(tool_calls[0]["id"]) > len("call_") + + def test_duplicate_function_names_get_distinct_ids(self, adapter): + """Two calls to the same function in one turn must have different IDs.""" + contents = [ + {"role": "user", "parts": [{"text": "Weather in London and Paris"}]}, + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "London"}, + } + }, + { + "functionCall": { + "name": "get_weather", + "args": {"city": "Paris"}, + } + }, + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + assistant_msg = messages[1] + + tool_calls = assistant_msg.get("tool_calls", []) + assert len(tool_calls) == 2 + id_set = {tc["id"] for tc in tool_calls} + assert len(id_set) == 2, "Duplicate tool_call_ids detected" + + def test_different_functions_get_distinct_ids(self, adapter): + """Calls to different functions must also produce distinct IDs.""" + contents = [ + {"role": "user", "parts": [{"text": "Weather and time"}]}, + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "London"}, + } + }, + { + "functionCall": { + "name": "get_time", + "args": {"timezone": "UTC"}, + } + }, + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + assistant_msg = messages[1] + + tool_calls = assistant_msg.get("tool_calls", []) + assert len(tool_calls) == 2 + id_set = {tc["id"] for tc in tool_calls} + assert len(id_set) == 2 + + +class TestFunctionResponseIdMatching: + """functionResponse tool_call_ids must match the preceding functionCall.""" + + def test_response_matches_call_id(self, adapter): + """A functionResponse should carry the same id as its functionCall.""" + contents = [ + {"role": "user", "parts": [{"text": "Weather?"}]}, + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "London"}, + } + } + ], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "get_weather", + "response": {"temp": "15C"}, + } + } + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + + # messages[1] = assistant with tool_calls + call_id = messages[1]["tool_calls"][0]["id"] + # messages[2] = tool response + assert messages[2]["role"] == "tool" + assert messages[2]["tool_call_id"] == call_id + + def test_fifo_matching_for_duplicate_function_names(self, adapter): + """When the same function is called twice, responses match in order.""" + contents = [ + {"role": "user", "parts": [{"text": "Two cities"}]}, + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "London"}, + } + }, + { + "functionCall": { + "name": "get_weather", + "args": {"city": "Paris"}, + } + }, + ], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "get_weather", + "response": {"temp": "15C"}, + } + }, + { + "functionResponse": { + "name": "get_weather", + "response": {"temp": "18C"}, + } + }, + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + + call_ids = [tc["id"] for tc in messages[1]["tool_calls"]] + assert len(call_ids) == 2 + assert call_ids[0] != call_ids[1] + + # Tool messages should match in FIFO order + tool_msgs = [m for m in messages if m.get("role") == "tool"] + assert len(tool_msgs) == 2 + assert tool_msgs[0]["tool_call_id"] == call_ids[0] + assert tool_msgs[1]["tool_call_id"] == call_ids[1] + + def test_mixed_functions_match_correctly(self, adapter): + """Multiple different functions match their responses correctly.""" + contents = [ + {"role": "user", "parts": [{"text": "Weather and time"}]}, + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "London"}, + } + }, + { + "functionCall": { + "name": "get_time", + "args": {"tz": "UTC"}, + } + }, + ], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "get_weather", + "response": {"temp": "15C"}, + } + }, + { + "functionResponse": { + "name": "get_time", + "response": {"time": "12:00"}, + } + }, + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + + weather_call_id = messages[1]["tool_calls"][0]["id"] + time_call_id = messages[1]["tool_calls"][1]["id"] + + tool_msgs = [m for m in messages if m.get("role") == "tool"] + # get_weather response matches get_weather call + assert tool_msgs[0]["tool_call_id"] == weather_call_id + assert json.loads(tool_msgs[0]["content"]) == {"temp": "15C"} + # get_time response matches get_time call + assert tool_msgs[1]["tool_call_id"] == time_call_id + assert json.loads(tool_msgs[1]["content"]) == {"time": "12:00"} + + +class TestMultiTurnToolCalling: + """End-to-end multi-turn conversations with tool use.""" + + def test_full_multi_turn_tool_conversation(self, adapter): + """ + Simulate: user asks -> model calls tool -> user sends result -> + model calls another tool -> user sends result -> model answers. + """ + contents = [ + {"role": "user", "parts": [{"text": "Add 2+3 then multiply by 4"}]}, + # Turn 1: model calls add + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "add", + "args": {"a": 2, "b": 3}, + } + } + ], + }, + # Turn 1 response + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "add", + "response": {"result": 5}, + } + } + ], + }, + # Turn 2: model calls multiply + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "multiply", + "args": {"a": 5, "b": 4}, + } + } + ], + }, + # Turn 2 response + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "multiply", + "response": {"result": 20}, + } + } + ], + }, + # Final answer + { + "role": "model", + "parts": [{"text": "The result is 20."}], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + + # Verify structure: user, assistant+tool_calls, tool, assistant+tool_calls, tool, assistant + assert messages[0]["role"] == "user" + assert messages[1]["role"] == "assistant" + assert len(messages[1]["tool_calls"]) == 1 + assert messages[2]["role"] == "tool" + assert messages[2]["tool_call_id"] == messages[1]["tool_calls"][0]["id"] + assert messages[3]["role"] == "assistant" + assert len(messages[3]["tool_calls"]) == 1 + assert messages[4]["role"] == "tool" + assert messages[4]["tool_call_id"] == messages[3]["tool_calls"][0]["id"] + assert messages[5]["role"] == "assistant" + assert messages[5]["content"] == "The result is 20." + + # All tool_call_ids must be distinct + all_ids = { + messages[1]["tool_calls"][0]["id"], + messages[3]["tool_calls"][0]["id"], + } + assert len(all_ids) == 2 + + def test_same_function_reused_across_separate_turns(self, adapter): + """ + The same function called in turn 1 AND turn 2 must produce distinct + IDs, and each turn's response must match its own turn's call. + """ + contents = [ + {"role": "user", "parts": [{"text": "Step 1"}]}, + # Turn 1: model calls get_weather + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "London"}, + } + } + ], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "get_weather", + "response": {"temp": "15C"}, + } + } + ], + }, + # Turn 2: model calls get_weather AGAIN + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "get_weather", + "args": {"city": "Paris"}, + } + } + ], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "get_weather", + "response": {"temp": "18C"}, + } + } + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + + # Turn 1: assistant[1] -> tool[2] + turn1_call_id = messages[1]["tool_calls"][0]["id"] + assert messages[2]["tool_call_id"] == turn1_call_id + + # Turn 2: assistant[3] -> tool[4] + turn2_call_id = messages[3]["tool_calls"][0]["id"] + assert messages[4]["tool_call_id"] == turn2_call_id + + # IDs across turns must be distinct + assert turn1_call_id != turn2_call_id + + def test_orphan_function_response_gets_fresh_id(self, adapter): + """ + A functionResponse with no preceding functionCall should still + produce a valid (generated) tool_call_id, not crash. + """ + contents = [ + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "unknown_func", + "response": {"data": "value"}, + } + } + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + + assert len(messages) == 1 + assert messages[0]["role"] == "tool" + assert messages[0]["tool_call_id"].startswith("call_") + assert len(messages[0]["tool_call_id"]) > len("call_") + + def test_function_response_content_serialization(self, adapter): + """functionResponse.response should be JSON-serialized as content.""" + contents = [ + { + "role": "model", + "parts": [ + { + "functionCall": { + "name": "search", + "args": {"q": "test"}, + } + } + ], + }, + { + "role": "user", + "parts": [ + { + "functionResponse": { + "name": "search", + "response": {"results": [1, 2, 3], "total": 3}, + } + } + ], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + + tool_msg = [m for m in messages if m.get("role") == "tool"][0] + parsed = json.loads(tool_msg["content"]) + assert parsed == {"results": [1, 2, 3], "total": 3} + + def test_inline_data_and_string_parts(self, adapter): + """inline_data and bare-string parts are handled in user turns.""" + contents = [ + { + "role": "user", + "parts": [ + "bare string part", + { + "inline_data": { + "mime_type": "image/png", + "data": "iVBORw0KGgo=", + } + }, + ], + }, + { + "role": "model", + "parts": ["model bare string"], + }, + ] + messages = adapter._transform_contents_to_messages(contents) + + user_msg = messages[0] + assert user_msg["role"] == "user" + assert len(user_msg["content"]) == 2 + assert user_msg["content"][0]["type"] == "text" + assert user_msg["content"][0]["text"] == "bare string part" + assert user_msg["content"][1]["type"] == "image_url" + assert "data:image/png;base64,iVBORw0KGgo=" in user_msg["content"][1]["image_url"]["url"] + + assistant_msg = messages[1] + assert assistant_msg["role"] == "assistant" + assert assistant_msg["content"] == "model bare string"