#8360·opik

[Bug]: Streamed OpenAI tool calls are dropped from the traced output

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

What component(s) are affected?

  • Opik Python SDK

Opik version

  • Opik version: checkout of main at 4546b5e66 (the aggregator has not been touched since it was added in 4a3970dee, so every release since then has the same code).

Describe the problem

A streamed OpenAI chat completion that calls a tool is recorded with an empty assistant message. The function name and the arguments never reach the span output, and finish_reason is still recorded as tool_calls, so the stored record says the model finished by calling a tool while showing no tool call.

Expected: the same choices[0].message a non-streamed response of that call produces, i.e. tool_calls with the reassembled arguments. This is what the openai client itself accumulates from the same chunks, and what integrations/mistral/chat_completion_chunks_aggregator.py in this SDK already does for Mistral streams.

Reproduction steps

No network and no credentials needed. The chunks below are the shape a tool call arrives as, built from the field names in openai.types.chat.chat_completion_chunk (openai 2.50.0). The last chunk is the stream_options={"include_usage": True} chunk, which carries choices: [].

python
from openai.types.chat import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import (
    Choice,
    ChoiceDelta,
    ChoiceDeltaToolCall,
    ChoiceDeltaToolCallFunction,
)

from opik.integrations.openai import chat_completion_chunks_aggregator


def tool_call_delta(index, *, call_id=None, name=None, arguments=None):
    fields = {"type": "function"}
    if call_id is not None:
        fields["id"] = call_id
    if name is not None or arguments is not None:
        fields["function"] = ChoiceDeltaToolCallFunction(
            name=name, arguments=arguments
        )
    return ChoiceDeltaToolCall(index=index, **fields)


def chunk(delta_fields, finish_reason=None, usage=None):
    return ChatCompletionChunk(
        id="chatcmpl-1",
        model="gpt-4o",
        object="chat.completion.chunk",
        created=1750000000,
        choices=[
            Choice(
                index=0,
                finish_reason=finish_reason,
                logprobs=None,
                delta=ChoiceDelta(**delta_fields),
            )
        ],
        usage=usage,
    )


chunks = [
    chunk({"role": "assistant"}),
    chunk(
        {
            "tool_calls": [
                tool_call_delta(0, call_id="call_9F2a", name="get_weather", arguments="")
            ]
        }
    ),
    chunk({"tool_calls": [tool_call_delta(0, arguments='{"location"')]}),
    chunk({"tool_calls": [tool_call_delta(0, arguments=': "Paris"}')]}),
    chunk({}, finish_reason="tool_calls"),
    chunk({}, usage={"prompt_tokens": 41, "completion_tokens": 17, "total_tokens": 58}),
]

aggregated = chat_completion_chunks_aggregator.aggregate(chunks)
print(aggregated.model_dump()["choices"][0])

Actual, run against main at 4546b5e66:

{'index': 0, 'message': {'role': 'assistant', 'content': ''}, 'finish_reason': 'tool_calls'}

What the openai client's own accumulator makes of that same list (openai.lib.streaming.chat.ChatCompletionStreamState, documented as "manually accumulating ChatCompletionChunks into a final ChatCompletion object"), run on the same six objects:

json
{
  "content": null,
  "refusal": null,
  "role": "assistant",
  "annotations": null,
  "audio": null,
  "function_call": null,
  "tool_calls": [
    {
      "id": "call_9F2a",
      "function": {
        "arguments": "{\"location\": \"Paris\"}",
        "name": "get_weather",
        "parsed_arguments": null
      },
      "type": "function",
      "index": 0
    }
  ],
  "parsed": null
}

(parsed and parsed_arguments are the client's own parsing helpers and only appear because ChatCompletionStreamState was built without response_format.) Both readings were run locally on one chunk list, which is the point: the list is valid stream input, the client reassembles it and this aggregator does not.

Root cause

sdks/python/src/opik/integrations/openai/chat_completion_chunks_aggregator.py:41-52 reads delta.role and delta.content from chunk.choices[0].delta and nothing else. A streamed function call arrives only as delta.tool_calls fragments keyed by index, so no branch reads them and the argument fragments are never concatenated.

Nothing here was removed at some point: git log -S tool_calls on that file returns no commit, and a count of tool_call across the sibling aggregators at 4546b5e66 gives:

aggregator tool_call lines
openai none
groq none
cerebras none
litellm none
mistral 14 (merges fragments by index, :81-108)

Three independent signals that losing the call is not the intent:

  1. The Mistral integration in this same SDK aggregates delta.tool_calls, added by Comet in c911d8d84.
  2. The non-streamed path stores the whole ChatCompletion, so the same call traced without stream=True keeps its tool calls. Only the streamed path loses them.
  3. sdks/python/src/opik/evaluation/models/base_model.py:34-46 documents tool_calls as part of the OpenAI-shape assistant message opik ferries between callers and LLM wrappers.

Impact

track_openai with stream=True is what most tool-calling agents use, and the chunk aggregation happens in the finally of the patched openai.Stream.__iter__ (stream_patchers.py:53-80), so it applies to both client.chat.completions.create(stream=True) and client.chat.completions.stream(...). Every such assistant turn is recorded as an empty message: the trace shows that a tool was called (finish_reason) but not which one or with what arguments. Reading a trace to debug an agent, or scoring a trace's output, sees content: "". There is no exception, no log line, and the same trace looks correct for non-streamed calls.

Nothing in CI covers this: the file has no unit test (the only aggregator unit test in the SDK is tests/unit/integrations/bedrock/test_claude_aggregator.py), and the streaming tests under tests/library_integration/openai/ all use text completions against live credentials.

Options

  • (a) Merge delta.tool_calls fragments by index inside the openai aggregator, mirroring the Mistral one. Output uses the non-streamed tool_calls shape (no index key, since ChatCompletionMessageToolCall has no such field). One file, ~30 added lines, no other path changes.
  • (b) Replace the hand-written aggregator with openai.lib.streaming.chat.ChatCompletionStreamState. Correct by construction, but it depends on a client-internal class and changes the aggregated type that openai_chat_completions_decorator.py:104 refers to.
  • (c) Leave the aggregator as it is and expose the tool calls from somewhere else (raw span fields or per-chunk spans). I did not check whether the UI has a place to read them from.

I have (a) working with unit tests, including a test that asserts the streamed output and a non-streamed ChatCompletionMessage report the same tool_calls, and will open a draft PR referencing this issue. Happy to switch to (b) if a maintainer prefers it.

Two related things, deliberately not in the same change

  • The same gap exists verbatim in the groq, cerebras and litellm streaming aggregators (the table above). They are separate files with their own copies of this loop, so extending the fix is three small patches rather than one shared helper. I kept this issue to the openai integration; say the word and I will follow up.
  • aggregate() reports only choices[0]. Feeding it an n=2 stream, where chunks carry choices[0].index of 0 and 1, returns one choice whose content is "alphabeta-one-two", i.e. the second choice's text is appended to the first message. That is the question the # TODO on line 25 asks, and how to report several choices is a design decision, so it is not folded in here.