spring-ai icon indicating copy to clipboard operation
spring-ai copied to clipboard

Fix streaming tool call merge for Qwen and OpenAI-compatible APIs

Open ultramancode opened this issue 2 weeks ago • 2 comments
trafficstars

Fixes #4790

Problem

When using OpenAI-compatible APIs like Qwen with streaming tool calls, subsequent chunks may not include the tool call ID. The current MessageAggregator uses addAll()which creates separate, incomplete ToolCall objects for each chunk instead of merging them. This results in ToolCall objects with empty name fields, causing: IllegalArgumentException: toolName cannot be null or empty

Root Cause

Some OpenAI-compatible APIs (e.g., Qwen via OpenRouter) follow a streaming pattern where:

  • First chunk: Contains both id and function.name
  • Subsequent chunks: Contain only function.arguments without id

Example:

Chunk 1: ToolCall(id="tool-123", name="getCurrentWeather", args="")
Chunk 2: ToolCall(id="",        name="",                  args="{\"location\": \"")
Chunk 3: ToolCall(id="",        name="",                  args="Seoul\"}")

Solution

Added mergeToolCalls() method in MessageAggregator as a safety net to handle tool call fragments that may not be properly merged at the API layer (e.g., OpenAiStreamFunctionCallingHelper).

This ensures that even when API-layer merging is incomplete or providers behave slightly differently, the aggregation layer can properly merge streaming tool call fragments.

This handles:

  • Standard ID-based matching (existing behavior)
  • ID-less streaming chunks
  • Multiple simultaneous tool calls
  • Mixed ID/no-ID scenarios

Changes

  • Replaced addAll() with new mergeToolCalls() method to properly handle streaming tool call fragments
  • Added mergeToolCall() helper method for null-safe property merging
  • Added comprehensive tests in MessageAggregatorTests
    • shouldMergeToolCallsWithoutIds: Verifies Qwen streaming pattern
    • shouldMergeMultipleToolCallsWithMixedIds: Multiple tool calls
    • shouldMergeToolCallsById: ID-based matching still works

Testing

All tests pass with actual Qwen streaming response pattern verified via OpenRouter API.

Example:

// Input: Streaming chunks
Chunk 1: ToolCall(id="tool-123", name="getCurrentWeather", args="")
Chunk 2: ToolCall(id="",        name="",                  args="{\"location\": \"")
Chunk 3: ToolCall(id="",        name="",                  args="Seoul\"}")

// Output: Merged result
ToolCall(id="tool-123", name="getCurrentWeather", args="{\"location\": \"Seoul\"}")

ultramancode avatar Nov 03 '25 16:11 ultramancode