mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
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:
parent
934ecdca78
commit
9cf922b096
3 changed files with 635 additions and 110 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue