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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions