diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index f160c5f927..e945229168 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -626,6 +626,20 @@ def _extract_reasoning_value(message: Message | Delta | None) -> Any: return message.get("reasoning") +_GEMMA4_MODEL_PATTERN = re.compile(r"gemma-?4") + + +def _is_gemma4_model(model: str) -> bool: + """Detects Gemma 4 models across naming conventions. + + Ollama uses "gemma4" (e.g. "ollama/gemma4:e2b"), while Hugging Face, + vLLM, and llama.cpp use the hyphenated "gemma-4" (e.g. + "google/gemma-4-26B-A4B"). Both need role='tool_responses' for tool + results. + """ + return bool(_GEMMA4_MODEL_PATTERN.search(model.lower())) + + class ChatCompletionFileUrlObject(TypedDict, total=False): file_data: str file_id: str @@ -997,7 +1011,8 @@ async def _content_to_message_param( # from the tool call, instead of OpenAI-compatible 'tool' role used by other models. # Earlier Gemma versions before version 4 do not support tool use, # so this check is intentionally scoped to only look for "gemma4" in the model name. - tool_role = "tool_responses" if "gemma4" in model.lower() else "tool" + tool_role = "tool_responses" if _is_gemma4_model(model) else "tool" + tool_messages.append( ChatCompletionToolMessage( role=tool_role, @@ -1165,7 +1180,7 @@ def _ensure_tool_results(messages: List[Message], model: str) -> List[Message]: healed_messages: List[Message] = [] pending_tool_call_ids: List[str] = [] - expected_tool_role = "tool_responses" if "gemma4" in model.lower() else "tool" + expected_tool_role = "tool_responses" if _is_gemma4_model(model) else "tool" for message in messages: role = message.get("role") diff --git a/tests/unittests/models/test_lite_llm_gemma_tool_role.py b/tests/unittests/models/test_lite_llm_gemma_tool_role.py index 901978dff8..5f73ab0799 100644 --- a/tests/unittests/models/test_lite_llm_gemma_tool_role.py +++ b/tests/unittests/models/test_lite_llm_gemma_tool_role.py @@ -91,6 +91,18 @@ async def test_gemma4_model_uses_tool_responses_role(self): "template; role='tool' causes infinite tool-calling loops." ) + @pytest.mark.asyncio + async def test_gemma4_hf_style_naming_uses_tool_responses_role(self): + """Hyphenated 'gemma-4' naming should also get role='tool_responses'.""" + content = _make_function_response_content() + + result = await _content_to_message_param(content, model="google/gemma-4-26B-A4B") + + 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.""" @@ -100,6 +112,7 @@ async def test_gemma4_uppercase_model_name(self): 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."""