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
This commit is contained in:
BillionClaw 2026-03-24 11:55:11 +08:00
parent 3292d02aa4
commit a8594dac23
2 changed files with 84 additions and 0 deletions

View file

@ -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
)

View file

@ -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