@@ -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
46964797async def test_finish_reason_unknown_maps_to_other (
0 commit comments