From 0c69a993765761cb52a65ddd6486269dbce212e8 Mon Sep 17 00:00:00 2001 From: Ishan Raj Singh Date: Tue, 28 Oct 2025 18:07:15 +0530 Subject: [PATCH] fix(LiteLlm): align fallback content with BaseLlm._maybe_append_user_content and add tests - Add fallback user message when messages list is empty due to include_contents='none' - Use same fallback text as BaseLlm._maybe_append_user_content for consistency - Add comprehensive tests covering empty contents scenarios with and without tools - Use actual dict responses in tests for proper serialization testing - Fixes #3242 --- src/google/adk/models/lite_llm.py | 11 ++ tests/integration/models/test_lite_llm.py | 224 ++++++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 tests/integration/models/test_lite_llm.py diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 4c6be95d38..4bdc14d619 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -806,6 +806,17 @@ async def generate_content_async( _get_completion_inputs(llm_request) ) + # Fix for include_contents='none' resulting in empty content error + # Ensure messages list is not empty (aligns with _maybe_append_user_content fallback) + if not messages: + messages = [ + ChatCompletionUserMessage( + role="user", + content="Handle the requests as specified in the System Instruction." + ) + ] + + if "functions" in self._additional_args: # LiteLLM does not support both tools and functions together. tools = None diff --git a/tests/integration/models/test_lite_llm.py b/tests/integration/models/test_lite_llm.py new file mode 100644 index 0000000000..2e6d82134d --- /dev/null +++ b/tests/integration/models/test_lite_llm.py @@ -0,0 +1,224 @@ +# Copyright 2025 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 LiteLlm model with include_contents='none'.""" + +import pytest +from unittest.mock import AsyncMock, patch + +from google.adk.models.lite_llm import LiteLlm +from google.adk.models.llm_request import LlmRequest +from google.genai import types +from litellm import ChatCompletionMessageToolCall, Function + + +@pytest.mark.asyncio +async def test_include_contents_none_with_fallback(): + """Test that LiteLlm handles include_contents='none' without empty content error.""" + + # Create a minimal LlmRequest with no contents + config = types.GenerateContentConfig( + system_instruction="Continue the phrase of the last agent with a short sentence" + ) + + llm_request = LlmRequest( + contents=[], # Empty contents simulating include_contents='none' + config=config + ) + + # Use actual dict instead of MagicMock for proper serialization testing + mock_response = { + "choices": [{ + "message": { + "content": "This is a test response.", + "tool_calls": None + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15 + } + } + + # Initialize LiteLlm model + model = LiteLlm(model="gemini/gemini-2.0-flash") + + # Mock the acompletion method + with patch.object(model.llm_client, 'acompletion', new_callable=AsyncMock) as mock_acompletion: + mock_acompletion.return_value = mock_response + + # This should not raise an error about empty content + # Instead, it should add fallback content + response_generator = model.generate_content_async(llm_request, stream=False) + + # Verify we can get a response without error + response = None + async for resp in response_generator: + response = resp + break + + # Assert response is not None and has expected structure + assert response is not None + assert response.content is not None + assert response.content.role == "model" + + # Verify that acompletion was called with non-empty messages + call_args = mock_acompletion.call_args + messages = call_args.kwargs.get('messages', []) + assert len(messages) > 0, "Messages should not be empty" + + # Verify the fallback message is present + user_messages = [m for m in messages if m.get('role') == 'user'] + assert len(user_messages) > 0, "Should have at least one user message" + assert "Handle the requests as specified in the System Instruction" in str(user_messages[0].get('content', '')) + + +@pytest.mark.asyncio +async def test_include_contents_none_with_tools(): + """Test that LiteLlm handles include_contents='none' with tools.""" + + # Create a function declaration + function_decl = types.FunctionDeclaration( + name="get_weather", + description="Get weather for a city", + parameters=types.Schema( + type=types.Type.OBJECT, + properties={ + "city": types.Schema(type=types.Type.STRING, description="City name") + }, + required=["city"] + ) + ) + + config = types.GenerateContentConfig( + system_instruction="You are a helpful assistant", + tools=[types.Tool(function_declarations=[function_decl])] + ) + + llm_request = LlmRequest( + contents=[], # Empty contents + config=config + ) + + # Create proper tool call object for realistic testing + mock_tool_call = ChatCompletionMessageToolCall( + type="function", + id="call_123", + function=Function( + name="get_weather", + arguments='{"city": "New York"}' + ) + ) + + # Use actual dict response structure + mock_response = { + "choices": [{ + "message": { + "content": None, + "tool_calls": [mock_tool_call] + }, + "finish_reason": "tool_calls" + }], + "usage": { + "prompt_tokens": 15, + "completion_tokens": 10, + "total_tokens": 25 + } + } + + model = LiteLlm(model="gemini/gemini-2.0-flash") + + # Mock the acompletion method + with patch.object(model.llm_client, 'acompletion', new_callable=AsyncMock) as mock_acompletion: + mock_acompletion.return_value = mock_response + + # Should handle empty contents gracefully + response_generator = model.generate_content_async(llm_request, stream=False) + + response = None + async for resp in response_generator: + response = resp + break + + assert response is not None + assert response.content is not None + + # Verify that acompletion was called with non-empty messages + call_args = mock_acompletion.call_args + messages = call_args.kwargs.get('messages', []) + assert len(messages) > 0, "Messages should not be empty with tools" + + # Verify tools were passed + tools = call_args.kwargs.get('tools', None) + assert tools is not None, "Tools should be passed to acompletion" + assert len(tools) > 0, "Should have at least one tool" + + +@pytest.mark.asyncio +async def test_include_contents_with_existing_content(): + """Test that LiteLlm works normally when contents are provided.""" + + config = types.GenerateContentConfig( + system_instruction="You are a helpful assistant" + ) + + # Provide actual content + llm_request = LlmRequest( + contents=[ + types.Content( + role="user", + parts=[types.Part(text="What is the weather in Paris?")] + ) + ], + config=config + ) + + mock_response = { + "choices": [{ + "message": { + "content": "The weather in Paris is sunny.", + "tool_calls": None + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 8, + "total_tokens": 28 + } + } + + model = LiteLlm(model="gemini/gemini-2.0-flash") + + with patch.object(model.llm_client, 'acompletion', new_callable=AsyncMock) as mock_acompletion: + mock_acompletion.return_value = mock_response + + response_generator = model.generate_content_async(llm_request, stream=False) + + response = None + async for resp in response_generator: + response = resp + break + + assert response is not None + assert response.content is not None + assert response.content.role == "model" + + # Verify that user's actual content was used + call_args = mock_acompletion.call_args + messages = call_args.kwargs.get('messages', []) + user_messages = [m for m in messages if m.get('role') == 'user'] + assert any("Paris" in str(m.get('content', '')) for m in user_messages)