Question:

If I use DeepSeekChatModel to configure Qwen3, when hitting ToolCall, an exception ava.lang.IllegalArgumentException: toolInput cannot be null or empty will be thrown. This is because Qwen3 ToolCall Response is streaming, and we have not handled this situation.

Example:

data: {"id":"91afc9cbf3a347f987df10e8e96f2495","object":"chat.completion.chunk","created":1754904475,"model":"qwen3-235b-a22b-instruct-2507-fp8","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":null,"tool_calls":null},"logprobs":null,"finish_reason":null,"matched_stop":null}],"usage":null}

data: {"id":"91afc9cbf3a347f987df10e8e96f2495","object":"chat.completion.chunk","created":1754904476,"model":"qwen3-235b-a22b-instruct-2507-fp8","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"id":"call_0d94d57a4ec94353a4eaa6d7","index":0,"type":"function","function":{"name":"getweather","arguments":""}}]},"logprobs":null,"finish_reason":null,"matched_stop":null}],"usage":null}

data: {"id":"91afc9cbf3a347f987df10e8e96f2495","object":"chat.completion.chunk","created":1754904476,"model":"qwen3-235b-a22b-instruct-2507-fp8","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"{\"city\": \""}}]},"logprobs":null,"finish_reason":null,"matched_stop":null}],"usage":null}

data: {"id":"91afc9cbf3a347f987df10e8e96f2495","object":"chat.completion.chunk","created":1754904476,"model":"qwen3-235b-a22b-instruct-2507-fp8","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"Be"}}]},"logprobs":null,"finish_reason":null,"matched_stop":null}],"usage":null}

data: {"id":"91afc9cbf3a347f987df10e8e96f2495","object":"chat.completion.chunk","created":1754904476,"model":"qwen3-235b-a22b-instruct-2507-fp8","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":[{"id":null,"index":0,"type":"function","function":{"name":null,"arguments":"ijing\"}"}}]},"logprobs":null,"finish_reason":null,"matched_stop":null}],"usage":null}

data: {"id":"91afc9cbf3a347f987df10e8e96f2495","object":"chat.completion.chunk","created":1754904476,"model":"qwen3-235b-a22b-instruct-2507-fp8","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"logprobs":null,"finish_reason":"tool_calls","matched_stop":151645}],"usage":null}

data: {"id":"91afc9cbf3a347f987df10e8e96f2495","object":"chat.completion.chunk","created":1754904476,"model":"qwen3-235b-a22b-instruct-2507-fp8","choices":[],"usage":{"prompt_tokens":124,"total_tokens":145,"completion_tokens":21,"prompt_tokens_details":null}}

data: [DONE]

Resolve

We need merge those chunks , possible suggestions like this

Plan A

Add merging logic to DeepSeekChatModel

  • Before https://github.com/spring-projects/spring-ai/blob/c7f7b68063aa8dd12335e1109e969297258c7ca4/models/spring-ai-deepseek/src/main/java/org/springframework/ai/deepseek/DeepSeekChatModel.java#L327-L345

  • After

    private List<AssistantMessage.ToolCall> merge(List<AssistantMessage.ToolCall> tools) {
        if (tools.isEmpty()) {
            return tools;
        }


        Map<String, AssistantMessage.ToolCall> completedTools = new HashMap<>();
        AssistantMessage.ToolCall last = null;


        for (AssistantMessage.ToolCall t : tools) {
            if (!t.id().isEmpty()) {
                last = t;
            } else {
                last = mergeTool(last, t);
            }


            completedTools.put(last.id(), last);
        }


        return completedTools.values().stream().toList();
    }


    private AssistantMessage.ToolCall mergeTool(AssistantMessage.ToolCall target, AssistantMessage.ToolCall from) {
        if (target == null) {
            return from;
        }


        if (from == null) {
            return target;
        }


        String argument = from.arguments() == null || from.arguments().isEmpty() ? target.arguments() : target.arguments() + from.arguments();
        return new AssistantMessage.ToolCall(target.id(), target.type(), target.name(), argument);
    }
  ```
And use it in buildGeneration method
  ```
    private Generation buildGeneration(Choice choice, Map<String, Object> metadata) {
        List<AssistantMessage.ToolCall> toolCalls = choice.message().toolCalls() == null ? List.of()
                : merge(choice.message()
                .toolCalls()
                .stream()
                .map(toolCall -> new AssistantMessage.ToolCall(toolCall.id(), "function",
                        toolCall.function().name(), toolCall.function().arguments()))
                .toList());


        String finishReason = (choice.finishReason() != null ? choice.finishReason().name() : "");
        var generationMetadataBuilder = ChatGenerationMetadata.builder().finishReason(finishReason);


        String textContent = choice.message().content();
        String reasoningContent = choice.message().reasoningContent();


        QwenAssistantMessage assistantMessage = new QwenAssistantMessage(textContent, reasoningContent,
                metadata, toolCalls);
        return new Generation(assistantMessage, generationMetadataBuilder.build());
    }

Plan B

add QwenChatModel module

https://github.com/shenweijiekdel/spring-ai/blob/d4c84429bbeb996747b40bd30ab2ebe8c29bf2d1/models/spring-ai-qwen/src/main/java/org/springframework/ai/qwen/QwenChatModel.java#L312-L364

This fork contains this fix, currently works fine.

How should we choose a solution? Thank you.