From e22702ede293606d4a296ac7da6461b8a6b1e8bf Mon Sep 17 00:00:00 2001 From: sarojrout Date: Mon, 24 Nov 2025 11:33:23 -0800 Subject: [PATCH 1/3] fix(models): Handle empty message in LiteLLM response (fixes #3618) When a turn ends with only tool calls and no final agent message, _model_response_to_generate_content_response was raising ValueError. This fix returns an empty LlmResponse instead, allowing workflows to continue gracefully. Changes: - Return empty LlmResponse when message is None or empty - Preserve finish_reason and usage_metadata if available - Added comprehensive test cases for edge cases - Fixed line length issues for pylint compliance Fixes: #3618 --- src/google/adk/models/lite_llm.py | 35 ++++++++++- tests/unittests/models/test_litellm.py | 84 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 9e3698b190..17eb2479f4 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -823,11 +823,15 @@ def _model_response_to_generate_content_response( ) -> LlmResponse: """Converts a litellm response to LlmResponse. Also adds usage metadata. + When a response has no message (e.g., turn ends with only tool calls), + returns an empty LlmResponse with finish_reason and usage_metadata if + available, instead of raising ValueError. + Args: response: The model response. Returns: - The LlmResponse. + The LlmResponse. May have empty content if message is None. """ message = None @@ -837,8 +841,35 @@ def _model_response_to_generate_content_response( message = first_choice.get("message", None) finish_reason = first_choice.get("finish_reason", None) + # Handle case where message is None or empty (valid when turn ends with + # tool calls only). Return empty LlmResponse instead of raising error. if not message: - raise ValueError("No message in response") + # Create empty LlmResponse with finish_reason and usage_metadata + # if available + llm_response = LlmResponse( + content=types.Content(role="model", parts=[]), + model_version=response.model, + ) + if finish_reason: + # If LiteLLM already provides a FinishReason enum (e.g., for Gemini), use + # it directly. Otherwise, map the finish_reason string to the enum. + if isinstance(finish_reason, types.FinishReason): + llm_response.finish_reason = finish_reason + else: + finish_reason_str = str(finish_reason).lower() + llm_response.finish_reason = _FINISH_REASON_MAPPING.get( + finish_reason_str, types.FinishReason.OTHER + ) + if response.get("usage", None): + llm_response.usage_metadata = types.GenerateContentResponseUsageMetadata( + prompt_token_count=response["usage"].get("prompt_tokens", 0), + candidates_token_count=response["usage"].get("completion_tokens", 0), + total_token_count=response["usage"].get("total_tokens", 0), + cached_content_token_count=_extract_cached_prompt_tokens( + response["usage"] + ), + ) + return llm_response thought_parts = _convert_reasoning_value_to_parts( _extract_reasoning_value(message) diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index f65fc77a61..f868e7cf7e 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -2610,6 +2610,90 @@ async def test_finish_reason_propagation( mock_acompletion.assert_called_once() +def test_model_response_to_generate_content_response_no_message_with_finish_reason(): + """Test response with no message but finish_reason returns empty LlmResponse. + + This test covers issue #3618: when a turn ends with tool calls and no final + message, we should return an empty LlmResponse instead of raising ValueError. + """ + response = ModelResponse( + model="test_model", + choices=[ + { + "finish_reason": "tool_calls", + # message is missing/None + } + ], + usage={ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + ) + + llm_response = _model_response_to_generate_content_response(response) + + # Should return empty LlmResponse, not raise ValueError + assert llm_response.content is not None + assert llm_response.content.role == "model" + assert len(llm_response.content.parts) == 0 + # tool_calls maps to STOP + assert llm_response.finish_reason == types.FinishReason.STOP + assert llm_response.usage_metadata is not None + assert llm_response.usage_metadata.prompt_token_count == 10 + assert llm_response.usage_metadata.candidates_token_count == 5 + assert llm_response.model_version == "test_model" + + +def test_model_response_to_generate_content_response_no_message_no_finish_reason(): + """Test response with no message and no finish_reason returns empty LlmResponse.""" + response = ModelResponse( + model="test_model", + choices=[ + { + # Both message and finish_reason are missing + } + ], + ) + + llm_response = _model_response_to_generate_content_response(response) + + # Should return empty LlmResponse, not raise ValueError + assert llm_response.content is not None + assert llm_response.content.role == "model" + assert len(llm_response.content.parts) == 0 + # finish_reason may be None or have a default value - the important thing + # is that we don't raise ValueError + assert llm_response.model_version == "test_model" + + +def test_model_response_to_generate_content_response_empty_message_dict(): + """Test response with empty message dict returns empty LlmResponse.""" + response = ModelResponse( + model="test_model", + choices=[ + { + "message": {}, # Empty dict is falsy + "finish_reason": "stop", + } + ], + usage={ + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, + }, + ) + + llm_response = _model_response_to_generate_content_response(response) + + # Should return empty LlmResponse, not raise ValueError + assert llm_response.content is not None + assert llm_response.content.role == "model" + assert len(llm_response.content.parts) == 0 + assert llm_response.finish_reason == types.FinishReason.STOP + assert llm_response.usage_metadata is not None + + @pytest.mark.asyncio async def test_finish_reason_unknown_maps_to_other( mock_acompletion, lite_llm_instance From a47f91e57e375e9c19af9001a671c7440352f5ca Mon Sep 17 00:00:00 2001 From: sarojrout Date: Mon, 24 Nov 2025 14:14:52 -0800 Subject: [PATCH 2/3] refactor(models): Removed code duplication in LiteLLM response handler --- src/google/adk/models/lite_llm.py | 45 +++++++++---------------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 17eb2479f4..eba406b5ab 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -842,43 +842,24 @@ def _model_response_to_generate_content_response( finish_reason = first_choice.get("finish_reason", None) # Handle case where message is None or empty (valid when turn ends with - # tool calls only). Return empty LlmResponse instead of raising error. - if not message: - # Create empty LlmResponse with finish_reason and usage_metadata - # if available + # tool calls only). Create empty LlmResponse instead of raising error. + if message: + thought_parts = _convert_reasoning_value_to_parts( + _extract_reasoning_value(message) + ) + llm_response = _message_to_generate_content_response( + message, + model_version=response.model, + thought_parts=thought_parts or None, + ) + else: + # Create empty LlmResponse when message is None or empty llm_response = LlmResponse( content=types.Content(role="model", parts=[]), model_version=response.model, ) - if finish_reason: - # If LiteLLM already provides a FinishReason enum (e.g., for Gemini), use - # it directly. Otherwise, map the finish_reason string to the enum. - if isinstance(finish_reason, types.FinishReason): - llm_response.finish_reason = finish_reason - else: - finish_reason_str = str(finish_reason).lower() - llm_response.finish_reason = _FINISH_REASON_MAPPING.get( - finish_reason_str, types.FinishReason.OTHER - ) - if response.get("usage", None): - llm_response.usage_metadata = types.GenerateContentResponseUsageMetadata( - prompt_token_count=response["usage"].get("prompt_tokens", 0), - candidates_token_count=response["usage"].get("completion_tokens", 0), - total_token_count=response["usage"].get("total_tokens", 0), - cached_content_token_count=_extract_cached_prompt_tokens( - response["usage"] - ), - ) - return llm_response - thought_parts = _convert_reasoning_value_to_parts( - _extract_reasoning_value(message) - ) - llm_response = _message_to_generate_content_response( - message, - model_version=response.model, - thought_parts=thought_parts or None, - ) + # Common logic for finish_reason and usage_metadata if finish_reason: # If LiteLLM already provides a FinishReason enum (e.g., for Gemini), use # it directly. Otherwise, map the finish_reason string to the enum. From db293253cb4ee95ece93721813b754c7b519915c Mon Sep 17 00:00:00 2001 From: sarojrout Date: Sat, 29 Nov 2025 21:45:13 -0800 Subject: [PATCH 3/3] linting issue fixed #3699 --- tests/unittests/models/test_litellm.py | 30 +++++++++++--------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index f868e7cf7e..82e7ac5b5b 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -2612,18 +2612,16 @@ async def test_finish_reason_propagation( def test_model_response_to_generate_content_response_no_message_with_finish_reason(): """Test response with no message but finish_reason returns empty LlmResponse. - + This test covers issue #3618: when a turn ends with tool calls and no final message, we should return an empty LlmResponse instead of raising ValueError. """ response = ModelResponse( model="test_model", - choices=[ - { - "finish_reason": "tool_calls", - # message is missing/None - } - ], + choices=[{ + "finish_reason": "tool_calls", + # message is missing/None + }], usage={ "prompt_tokens": 10, "completion_tokens": 5, @@ -2649,11 +2647,9 @@ def test_model_response_to_generate_content_response_no_message_no_finish_reason """Test response with no message and no finish_reason returns empty LlmResponse.""" response = ModelResponse( model="test_model", - choices=[ - { - # Both message and finish_reason are missing - } - ], + choices=[{ + # Both message and finish_reason are missing + }], ) llm_response = _model_response_to_generate_content_response(response) @@ -2671,12 +2667,10 @@ def test_model_response_to_generate_content_response_empty_message_dict(): """Test response with empty message dict returns empty LlmResponse.""" response = ModelResponse( model="test_model", - choices=[ - { - "message": {}, # Empty dict is falsy - "finish_reason": "stop", - } - ], + choices=[{ + "message": {}, # Empty dict is falsy + "finish_reason": "stop", + }], usage={ "prompt_tokens": 5, "completion_tokens": 3,