[Bug]: Streamed Mistral tool calls merge into one when the stream omits `index`
What happens
A streamed Mistral response that carries two tool calls records one call, whose arguments
are both payloads concatenated and whose second function name is gone. Nothing raises, nothing is
logged, and finish_reason still says tool_calls, so the trace looks like a call the model never
made. With object-shaped arguments the same collision aborts the whole aggregation and the span
loses its output entirely.
sdks/python/src/opik/integrations/mistral/chat_completion_chunks_aggregator.py:86-89 keys each
fragment by tool_call.index, and falls back to the fragment's position when that is None:
index = (
tool_call.index if tool_call.index is not None else position
)mistralai 1.5.2 does not give None when a stream omits index. It gives 0:
mistralai/models/toolcall.py:17 index: NotRequired[int] (the TypedDict side)
mistralai/models/toolcall.py:29 index: Optional[int] = 0 (the model the SDK parses into)So the fallback can only fire for a payload that sends "index": null explicitly, and there it is
also wrong: with one call per chunk the position is 0 every time, which is the same collision.
How to reproduce
Every chunk below is built with CompletionEvent.model_validate_json(), i.e. through the same
parser the client uses on the wire -- no test-only constructors. Calls A and B are complete
tool calls (id, type, function.name, function.arguments).
import json
from mistralai.models.completionevent import CompletionEvent
from opik.integrations.mistral import chat_completion_chunks_aggregator as agg
def event(delta, finish=None):
body = {"data": {"id": "s", "model": "mistral-large-latest",
"object": "chat.completion.chunk", "created": 1750000000,
"usage": None,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}]}}
return CompletionEvent.model_validate_json(json.dumps(body))
A = {"id": "call_a", "type": "function",
"function": {"name": "get_weather", "arguments": '{"city":"Paris"}'}}
B = {"id": "call_b", "type": "function",
"function": {"name": "get_time", "arguments": '{"tz":"CET"}'}}
stream = [
event({"role": "assistant", "tool_calls": [A]}),
event({"tool_calls": [B]}),
event({}, finish="tool_calls"),
]
print(agg.aggregate(stream).model_dump()["choices"][0]["message"])Measured on main at 4546b5e66 (mistralai 1.5.2, pydantic 2.13.4, Python 3.11, macOS):
| stream | aggregate() records |
|---|---|
A and B in separate chunks, index omitted |
one call get_weather with arguments {"city":"Paris"}{"tz":"CET"} -- invalid JSON, and get_time is not recorded at all |
A and B both in one chunk's delta.tool_calls, index omitted |
same single merged call |
A and B with "index": null sent explicitly |
same single merged call |
two calls whose arguments are objects ({...}), index omitted |
aggregate() returns None: TypeError: unsupported operand type(s) for +: 'dict' and 'dict' at _merge_tool_call:37, swallowed by the except Exception in aggregate(), so the span loses the whole output |
control: identical streams that do send index: 0 / index: 1 |
two separate calls, correct |
control: two chunks repeating the same id without an index |
one call, arguments concatenated (this is the behaviour a fix must not break) |
Two facts from the same measurements narrow the fix: FunctionCall.name and
.function.arguments are required in the SDK's types, so an arguments-only continuation
fragment cannot be parsed at all (ValidationError: 2 validation errors for CompletionEvent).
Every fragment the SDK accepts is therefore a complete call, exactly as the comment on line 82
says. And "index" in tool_call.model_fields_set distinguishes "the stream sent an index" from
"the SDK defaulted it to 0" (verified for both index and id).
What should happen
A fragment whose identity the stream did not send should not be merged with a different call. When
index is absent, the call's own id should decide whether it continues a call already seen or
opens a new one -- which is what the fallback on line 88 appears to be trying to do, and does not.
Reachability -- what I did and did not measure
- Measured:
mistralai's own model typesindexas optional with default0, so a payload that omits it validates and the aggregator then puts every call in slot 0. The collision above is produced throughCompletionEvent.model_validate_json, not a hand-built object. - Not measured: whether
api.mistral.aiomitsindextoday. I have no credentials and did not capture live traffic, so if the hosted API always sendsindex, the exposure is limited to Mistral-compatible gateways and self-hosted deployments behindserver_url, which is the same wire format parsed by the same model. The code-level defect (a fallback that cannot fire for the case it was written for, and that is wrong even where it does) stands either way.
Options
- (a) Resolve the key from the wire, not the default. Use
"index" in model_fields_setto tell a sent index from the SDK's0, and otherwise fall back to the fragment'sid: a known id continues that slot, an unknown one opens a new slot. ~12 lines local to this file, no change to streams that sendindex, and it keeps the "same id, two chunks" behaviour. - (b) Same rule, extracted.
#8361(openai streamed tool calls) contains the openai-side version of this resolution. A shared helper would need the two call sites' serialisation settled first -- openai doesmodel_dump(exclude_none=True)and dropsindex, mistral doesmodel_dump(mode="json")and keeps it -- which is a design decision for the module owner, and the reason#8361's reviewer was told the extraction belongs in its own PR. This repo's existing shared-aggregator shape isintegrations/bedrock/invoke_model/chunks_aggregator/. - (c) Decide
indexis guaranteed and delete the deadelse positionbranch instead, leavingaggregate()to trust the field. That is defensible if the answer to the reachability question above is "the wire always carries it", and it would make the file honest about its assumption.
I have attached (a) as a draft with tests, so there is something runnable to look at. If you prefer
(b), the helper in that PR is the thing that moves and its tests come with it; if the answer to the
reachability question is "the wire always carries index", then (c) is the smaller honest change
and I will retitle this to the dead-branch cleanup rather than keep the resolution.
Related
- #8360 / #8361 -- the same
delta.tool_callsmerge on the openai side. While measuring that PR I found the two SDKs disagree about this field: openai'sChoiceDeltaToolCall.indexis a required non-nullableint, mistralai'sToolCall.indexis optional and defaults to0. That difference is why the fallback is dead there and reachable here.
Environment
opik 4546b5e66 (upstream main), mistralai 1.5.2, pydantic 2.13.4, Python 3.11.15, macOS.
All outputs above are from local runs and will be reproducible by the tests in the linked PR.
Source: comet-ml/opik