From a8594dac239acc9f4bd7364a9a3f57bd7aaf2543 Mon Sep 17 00:00:00 2001 From: BillionClaw <267901332+BillionClaw@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:55:11 +0800 Subject: [PATCH] fix: Handle Gemini empty responses with finishReason=STOP but no content/parts When Gemini (particularly gemini-2.5-flash-lite) returns candidates with finishReason=STOP but without the 'parts' field in content (e.g., in long-running agentic tasks), _process_candidates skips these candidates because it requires 'parts' to be present. This resulted in an empty choices list without any error being raised. This fix adds a fallback after _process_candidates: if choices is still empty but candidates exist, create a choice with finish_reason=stop and content=None. This is consistent with the streaming path (lines 3080-3097) which already handles this case. Fixes https://github.com/BerriAI/litellm/issues/24442 --- .../vertex_and_google_ai_studio_gemini.py | 19 ++++++ ...test_vertex_and_google_ai_studio_gemini.py | 65 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index 36f51c5b2f5..18fa8d64036 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -2375,6 +2375,25 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): _candidates, model_response, logging_obj.optional_params ) + # Handle the case where _process_candidates produced no choices. + # This can happen when Gemini returns candidates with finishReason=STOP + # but no content/parts (e.g., empty response in agentic tasks with + # gemini-2.5-flash-lite). See https://discuss.ai.google.dev/t/ + # finishreason-stop-but-parts-is-missing-inside-candidate/99331 + if not model_response.choices and _candidates: + for candidate in _candidates: + finish_reason_str = candidate.get("finishReason") + choice = litellm.Choices( + finish_reason=VertexGeminiConfig._check_finish_reason( + None, finish_reason_str + ), + index=candidate.get("index", 0), + message={"role": "assistant", "content": None}, + logprobs=None, + enhancements=None, + ) + model_response.choices.append(choice) + usage = VertexGeminiConfig._calculate_usage( completion_response=completion_response ) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 3102a695961..47e08ebcc0c 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -672,6 +672,71 @@ def test_check_finish_reason(): ) +def test_vertex_ai_empty_content_with_stop_finish_reason(): + """ + Test that Gemini responses with finishReason=STOP but no content/parts + (empty response in agentic tasks) are handled correctly. + + See: https://github.com/BerriAI/litellm/issues/24442 + See: https://discuss.ai.google.dev/t/finishreason-stop-but-parts-is-missing-inside-candidate/99331 + """ + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + from litellm.types.llms.vertex_ai import GenerateContentResponseBody + + v = VertexGeminiConfig() + + # Simulate the raw API response that has finishReason=STOP but no parts + raw_response = { + "candidates": [ + { + "content": { + "role": "model" + # Note: no "parts" key - this is the bug condition + }, + "finishReason": "STOP", + "index": 0, + } + ], + "usageMetadata": { + "promptTokenCount": 3130, + "totalTokenCount": 3130, + "promptTokensDetails": [ + {"modality": "TEXT", "tokenCount": 3130} + ], + }, + "modelVersion": "gemini-2.5-flash-lite", + "responseId": "8ZfBaYu9LarVz7IPlJG7gQU", + } + + # Mock the logging object + mock_logging_obj = MagicMock() + mock_logging_obj.optional_params = {} + + # Transform the response + model_response = ModelResponse() + model_response._hidden_params = {} + + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + result = v._transform_google_generate_content_to_openai_model_response( + completion_response=GenerateContentResponseBody(**raw_response), + model_response=model_response, + model="gemini-2.5-flash-lite", + logging_obj=mock_logging_obj, + raw_response=MagicMock(headers={}), + ) + + # The response should have a choice with finish_reason=stop and content=None + assert len(result.choices) == 1 + assert result.choices[0].finish_reason == "stop" + assert result.choices[0].message.content is None + assert result.choices[0].message.role == "assistant" + + def test_finish_reason_unspecified_and_malformed_function_call(): """ Test that FINISH_REASON_UNSPECIFIED and MALFORMED_FUNCTION_CALL