-
Notifications
You must be signed in to change notification settings - Fork 5.1k
fix: merge list entries by logical index instead of physical position (#3201) #3521
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
feeea11
f2b61eb
1b63e4c
78360f9
cbcc427
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,9 +22,9 @@ | |
| FunctionToolCallArgumentsDoneEvent, | ||
| FunctionToolCallArgumentsDeltaEvent, | ||
| ) | ||
| from .._deltas import accumulate_delta | ||
| from .._deltas import accumulate_delta, _coalesce_list_by_index | ||
| from ...._types import Omit, IncEx, omit | ||
| from ...._utils import is_given, consume_sync_iterator, consume_async_iterator | ||
| from ...._utils import is_list, is_given, consume_sync_iterator, consume_async_iterator | ||
| from ...._compat import model_dump | ||
| from ...._models import build, construct_type | ||
| from ..._parsing import ( | ||
|
|
@@ -409,13 +409,19 @@ def _accumulate_chunk(self, chunk: ChatCompletionChunk) -> ParsedChatCompletionS | |
| elif TYPE_CHECKING: # type: ignore[unreachable] | ||
| assert_never(prev_tool) | ||
| except IndexError: | ||
| # A new choice appeared that wasn't in the initial chunk. | ||
| # Coalesce tool_calls by index to handle duplicate-index entries | ||
| # from speculative decoding, same as _convert_initial_chunk_into_snapshot. | ||
| delta_dict = choice.delta.to_dict() | ||
| if is_list(delta_dict.get("tool_calls")): | ||
| delta_dict["tool_calls"] = _coalesce_list_by_index(cast("list[object]", delta_dict["tool_calls"])) | ||
| choice_snapshot = cast( | ||
| ParsedChoiceSnapshot, | ||
| construct_type( | ||
| type_=ParsedChoiceSnapshot, | ||
| value={ | ||
| **choice.model_dump(exclude_unset=True, exclude={"delta"}), | ||
| "message": choice.delta.to_dict(), | ||
| "message": delta_dict, | ||
| }, | ||
| ), | ||
| ) | ||
|
|
@@ -742,9 +748,17 @@ def _convert_initial_chunk_into_snapshot(chunk: ChatCompletionChunk) -> ParsedCh | |
| choices = cast("list[object]", data["choices"]) | ||
|
|
||
| for choice in chunk.choices: | ||
| message_dict = choice.delta.to_dict() | ||
| # Coalesce duplicate-index tool_calls in the initial chunk. (#3201) | ||
| # When the first chunk contains multiple tool_calls with the same index | ||
| # (e.g. from speculative decoding), storing them directly would leave | ||
| # duplicate entries that later merges can't fix. | ||
| tool_calls = message_dict.get("tool_calls") | ||
| if is_list(tool_calls) and len(tool_calls) > 1: | ||
| message_dict["tool_calls"] = _coalesce_list_by_index(tool_calls) | ||
|
Comment on lines
+757
to
+758
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When streaming multiple choices where the first SSE initializes only an earlier choice, a later choice's first chunk does not pass through this new initial-chunk coalescing; Useful? React with 👍 / 👎. |
||
| choices[choice.index] = { | ||
| **choice.model_dump(exclude_unset=True, exclude={"delta"}), | ||
| "message": choice.delta.to_dict(), | ||
| "message": message_dict, | ||
| } | ||
|
|
||
| return cast( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a sparse tool-call stream emits index 0 then 2, this code pads slot 1 with
{}; after the snapshot is constructed and the next chunk callsmodel_dump, that placeholder is no longer empty but a dict of unset tool-call fields such asid/function/type: None. If index 1 then arrives, this branch inserts before that non-empty placeholder instead of replacing it, shifting the existing index-2 tool call to slot 3; the chat stream still readstool_calls[2]in the parsed-argument preservation/event paths, so it can assert or drop events for tool call 2. Fresh evidence in this revision is that the placeholder replacement only handles raw{}placeholders, not the dumped placeholders produced by the snapshot round trip.Useful? React with 👍 / 👎.