#8362·opik

[Bug]: Streamed Mistral tool calls merge into one when the stream omits `index`

Author: feiiiiii5Created Sep 16, 2026Updated Sep 16, 2026

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:

python
                        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).

python
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 types index as optional with default 0, so a payload that omits it validates and the aggregator then puts every call in slot 0. The collision above is produced through CompletionEvent.model_validate_json, not a hand-built object.
  • Not measured: whether api.mistral.ai omits index today. I have no credentials and did not capture live traffic, so if the hosted API always sends index, the exposure is limited to Mistral-compatible gateways and self-hosted deployments behind server_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_set to tell a sent index from the SDK's 0, and otherwise fall back to the fragment's id: a known id continues that slot, an unknown one opens a new slot. ~12 lines local to this file, no change to streams that send index, 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 does model_dump(exclude_none=True) and drops index, mistral does model_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 is integrations/bedrock/invoke_model/chunks_aggregator/.
  • (c) Decide index is guaranteed and delete the dead else position branch instead, leaving aggregate() 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_calls merge on the openai side. While measuring that PR I found the two SDKs disagree about this field: openai's ChoiceDeltaToolCall.index is a required non-nullable int, mistralai's ToolCall.index is optional and defaults to 0. 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.