From 4108b03821bd4aca29c0776df0b39395b6351a3e Mon Sep 17 00:00:00 2001 From: Ashfaq <105435085+Ashfaqbs@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:25:48 +0530 Subject: [PATCH] fix(models): aggregate streaming thought parts for all LiteLlm providers The streaming finalizers passed the raw per-delta thought parts straight into the response, so a provider that streams reasoning token by token (e.g. xai/grok, OpenAI reasoning models via LiteLLM) produced one types.Part(thought=True) per delta in the aggregated response instead of one part per thinking block. _aggregate_streaming_thought_parts already did this joining, splitting on thought_signature to preserve Anthropic's per-block boundaries, but it was only wired into the Anthropic message- building path. Call it from both stream finalizers so every provider gets the same shape the non-streaming path already produces. Fixes #6895 --- src/google/adk/models/lite_llm.py | 12 ++++- tests/unittests/models/test_litellm.py | 72 ++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/google/adk/models/lite_llm.py b/src/google/adk/models/lite_llm.py index 85e90d4e6a3..d4d6c767637 100644 --- a/src/google/adk/models/lite_llm.py +++ b/src/google/adk/models/lite_llm.py @@ -3232,7 +3232,11 @@ def _finalize_tool_call_response( tool_calls=tool_calls, ), model_version=model_version, - thought_parts=list(reasoning_parts) if reasoning_parts else None, + thought_parts=( + _aggregate_streaming_thought_parts(reasoning_parts) + if reasoning_parts + else None + ), ) mapped_finish_reason = _map_finish_reason(finish_reason) llm_response.finish_reason = mapped_finish_reason @@ -3256,7 +3260,11 @@ def _finalize_text_response( content=message_content, ), model_version=model_version, - thought_parts=list(reasoning_parts) if reasoning_parts else None, + thought_parts=( + _aggregate_streaming_thought_parts(reasoning_parts) + if reasoning_parts + else None + ), ) mapped_finish_reason = _map_finish_reason(finish_reason) llm_response.finish_reason = mapped_finish_reason diff --git a/tests/unittests/models/test_litellm.py b/tests/unittests/models/test_litellm.py index 6c89f2a7256..2dad710ab77 100644 --- a/tests/unittests/models/test_litellm.py +++ b/tests/unittests/models/test_litellm.py @@ -4536,6 +4536,78 @@ async def test_generate_content_async_stream_with_reasoning_tokens( ) +@pytest.mark.asyncio +async def test_generate_content_async_stream_aggregates_reasoning_deltas( + mock_completion, lite_llm_instance +): + """Per-token reasoning deltas must collapse into one thought part. + + Regression test: the streaming finalizers passed the raw per-delta thought + parts straight through, so a model that streams reasoning token by token + (e.g. xai/grok via LiteLLM) produced one `types.Part(thought=True)` per + delta in the final, non-partial response instead of one part per thinking + block -- the same shape the non-streaming path already produces. + """ + streaming_reasoning_response = [ + ModelResponseStream( + model="test_model", + choices=[ + StreamingChoices( + finish_reason=None, + delta=Delta(role="assistant", reasoning_content="The"), + ) + ], + ), + ModelResponseStream( + model="test_model", + choices=[ + StreamingChoices( + finish_reason=None, + delta=Delta(role="assistant", reasoning_content=" user"), + ) + ], + ), + ModelResponseStream( + model="test_model", + choices=[ + StreamingChoices( + finish_reason=None, + delta=Delta(role="assistant", reasoning_content=" wants"), + ) + ], + ), + ModelResponseStream( + model="test_model", + choices=[ + StreamingChoices( + finish_reason=None, + delta=Delta(role="assistant", content="Hi!"), + ) + ], + ), + ModelResponseStream( + model="test_model", + choices=[StreamingChoices(finish_reason="stop")], + ), + ] + mock_completion.return_value = iter(streaming_reasoning_response) + + responses = [ + response + async for response in lite_llm_instance.generate_content_async( + LLM_REQUEST_WITH_FUNCTION_DECLARATION, stream=True + ) + ] + + final_response = responses[-1] + assert final_response.partial is not True + thought_parts = [ + part for part in final_response.content.parts if part.thought + ] + assert len(thought_parts) == 1 + assert thought_parts[0].text == "The user wants" + + @pytest.mark.asyncio async def test_generate_content_async_stream_with_usage_metadata( mock_completion, lite_llm_instance