Skip to content

Commit d3e793f

Browse files
DeanChensjcopybara-github
authored andcommitted
fix: Handle empty message in LiteLLM response
When a turn ends with tool calls only, LiteLLM response may have an empty message. Previously, this raised a ValueError. This change returns an empty LlmResponse instead, matching the behavior of the streaming path. Re-implemented from GitHub PR #3699 to fix issue #3618 Closes #3618 Merge #3699 Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 938139278
1 parent 2e3d717 commit d3e793f

2 files changed

Lines changed: 126 additions & 20 deletions

File tree

src/google/adk/models/lite_llm.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1793,26 +1793,31 @@ def _model_response_to_generate_content_response(
17931793
message = first_choice.get("message", None)
17941794
finish_reason = first_choice.get("finish_reason", None)
17951795

1796-
if not message:
1797-
raise ValueError("No message in response")
1796+
# Handle case where message is None or empty (e.g., when the response contains
1797+
# no text content or tool calls). Create empty LlmResponse instead of raising error.
1798+
if message:
1799+
thought_parts = _convert_reasoning_value_to_parts(
1800+
_extract_reasoning_value(message)
1801+
)
1802+
llm_response = _message_to_generate_content_response(
1803+
message,
1804+
model_version=response.model,
1805+
thought_parts=thought_parts or None,
1806+
)
1807+
else:
1808+
# Create empty LlmResponse when message is None or empty
1809+
llm_response = LlmResponse(
1810+
content=types.Content(role="model", parts=[]),
1811+
model_version=response.model,
1812+
)
17981813

1799-
thought_parts = _convert_reasoning_value_to_parts(
1800-
_extract_reasoning_value(message)
1801-
)
1802-
llm_response = _message_to_generate_content_response(
1803-
message,
1804-
model_version=response.model,
1805-
thought_parts=thought_parts or None,
1806-
)
1807-
if finish_reason:
1808-
# If LiteLLM already provides a FinishReason enum (e.g., for Gemini), use
1809-
# it directly. Otherwise, map the finish_reason string to the enum.
1810-
if isinstance(finish_reason, types.FinishReason):
1811-
llm_response.finish_reason = finish_reason
1812-
else:
1813-
finish_reason_str = str(finish_reason).lower()
1814-
llm_response.finish_reason = _FINISH_REASON_MAPPING.get(
1815-
finish_reason_str, types.FinishReason.OTHER
1814+
mapped_finish_reason = _map_finish_reason(finish_reason)
1815+
if mapped_finish_reason:
1816+
llm_response.finish_reason = mapped_finish_reason
1817+
if mapped_finish_reason != types.FinishReason.STOP:
1818+
llm_response.error_code = mapped_finish_reason
1819+
llm_response.error_message = _finish_reason_to_error_message(
1820+
mapped_finish_reason
18161821
)
18171822
if response.get("usage", None):
18181823
usage_dict = response["usage"]
@@ -1888,7 +1893,7 @@ def _finish_reason_to_error_message(
18881893
"""Returns an error message for non-stop finish reasons."""
18891894
if finish_reason == types.FinishReason.MAX_TOKENS:
18901895
return "Maximum tokens reached"
1891-
return f"Finished with {finish_reason}"
1896+
return f"Finished with {finish_reason.name}"
18921897

18931898

18941899
def _enforce_strict_openai_schema(schema: dict[str, Any]) -> None:

tests/unittests/models/test_litellm.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4691,6 +4691,107 @@ async def test_finish_reason_propagation(
46914691
mock_acompletion.assert_called_once()
46924692

46934693

4694+
def test_model_response_to_generate_content_response_no_message_with_finish_reason():
4695+
"""Test response with no message but finish_reason returns empty LlmResponse.
4696+
4697+
This test covers issue #3618: when a turn ends with tool calls and no final
4698+
message, we should return an empty LlmResponse instead of raising ValueError.
4699+
"""
4700+
response = ModelResponse(
4701+
model="test_model",
4702+
choices=[{
4703+
"finish_reason": "tool_calls",
4704+
# message is missing/None
4705+
}],
4706+
usage={
4707+
"prompt_tokens": 10,
4708+
"completion_tokens": 5,
4709+
"total_tokens": 15,
4710+
},
4711+
)
4712+
# Force message to be None to guarantee hitting the else branch
4713+
response.choices[0].message = None
4714+
4715+
llm_response = _model_response_to_generate_content_response(response)
4716+
4717+
# Should return empty LlmResponse, not raise ValueError
4718+
assert llm_response.content is not None
4719+
assert llm_response.content.role == "model"
4720+
assert len(llm_response.content.parts) == 0
4721+
# tool_calls maps to STOP
4722+
assert llm_response.finish_reason == types.FinishReason.STOP
4723+
assert llm_response.usage_metadata is not None
4724+
assert llm_response.usage_metadata.prompt_token_count == 10
4725+
assert llm_response.usage_metadata.candidates_token_count == 5
4726+
assert llm_response.model_version == "test_model"
4727+
4728+
4729+
def test_model_response_to_generate_content_response_no_message_no_finish_reason():
4730+
"""Test response with no message and no finish_reason returns empty LlmResponse."""
4731+
response = ModelResponse(
4732+
model="test_model",
4733+
choices=[{
4734+
# Both message and finish_reason are missing
4735+
}],
4736+
)
4737+
# Force message to be None to guarantee hitting the else branch
4738+
response.choices[0].message = None
4739+
4740+
llm_response = _model_response_to_generate_content_response(response)
4741+
4742+
# Should return empty LlmResponse, not raise ValueError
4743+
assert llm_response.content is not None
4744+
assert llm_response.content.role == "model"
4745+
assert len(llm_response.content.parts) == 0
4746+
# finish_reason may be None or have a default value - the important thing
4747+
# is that we don't raise ValueError
4748+
assert llm_response.model_version == "test_model"
4749+
4750+
4751+
def test_model_response_to_generate_content_response_empty_message_dict():
4752+
"""Test response with empty message dict returns empty LlmResponse."""
4753+
response = ModelResponse(
4754+
model="test_model",
4755+
choices=[{
4756+
"message": {}, # Empty dict is falsy
4757+
"finish_reason": "stop",
4758+
}],
4759+
usage={
4760+
"prompt_tokens": 5,
4761+
"completion_tokens": 3,
4762+
"total_tokens": 8,
4763+
},
4764+
)
4765+
# Ensure we test the parsing of an empty message dictionary rather than None.
4766+
4767+
llm_response = _model_response_to_generate_content_response(response)
4768+
4769+
# Should return empty LlmResponse, not raise ValueError
4770+
assert llm_response.content is not None
4771+
assert llm_response.content.role == "model"
4772+
assert len(llm_response.content.parts) == 0
4773+
assert llm_response.finish_reason == types.FinishReason.STOP
4774+
assert llm_response.usage_metadata is not None
4775+
4776+
4777+
def test_model_response_to_generate_content_response_safety_finish_reason():
4778+
"""Test that SAFETY finish reason sets error_code and error_message."""
4779+
response = ModelResponse(
4780+
model="test_model",
4781+
choices=[{
4782+
"finish_reason": "content_filter",
4783+
}],
4784+
)
4785+
# Force message to be None to guarantee hitting the else branch
4786+
response.choices[0].message = None
4787+
4788+
llm_response = _model_response_to_generate_content_response(response)
4789+
4790+
assert llm_response.finish_reason == types.FinishReason.SAFETY
4791+
assert llm_response.error_code == types.FinishReason.SAFETY
4792+
assert llm_response.error_message == "Finished with SAFETY"
4793+
4794+
46944795
@pytest.mark.skip(reason="LiteLLM finish_reason mapping behaviour changed")
46954796
@pytest.mark.asyncio
46964797
async def test_finish_reason_unknown_maps_to_other(

0 commit comments

Comments
 (0)