diff --git a/src/google/adk/cli/browser/assets/config/runtime-config.json b/src/google/adk/cli/browser/assets/config/runtime-config.json index 888614ad56..873e88b1f1 100644 --- a/src/google/adk/cli/browser/assets/config/runtime-config.json +++ b/src/google/adk/cli/browser/assets/config/runtime-config.json @@ -1,4 +1,4 @@ { "backendUrl": "", - "telemetry": false + "telemetry": null } diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index cd38fa48fb..c7e3cb5c0a 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -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( @@ -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( diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 57dfd17c14..3ca54df21c 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -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