From 8eb1c07c0df798c1f9b866a723deb24adeb9f5db Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:08:37 +0530 Subject: [PATCH] fix(litellm): guard against Content with no parts in _content_to_message_param types.Content(role="user") defaults parts to None, and the fallback path can produce an empty user turn, so iterating content.parts in _content_to_message_param raised TypeError: 'NoneType' object is not iterable during LiteLLM request assembly, before any model call. Normalize parts to content.parts or [] once (matching the existing idiom in this module and the google_llm adapter, which skips contents without parts) and iterate the normalized list in all three branches. A content with no parts now yields a message with empty content instead of crashing. Add regression tests covering user and assistant roles for both parts=None and parts=[]. --- src/google/adk/models/lite_llm.py | 12 +++++++++--- tests/unittests/models/test_litellm.py | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 2e4cf65f32..60c2287d01 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -922,9 +922,15 @@ async def _content_to_message_param( """ _ensure_litellm_imported() + # A types.Content may have parts=None (e.g. types.Content(role="user")) or an + # empty list (e.g. from _append_fallback_user_content_if_missing). Normalize to + # an iterable so the loops below do not raise, matching the google_llm adapter + # which skips contents without parts. + parts = content.parts or [] + tool_messages: list[Message] = [] non_tool_parts: list[types.Part] = [] - for part in content.parts: + for part in parts: if part.function_response: response = part.function_response.response response_content = ( @@ -965,7 +971,7 @@ async def _content_to_message_param( role = _to_litellm_role(content.role) if role == "user": - user_parts = [part for part in content.parts if not part.thought] + user_parts = [part for part in parts if not part.thought] message_content = ( await _get_content(user_parts, provider=provider, model=model) or None ) @@ -974,7 +980,7 @@ async def _content_to_message_param( tool_calls = [] content_parts: list[types.Part] = [] reasoning_parts: list[types.Part] = [] - for part in content.parts: + for part in parts: if part.function_call: tool_call_id = part.function_call.id or "" tool_call_dict: ChatCompletionAssistantToolCall = { diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 6f4341f55f..a0322cb2b5 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -1928,6 +1928,26 @@ async def test_content_to_message_param_user_message(): assert message["content"] == "Test prompt" +@pytest.mark.asyncio +@pytest.mark.parametrize("parts", [None, []]) +async def test_content_to_message_param_user_message_without_parts(parts): + # types.Content(role="user") defaults parts to None, so a content with no + # parts must not raise (mirrors the google_llm adapter which skips it). + content = types.Content(role="user", parts=parts) + message = await _content_to_message_param(content) + assert message["role"] == "user" + assert message["content"] is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("parts", [None, []]) +async def test_content_to_message_param_assistant_message_without_parts(parts): + content = types.Content(role="assistant", parts=parts) + message = await _content_to_message_param(content) + assert message["role"] == "assistant" + assert message["content"] is None + + @pytest.mark.asyncio @pytest.mark.parametrize("file_uri,mime_type", FILE_URI_TEST_CASES) async def test_content_to_message_param_user_message_with_file_uri(