fix(gemini): generate unique tool_call_ids in GoogleGenAI adapter

The adapter used deterministic f"call_{name}" for tool_call_ids,
causing collisions when the same function was called multiple times
(e.g. get_weather for London AND Paris).

Changes:
- Generate uuid-based unique IDs for each functionCall part
- Maintain a per-name FIFO queue to match functionResponse parts
  to the correct preceding functionCall
- Fallback to a fresh uuid when no preceding call exists

Fixes: https://github.com/BerriAI/litellm/issues/27078

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
netbrah 2026-05-03 04:12:14 -04:00
parent 934ecdca78
commit 9cf922b096
3 changed files with 635 additions and 110 deletions

View file

@ -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,

View file

@ -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"])

View file

@ -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"