fix: restore tool_calls finish_reason for empty-text final chunk (issue #22900)

The guard condition `choice.delta.content is None` excluded the case where
Gemini sends `parts: [{text: ""}]` (empty string, not None) as the final
chunk after tool calls. Widen to `not choice.delta.content` to cover both
None and empty-string. Also restores the test for this case that was dropped
in the previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
RoyVivat 2026-04-02 15:04:51 -07:00
parent 9cfe003617
commit 901144dbf8
No known key found for this signature in database
GPG key ID: 59743472EC86530E
2 changed files with 67 additions and 1 deletions

View file

@ -3120,7 +3120,7 @@ class ModelResponseIterator:
choice.delta is None
or (
not choice.delta.tool_calls
and choice.delta.content is None
and not choice.delta.content
)
)
):

View file

@ -337,3 +337,69 @@ def test_streaming_tool_calls_then_empty_content_finish_reason_is_tool_calls():
assert len(response2.choices) == 1
assert response2.choices[0].finish_reason == "tool_calls"
assert response2.choices[0].delta.content is None
def test_streaming_tool_calls_then_empty_text_finish_reason_is_tool_calls():
"""
When Gemini streams tool calls in one chunk and the final chunk has BOTH
empty content (parts: [{text: ""}]) AND finishReason="STOP", the
finish_reason must still be "tool_calls".
This covers models like gemini-3.1-flash-lite-preview that send the
final chunk with content (empty text) instead of omitting it entirely.
Ref: https://github.com/BerriAI/litellm/issues/22900
"""
logging_obj = _make_logging_obj()
iterator = ModelResponseIterator(
streaming_response=iter([]),
sync_stream=True,
logging_obj=logging_obj,
)
# Chunk 1: tool call with no finishReason
chunk_with_tool_calls = {
"candidates": [
{
"content": {
"parts": [
{
"functionCall": {
"name": "get_current_weather",
"args": {"location": "Boston, MA"},
}
}
],
"role": "model",
},
"index": 0,
}
],
}
# Chunk 2: finishReason="STOP" with empty text content (not None, but "")
chunk_empty_text_with_finish = {
"candidates": [
{
"content": {
"parts": [{"text": ""}],
"role": "model",
},
"finishReason": "STOP",
"index": 0,
}
],
}
# Process chunk 1: tool calls
response1 = iterator.chunk_parser(chunk_with_tool_calls)
assert response1 is not None
assert len(response1.choices) == 1
assert response1.choices[0].delta.tool_calls is not None
assert iterator.has_seen_tool_calls is True
# Process chunk 2: empty text content with finishReason
response2 = iterator.chunk_parser(chunk_empty_text_with_finish)
assert response2 is not None
assert len(response2.choices) == 1
assert response2.choices[0].finish_reason == "tool_calls"