mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
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:
parent
20d80b5420
commit
65160a97c5
3 changed files with 104 additions and 28 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue