fix(guardrails): keep tool calls carried by a later choice of a packed multi-choice chunk

The rebuild's tool-call selection and its text-only fast path only looked at
choice 0 of each chunk, so a chunk that packs several choices (Gemini with
candidateCount above 1) lost a tool call carried by a later candidate, and a
chunk whose later choice had no tool calls at all made the rebuild raise.
Both now consider every choice in the chunk.
This commit is contained in:
mateo-berri 2026-09-13 02:55:03 -07:00
parent 20d80b5420
commit 65160a97c5
3 changed files with 104 additions and 28 deletions

View file

@ -427,7 +427,7 @@ class ChunkProcessor:
if not delta:
continue
choice_index = choice.get("index", 0)
for tool_call in delta.get("tool_calls", ()):
for tool_call in delta.get("tool_calls") or ():
if not tool_call:
continue
if isinstance(tool_call, dict):
@ -478,7 +478,7 @@ class ChunkProcessor:
choices = chunk["choices"]
for choice in choices:
delta = choice.get("delta", {})
tool_calls = delta.get("tool_calls", [])
tool_calls = delta.get("tool_calls") or ()
choice_index = choice.get("index", 0)
for tool_call in tool_calls:

View file

@ -8749,6 +8749,39 @@ def _stamp_streaming_usage_cost(usage: Usage, response: ModelResponse, logging_o
setattr(usage, "cost", computed_cost)
_NON_TEXT_DELTA_FIELDS: Final = (
"tool_calls",
"function_call",
"reasoning_content",
"thinking_blocks",
"annotations",
"audio",
"images",
"provider_specific_fields",
)
def _stream_choice_delta(choice: object) -> Mapping[str, object]:
delta: Final = choice.get("delta", {}) if isinstance(choice, dict) else getattr(choice, "delta", {})
if isinstance(delta, Mapping):
return delta
if isinstance(delta, BaseModel):
return delta.model_dump()
return {}
def _delta_carries_more_than_text(delta: Mapping[str, object]) -> bool:
return any(delta.get(field) is not None for field in _NON_TEXT_DELTA_FIELDS)
def _simple_text_part(choices: Sequence[object]) -> str | None:
deltas: Final = tuple(_stream_choice_delta(choice) for choice in choices)
if any(_delta_carries_more_than_text(delta) for delta in deltas):
return None
content: Final = deltas[0].get("content")
return content if isinstance(content, str) else ""
def stream_chunk_builder(
chunks: list,
messages: Sequence | None = None,
@ -8793,31 +8826,11 @@ def stream_chunk_builder(
if not chunk.get("choices"):
continue
choice = chunk["choices"][0]
delta_obj = choice.get("delta", {}) if isinstance(choice, dict) else getattr(choice, "delta", {})
if isinstance(delta_obj, dict):
delta = delta_obj
elif hasattr(delta_obj, "model_dump"):
delta = cast(dict[str, Any], delta_obj.model_dump())
else:
delta = {}
if (
delta.get("tool_calls") is not None
or delta.get("function_call") is not None
or delta.get("reasoning_content") is not None
or delta.get("thinking_blocks") is not None
or delta.get("annotations") is not None
or delta.get("audio") is not None
or delta.get("images") is not None
or delta.get("provider_specific_fields") is not None
):
if (part := _simple_text_part(chunk["choices"])) is None:
is_simple_text_stream = False
break
content = delta.get("content")
if isinstance(content, str) and content:
simple_content_parts.append(content)
if part:
simple_content_parts.append(part)
if is_simple_text_stream:
if simple_content_parts:
@ -8854,9 +8867,10 @@ def stream_chunk_builder(
tool_call_chunks: Final = [
chunk
for chunk in chunks
if chunk.get("choices")
and "tool_calls" in chunk["choices"][0]["delta"]
and chunk["choices"][0]["delta"]["tool_calls"] is not None
if any(
"tool_calls" in choice["delta"] and choice["delta"]["tool_calls"] is not None
for choice in chunk.get("choices") or ()
)
]
if len(tool_call_chunks) > 0:

View file

@ -1659,6 +1659,68 @@ async def test_async_mock_delay():
assert delay >= 0.01
def test_stream_chunk_builder_keeps_tool_calls_carried_only_by_a_later_choice_of_a_multi_choice_chunk():
from litellm import stream_chunk_builder
from litellm.types.utils import (
ChatCompletionDeltaToolCall,
Delta,
Function,
ModelResponseStream,
StreamingChoices,
)
def chunk(choices: list[StreamingChoices]) -> ModelResponseStream:
return ModelResponseStream(
id="chatcmpl-multi-choice",
created=1751934860,
model="gpt-4.1-mini",
object="chat.completion.chunk",
choices=choices,
)
chunks = [
chunk(
[
StreamingChoices(index=0, delta=Delta(role="assistant", content="hello")),
StreamingChoices(
index=1,
delta=Delta(
role="assistant",
tool_calls=[
ChatCompletionDeltaToolCall(
id="call_1",
index=0,
type="function",
function=Function(name="lookup_fruit", arguments='{"fruit":'),
)
],
),
),
]
),
chunk(
[
StreamingChoices(index=0, delta=Delta(content=" world"), finish_reason="stop"),
StreamingChoices(
index=1,
delta=Delta(
tool_calls=[ChatCompletionDeltaToolCall(index=0, function=Function(arguments='"kiwi"}'))]
),
finish_reason="tool_calls",
),
]
),
]
response = stream_chunk_builder(chunks=chunks)
tool_calls = response.choices[0].message.tool_calls
assert tool_calls is not None
assert [(call.id, call.function.name, call.function.arguments) for call in tool_calls] == [
("call_1", "lookup_fruit", '{"fruit":"kiwi"}')
]
def test_stream_chunk_builder_thinking_blocks():
from litellm import stream_chunk_builder
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices