Repository navigation
Fix streamed tool calls that arrive without an index - #1826
pcbeingused333 wants to merge 3 commits into
Conversation
Some OpenAI-compatible APIs (Gemini's /v1beta/openai endpoint) omit tool_calls[].index in streamed chunks and send each parallel call whole in its own chunk. Chat and AsyncChat keyed accumulated calls by index, so every call landed under None, the argument strings were concatenated and json.loads raised "Extra data" at the end of the stream. Without an index, a delta now starts a new call only when it has a new id (or a name and no id); anything else continues the latest call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Muguett-DBY
left a comment
There was a problem hiding this comment.
Would it be worth guarding against a key collision if a compatible endpoint mixes indexed and unindexed calls? For example, if the accumulator already has a call at index 1 and a new unindexed call arrives with a different id, len(tool_calls) is also 1. _tool_call_key returns that occupied key, so the caller appends the new arguments to the existing call.
I reproduced the collision by executing the exact helper from 4445d4a with those inputs and checked the sync/async accumulation paths. I don't know whether Gemini emits mixed streams; this is a defensive case rather than a claim about its behavior. A distinct key for the unindexed call and a mixed-stream regression test could make the fallback safer. I haven't run the full branch test suite.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Good catch, @Muguett-DBY, thanks for reproducing it. Added 036bad7: when a call arrives without an index, the key now skips any slot already taken by an indexed call, so a mixed stream (indexed calls plus an unindexed one) no longer merges the unindexed call into an existing one. There's a regression test for the mixed case ( |
|
Thanks for fixing that! The new test covers an indexed call followed by an unindexed one. I checked the opposite order too: if an unindexed call with id I reproduced this with the exact |
…alls Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
Thanks. Done in ca1d22a: indexed and unindexed calls now accumulate under separate keys, so neither order can merge two calls; added a test for the unindexed-then-indexed order. Like you, I don't know of a provider that mixes both in one stream; this is defensive. |
Gemini's OpenAI-compatible endpoint (
https://generativelanguage.googleapis.com/v1beta/openai/, configured throughextra-openai-models.yaml) streams parallel tool calls withouttool_calls[].index. Each call arrives whole in its own chunk, and the stream ends withfinish_reason: "stop":Chat.executeandAsyncChat.executekey the accumulated calls bytool_call.index, so both calls go underNone. The second call's arguments get appended to the first, andjson.loads('{"city":"Paris"}{"city":"Tokyo"}')raisesJSONDecodeError: Extra datawhen the stream ends. With this endpoint, any prompt where the model makes more than one tool call in a turn fails this way.The fix adds a small
_tool_call_key()helper that both paths use. Whenindexis present nothing changes. When it is missing, a delta starts a new call only if it has a new id (different from the latest call's id), or a name with no id. Anything else continues the latest call, so providers that repeat the same id on every argument fragment still get one call.Tests (in
tests/test_openai_messages.py):mainwith theJSONDecodeErrorand pass with this changeThe full test suite,
black --check,ruff checkandmypy llmpass. A stream captured from Gemini was also replayed throughChatandAsyncChatagainst a local server:mainraises, and this branch returns the two calls (Paris,Tokyo) with their original ids.This change was made with Claude Code.
🤖 Generated with Claude Code