Summary
The Spring AI OpenAI SSE reassembly in BraintrustSpringAI.reassembleOpenAISSE() incorrectly handles tool_calls deltas during streaming. Each streaming chunk's delta.tool_calls array is appended as-is to the result array instead of being merged by the index field. This produces a malformed tool_calls array with N partial objects instead of one properly assembled tool_call per invocation.
This is distinct from #78 (which covers Anthropic SSE non-text content blocks). This issue affects the OpenAI SSE path specifically and involves a different bug: incorrect delta-merge semantics for tool_calls rather than missing content block types.
For comparison, the direct OpenAI SDK instrumentation in this repo (TracingHttpClient in openai_2_8_0) uses the official ChatCompletionAccumulator which correctly merges tool_call deltas by index.
What is missing
In BraintrustSpringAI.reassembleOpenAISSE() (lines 594–605):
if (delta.has("tool_calls")) {
if (!choice.get("message").has("tool_calls")) {
((ObjectNode) choice.get("message"))
.set("tool_calls", mapper.createArrayNode());
}
for (var tc : delta.get("tool_calls")) {
((ArrayNode) choice.get("message").get("tool_calls"))
.add(tc);
}
}
How OpenAI streaming sends tool_calls
OpenAI sends tool_calls incrementally across multiple chunks using an index field to identify which tool_call is being updated:
- First chunk:
{"tool_calls": [{"index": 0, "id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": ""}}]}
- Subsequent chunks:
{"tool_calls": [{"index": 0, "function": {"arguments": "{\"lo"}}]}
- More chunks:
{"tool_calls": [{"index": 0, "function": {"arguments": "cation\": \"NYC\"}"}}]}
The correct reassembly should find the existing tool_call with the matching index and concatenate function.arguments. Instead, the current code appends each chunk as a new element, producing:
"tool_calls": [
{"index": 0, "id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": ""}},
{"index": 0, "function": {"arguments": "{\"lo"}},
{"index": 0, "function": {"arguments": "cation\": \"NYC\"}"}}
]
instead of the expected:
"tool_calls": [
{"index": 0, "id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"NYC\"}"}}
]
Additional missing delta fields
The method (lines 587–606) only processes delta.content and delta.tool_calls. The following delta fields are also dropped:
refusal — safety refusal text, sent incrementally like content when the model declines a request
reasoning_content — reasoning text for o-series models in streaming
Braintrust docs status
Upstream sources
Local files inspected
braintrust-sdk/instrumentation/springai_1_0_0/src/main/java/dev/braintrust/instrumentation/springai/v1_0_0/BraintrustSpringAI.java — lines 554–632 (reassembleOpenAISSE: tool_calls appended at lines 600–603 instead of merged by index; no handling for refusal or reasoning_content deltas)
braintrust-sdk/instrumentation/openai_2_8_0/src/main/java/dev/braintrust/instrumentation/openai/v2_8_0/TracingHttpClient.java — direct SDK path uses ChatCompletionAccumulator which handles tool_calls merging correctly
braintrust-sdk/instrumentation/springai_1_0_0/src/test/java/dev/braintrust/instrumentation/springai/v1_0_0/BraintrustSpringAITest.java — no streaming tests exercise tool_calls responses
Summary
The Spring AI OpenAI SSE reassembly in
BraintrustSpringAI.reassembleOpenAISSE()incorrectly handlestool_callsdeltas during streaming. Each streaming chunk'sdelta.tool_callsarray is appended as-is to the result array instead of being merged by theindexfield. This produces a malformedtool_callsarray with N partial objects instead of one properly assembled tool_call per invocation.This is distinct from #78 (which covers Anthropic SSE non-text content blocks). This issue affects the OpenAI SSE path specifically and involves a different bug: incorrect delta-merge semantics for tool_calls rather than missing content block types.
For comparison, the direct OpenAI SDK instrumentation in this repo (
TracingHttpClientinopenai_2_8_0) uses the officialChatCompletionAccumulatorwhich correctly merges tool_call deltas by index.What is missing
In
BraintrustSpringAI.reassembleOpenAISSE()(lines 594–605):How OpenAI streaming sends tool_calls
OpenAI sends tool_calls incrementally across multiple chunks using an
indexfield to identify which tool_call is being updated:{"tool_calls": [{"index": 0, "id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": ""}}]}{"tool_calls": [{"index": 0, "function": {"arguments": "{\"lo"}}]}{"tool_calls": [{"index": 0, "function": {"arguments": "cation\": \"NYC\"}"}}]}The correct reassembly should find the existing tool_call with the matching
indexand concatenatefunction.arguments. Instead, the current code appends each chunk as a new element, producing:instead of the expected:
Additional missing delta fields
The method (lines 587–606) only processes
delta.contentanddelta.tool_calls. The following delta fields are also dropped:refusal— safety refusal text, sent incrementally likecontentwhen the model declines a requestreasoning_content— reasoning text for o-series models in streamingBraintrust docs status
Upstream sources
tool_callsuse anindexfield for delta-merge identification;function.argumentsis sent incrementally and must be concatenatedLocal files inspected
braintrust-sdk/instrumentation/springai_1_0_0/src/main/java/dev/braintrust/instrumentation/springai/v1_0_0/BraintrustSpringAI.java— lines 554–632 (reassembleOpenAISSE: tool_calls appended at lines 600–603 instead of merged by index; no handling forrefusalorreasoning_contentdeltas)braintrust-sdk/instrumentation/openai_2_8_0/src/main/java/dev/braintrust/instrumentation/openai/v2_8_0/TracingHttpClient.java— direct SDK path usesChatCompletionAccumulatorwhich handles tool_calls merging correctlybraintrust-sdk/instrumentation/springai_1_0_0/src/test/java/dev/braintrust/instrumentation/springai/v1_0_0/BraintrustSpringAITest.java— no streaming tests exercise tool_calls responses