From 9ae4ea1616f0f04561082a6f75b8052e8939c69f Mon Sep 17 00:00:00 2001 From: xiajiahang Date: Tue, 13 Oct 2026 20:55:25 +0800 Subject: [PATCH 1/2] fix(streaming): preserve single-object tool calls during assembly --- .../streaming_chunk_builder_utils.py | 15 ++++-- .../test_streaming_chunk_builder_utils.py | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 2ceb0042f1f..5287ad3f2cf 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -143,8 +143,17 @@ class _ToolCallFragment(TypedDict, total=False): provider_specific_fields: ReadOnly[dict[str, object] | None] +_ToolCallEntry: TypeAlias = _ToolCallFragment | ChatCompletionDeltaToolCall | ChatCompletionDeltaCustomToolCall + + class _ToolCallDelta(TypedDict, total=False): - tool_calls: ReadOnly[Sequence[_ToolCallFragment | ChatCompletionDeltaToolCall | ChatCompletionDeltaCustomToolCall]] + tool_calls: ReadOnly[Sequence[_ToolCallEntry] | _ToolCallEntry] + + +def _normalize_tool_calls(tool_calls: Sequence[_ToolCallEntry] | _ToolCallEntry | None) -> Sequence[_ToolCallEntry]: + if isinstance(tool_calls, (dict, ChatCompletionDeltaToolCall, ChatCompletionDeltaCustomToolCall)): + return (tool_calls,) + return tool_calls or () class _ToolCallChoice(TypedDict, total=False): @@ -472,7 +481,7 @@ class ChunkProcessor: if not delta: continue choice_index = choice.get("index", 0) - for tool_call in delta.get("tool_calls") or (): + for tool_call in _normalize_tool_calls(delta.get("tool_calls")): if not tool_call: continue if isinstance(tool_call, dict): @@ -519,7 +528,7 @@ class ChunkProcessor: choices = chunk["choices"] for choice in choices: delta = choice.get("delta", {}) - tool_calls = delta.get("tool_calls") or () + tool_calls: Final = _normalize_tool_calls(delta.get("tool_calls")) choice_index = choice.get("index", 0) for tool_call in tool_calls: diff --git a/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py b/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py index 3f73eace59f..9b5e32efb6f 100644 --- a/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py +++ b/tests/unit/litellm_core_utils/test_streaming_chunk_builder_utils.py @@ -15,6 +15,10 @@ from litellm.types.utils import GenericStreamingChunk from litellm.litellm_core_utils.streaming_chunk_builder_utils import ChunkProcessor from litellm.llms.anthropic.chat.handler import ModelResponseIterator from litellm.types.utils import ( + ChatCompletionDeltaCustomToolCall, + ChatCompletionDeltaCustomToolCallPayload, + ChatCompletionMessageCustomToolCall, + Choices, ChatCompletionDeltaToolCall, ChatCompletionMessageToolCall, Delta, @@ -27,6 +31,53 @@ from litellm.types.utils import ( ) +@pytest.mark.parametrize("custom", (False, True)) +@pytest.mark.parametrize("as_dict", (False, True)) +@pytest.mark.parametrize("single", (False, True)) +def test_stream_chunk_builder_preserves_single_tool_call(custom: bool, as_dict: bool, single: bool) -> None: + tool_call: Final = ( + ChatCompletionDeltaCustomToolCall( + index=0, + id="call_one", + type="custom", + custom=ChatCompletionDeltaCustomToolCallPayload(name="lookup", input='{"key": "value"}'), + ) + if custom + else ChatCompletionDeltaToolCall( + index=0, + id="call_one", + type="function", + function=Function(name="lookup", arguments='{"key": "value"}'), + ) + ) + entry: Final = tool_call.model_dump() if as_dict else tool_call + chunk: Final = ModelResponseStream( + id="chatcmpl-single", + model="test-model", + created=0, + choices=[ + StreamingChoices(index=0, finish_reason="tool_calls", delta=Delta(tool_calls=entry if single else [entry])) + ], + ) + response: Final = stream_chunk_builder([chunk]) + assert response is not None + choice: Final = response.choices[0] + assert isinstance(choice, Choices) + combined: Final = choice.message.tool_calls + assert combined is not None and len(combined) == 1 + assert combined[0].id == "call_one" + if custom: + assert isinstance(combined[0], ChatCompletionMessageCustomToolCall) + assert combined[0].type == "custom" + assert combined[0].custom.name == "lookup" + assert combined[0].custom.input == '{"key": "value"}' + else: + assert isinstance(combined[0], ChatCompletionMessageToolCall) + assert combined[0].type == "function" + assert combined[0].function.name == "lookup" + assert combined[0].function.arguments == '{"key": "value"}' + + def test_get_combined_tool_content(): chunks = [ ModelResponseStream( From 8810cabdfda6f0abfe554aae0d77e0a307ab1e70 Mon Sep 17 00:00:00 2001 From: xiajiahang Date: Tue, 13 Oct 2026 21:05:21 +0800 Subject: [PATCH 2/2] fix(streaming): avoid a final declaration inside the tool call loop --- litellm/litellm_core_utils/streaming_chunk_builder_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index 5287ad3f2cf..0bcfa2d1838 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -528,10 +528,9 @@ class ChunkProcessor: choices = chunk["choices"] for choice in choices: delta = choice.get("delta", {}) - tool_calls: Final = _normalize_tool_calls(delta.get("tool_calls")) choice_index = choice.get("index", 0) - for tool_call in tool_calls: + for tool_call in _normalize_tool_calls(delta.get("tool_calls")): # Handle both dict and object formats if not tool_call: continue