Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"backendUrl": "",
"telemetry": false
"telemetry": null
}
33 changes: 20 additions & 13 deletions src/google/adk/models/lite_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3562,13 +3562,18 @@ def _reset_stream_buffers() -> None:
chunk.cache_creation_tokens,
)

# LiteLLM 1.81+ can set finish_reason="stop" on partial chunks. Only
# finalize tool calls on an explicit tool_calls/length finish_reason,
# or on a stop-only chunk (no content/tool deltas).
if function_calls and (
finish_reason == "tool_calls"
or finish_reason == "length"
or (finish_reason == "stop" and chunk is None)
# LiteLLM 1.81+ can set finish_reason="stop" on partial chunks, and
# _model_response_to_chunk repeats a chunk's finish_reason on every
# tool call that chunk carries. Ending the segment while a tool-call
# delta is still in hand finalizes after the first of several
# parallel calls, and _reset_stream_buffers then drops the rest, so
# only a chunk that carries no further delta ends it here. Every
# other terminal reason falls through to the end-of-stream
# finalizer below, which replays last_finish_reason.
if (
function_calls
and chunk is None
and finish_reason in ("tool_calls", "length", "stop")
):
aggregated_llm_response_with_tool_call = (
_finalize_tool_call_response(
Expand All @@ -3577,12 +3582,14 @@ def _reset_stream_buffers() -> None:
)
)
_reset_stream_buffers()
elif (text_parts or reasoning_parts) and (
finish_reason == "length"
or (
finish_reason == "stop"
and chunk is None
and not function_calls
elif (
(text_parts or reasoning_parts)
# Buffered tool calls outrank buffered text, as they did when
# the branch above still caught every terminal reason.
and not function_calls
and (
finish_reason == "length"
or (finish_reason == "stop" and chunk is None)
)
):
aggregated_llm_response = _finalize_text_response(
Expand Down
69 changes: 69 additions & 0 deletions tests/unittests/models/test_litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5099,6 +5099,75 @@ async def test_generate_content_async_stream_reason_does_not_carry_over(
assert text_responses[-1].finish_reason != types.FinishReason.MAX_TOKENS


@pytest.mark.asyncio
async def test_generate_content_async_stream_keeps_parallel_tool_calls(
mock_completion, lite_llm_instance
):
"""Parallel tool calls delivered with the finish reason are all kept.

A provider that emits several tool calls on the chunk that also carries
finish_reason stamps that reason on every one of them, so the segment must
not be finalized until the chunk's last call has been buffered.
"""
mock_completion.return_value = iter([
ModelResponseStream(
model="test_model",
choices=[
StreamingChoices(
finish_reason="tool_calls",
delta=Delta(
role="assistant",
tool_calls=[
ChatCompletionDeltaToolCall(
type="function",
id="call_1",
function=Function(
name="get_weather",
arguments='{"city":"SF"}',
),
index=0,
),
ChatCompletionDeltaToolCall(
type="function",
id="call_2",
function=Function(
name="get_time", arguments='{"tz":"UTC"}'
),
index=1,
),
],
),
)
],
),
])

llm_request = LlmRequest(
contents=[
types.Content(
role="user", parts=[types.Part.from_text(text="Test prompt")]
)
],
)

responses = [
response
async for response in lite_llm_instance.generate_content_async(
llm_request, stream=True
)
]

final = responses[-1]
function_calls = [
part.function_call for part in final.content.parts if part.function_call
]
assert [call.name for call in function_calls] == ["get_weather", "get_time"]
assert [call.args for call in function_calls] == [
{"city": "SF"},
{"tz": "UTC"},
]


@pytest.mark.asyncio
async def test_generate_content_async_stream_with_only_finish_reason(
mock_completion, lite_llm_instance
Expand Down
Loading