ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
178 lines
5.9 KiB
Python
178 lines
5.9 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
"""Tests for Gemma-specific tool role handling in _content_to_message_param.
|
|
|
|
Gemma's chat template expects role='tool_responses' for tool result messages,
|
|
while the OpenAI-compatible default is role='tool'. This module verifies that
|
|
_content_to_message_param sets the correct role based on the model name.
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
from google.adk.models.lite_llm import _content_to_message_param
|
|
from google.genai import types
|
|
import pytest
|
|
|
|
|
|
def _make_function_response_content(
|
|
function_name: str = "get_weather",
|
|
response_data: dict[str, Any] | None = None,
|
|
call_id: str = "call_001",
|
|
) -> types.Content:
|
|
"""Builds a types.Content with a single function_response part."""
|
|
if response_data is None:
|
|
response_data = {"city": "Santiago de Cuba", "condition": "sunny"}
|
|
return types.Content(
|
|
role="user",
|
|
parts=[
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name=function_name,
|
|
response=response_data,
|
|
id=call_id,
|
|
)
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
def _make_multi_function_response_content(
|
|
call_ids: list[str] | None = None,
|
|
) -> types.Content:
|
|
"""Builds a types.Content with multiple function_response parts."""
|
|
if call_ids is None:
|
|
call_ids = ["call_001", "call_002"]
|
|
return types.Content(
|
|
role="user",
|
|
parts=[
|
|
types.Part(
|
|
function_response=types.FunctionResponse(
|
|
name=f"tool_{i}",
|
|
response={"result": f"value_{i}"},
|
|
id=call_id,
|
|
)
|
|
)
|
|
for i, call_id in enumerate(call_ids)
|
|
],
|
|
)
|
|
|
|
|
|
def _extract_role(msg) -> str:
|
|
"""Extracts role from a litellm message, whether dict or object."""
|
|
if isinstance(msg, dict):
|
|
return msg["role"]
|
|
return msg.role
|
|
|
|
|
|
class TestToolRoleSingleResponse:
|
|
"""_content_to_message_param with a single function_response part."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gemma4_model_uses_tool_responses_role(self):
|
|
"""Models containing 'gemma4' should get role='tool_responses'."""
|
|
content = _make_function_response_content()
|
|
|
|
result = await _content_to_message_param(content, model="ollama/gemma4:e2b")
|
|
|
|
assert _extract_role(result) == "tool_responses", (
|
|
"Gemma models require role='tool_responses' to match their chat "
|
|
"template; role='tool' causes infinite tool-calling loops."
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gemma4_uppercase_model_name(self):
|
|
"""Model name matching should be case-insensitive."""
|
|
content = _make_function_response_content()
|
|
|
|
result = await _content_to_message_param(content, model="ollama/Gemma4:31b")
|
|
|
|
assert _extract_role(result) == "tool_responses"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_call_id_and_content_preserved(self):
|
|
"""Fix must not alter tool_call_id or content — only role changes."""
|
|
content = _make_function_response_content(
|
|
response_data={"status": "ok"}, call_id="my_call_123"
|
|
)
|
|
|
|
result = await _content_to_message_param(content, model="ollama/gemma4:e2b")
|
|
|
|
if isinstance(result, dict):
|
|
assert result["tool_call_id"] == "my_call_123"
|
|
assert "ok" in result["content"]
|
|
else:
|
|
assert result.tool_call_id == "my_call_123"
|
|
assert "ok" in result.content
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_model_string_uses_tool_role(self):
|
|
"""Empty model string should fall back to default role='tool'."""
|
|
content = _make_function_response_content()
|
|
|
|
result = await _content_to_message_param(content, model="")
|
|
|
|
assert _extract_role(result) == "tool"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unrelated_models_use_tool_role(self):
|
|
"""Models that do not contain 'gemma4' must not be affected."""
|
|
unaffected_models = [
|
|
"ollama/llama3:8b",
|
|
"ollama/qwen2.5-coder:3b",
|
|
"anthropic/claude-3-opus",
|
|
"openai/gpt-4o",
|
|
"ollama/gemma3:4b", # gemma3 != gemma4
|
|
]
|
|
for model in unaffected_models:
|
|
content = _make_function_response_content()
|
|
result = await _content_to_message_param(content, model=model)
|
|
assert (
|
|
_extract_role(result) == "tool"
|
|
), f"Model '{model}' should not be affected by the Gemma4 fix."
|
|
|
|
|
|
class TestToolRoleMultipleResponses:
|
|
"""_content_to_message_param with multiple function_response parts."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_gemma4_all_messages_use_tool_responses_role(self):
|
|
"""All messages in a multi-response must have role='tool_responses'."""
|
|
content = _make_multi_function_response_content(
|
|
call_ids=["call_a", "call_b", "call_c"]
|
|
)
|
|
|
|
result = await _content_to_message_param(content, model="ollama/gemma4:4b")
|
|
|
|
assert isinstance(result, list)
|
|
assert len(result) == 3
|
|
for msg in result:
|
|
assert _extract_role(msg) == "tool_responses", (
|
|
"Every tool message in a multi-response must use 'tool_responses' "
|
|
"for Gemma4 models."
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_gemma_multi_response_uses_tool_role(self):
|
|
"""Non-Gemma multi-response messages should all have role='tool'."""
|
|
content = _make_multi_function_response_content(
|
|
call_ids=["call_a", "call_b"]
|
|
)
|
|
|
|
result = await _content_to_message_param(content, model="openai/gpt-4o")
|
|
|
|
assert isinstance(result, list)
|
|
for msg in result:
|
|
assert _extract_role(msg) == "tool"
|