[Bug]: Streamed OpenAI tool calls are dropped from the traced output
What component(s) are affected?
- Opik Python SDK
Opik version
- Opik version: checkout of
mainat4546b5e66(the aggregator has not been touched since it was added in4a3970dee, 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: [].
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:
{
"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:
- The Mistral integration in this same SDK aggregates
delta.tool_calls, added by Comet inc911d8d84. - The non-streamed path stores the whole
ChatCompletion, so the same call traced withoutstream=Truekeeps its tool calls. Only the streamed path loses them. sdks/python/src/opik/evaluation/models/base_model.py:34-46documentstool_callsas 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_callsfragments by index inside the openai aggregator, mirroring the Mistral one. Output uses the non-streamedtool_callsshape (noindexkey, sinceChatCompletionMessageToolCallhas 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 thatopenai_chat_completions_decorator.py:104refers 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,cerebrasandlitellmstreaming 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 onlychoices[0]. Feeding it ann=2stream, where chunks carrychoices[0].indexof 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# TODOon line 25 asks, and how to report several choices is a design decision, so it is not folded in here.
Source: comet-ml/opik