Skip to content

[BOT ISSUE] Spring AI OpenAI SSE reassembly produces malformed tool_calls — deltas appended instead of merged by index #79

@braintrust-bot

Description

@braintrust-bot

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:

  1. First chunk: {"tool_calls": [{"index": 0, "id": "call_abc", "type": "function", "function": {"name": "get_weather", "arguments": ""}}]}
  2. Subsequent chunks: {"tool_calls": [{"index": 0, "function": {"arguments": "{\"lo"}}]}
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions