spring-ai
spring-ai copied to clipboard
Fix streaming tool call merge for Qwen and OpenAI-compatible APIs
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
idandfunction.name - Subsequent chunks: Contain only
function.argumentswithoutid
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 newmergeToolCalls()method to properly handle streaming tool call fragments - Added
mergeToolCall()helper method for null-safe property merging - Added comprehensive tests in
MessageAggregatorTestsshouldMergeToolCallsWithoutIds: Verifies Qwen streaming patternshouldMergeMultipleToolCallsWithMixedIds: Multiple tool callsshouldMergeToolCallsById: 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\"}")