🐛 fix(model-runtime): handle incremental tool call chunks in Qwen stream (#11219)

* 🐛 fix(model-runtime): handle incremental tool call chunks in Qwen stream

When streaming tool calls, subsequent chunks may not have an id (only
incremental arguments). The previous code generated a new id for each
chunk, causing the parser to treat them as different tool calls instead
of merging the arguments.

Changes:
- Store first tool call's info in streamContext.tool for subsequent chunks
- Use stored tool id from streamContext for incremental chunks without id
- Add test case for mixed text + incremental tool calls (DeepSeek style)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* update WorkingDirectory

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Arvin Xu
2026-01-05 12:26:14 +08:00
committed by GitHub
parent 9d8f1aa764
commit 03b9407e23
3 changed files with 159 additions and 6 deletions

View File

@@ -479,6 +479,146 @@ describe('QwenAIStream', () => {
`data: [{"function":{"arguments":"","name":"get_weather"},"id":"call_123","index":0,"type":"function"}]\n\n`,
]);
});
it('should handle mixed text content followed by streaming tool calls (DeepSeek style)', async () => {
// This test simulates the stream pattern from DeepSeek models via Qwen API
// where text content is streamed first, followed by incremental tool call chunks
const mockOpenAIStream = new ReadableStream({
start(controller) {
// Text content chunks with role in first chunk
controller.enqueue({
choices: [
{
delta: { content: '看来', role: 'assistant' },
finish_reason: null,
index: 0,
},
],
id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075',
model: 'deepseek-v3',
object: 'chat.completion.chunk',
created: 1767574524,
});
controller.enqueue({
choices: [
{
delta: { content: '我的' },
finish_reason: null,
index: 0,
},
],
id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075',
model: 'deepseek-v3',
object: 'chat.completion.chunk',
created: 1767574524,
});
controller.enqueue({
choices: [
{
delta: { content: '函数调用格式有误。' },
finish_reason: null,
index: 0,
},
],
id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075',
model: 'deepseek-v3',
object: 'chat.completion.chunk',
created: 1767574524,
});
// First tool call chunk with id, name, and partial arguments
controller.enqueue({
choices: [
{
delta: {
tool_calls: [
{
id: 'call_ff00c42325d74b979990cb',
type: 'function',
function: {
name: 'modelscope-time____get_current_time____mcp',
arguments: '{"',
},
index: 0,
},
],
},
finish_reason: null,
index: 0,
},
],
id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075',
model: 'deepseek-v3',
object: 'chat.completion.chunk',
created: 1767574524,
});
// Subsequent tool call chunk with only incremental arguments (no id)
controller.enqueue({
choices: [
{
delta: {
tool_calls: [
{
type: 'function',
function: {
arguments: 'timezone":"America/New_York"}',
},
index: 0,
},
],
},
finish_reason: null,
index: 0,
},
],
id: 'chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075',
model: 'deepseek-v3',
object: 'chat.completion.chunk',
created: 1767574524,
});
controller.close();
},
});
const onTextMock = vi.fn();
const onToolCallMock = vi.fn();
const protocolStream = QwenAIStream(mockOpenAIStream, {
callbacks: {
onText: onTextMock,
onToolsCalling: onToolCallMock,
},
});
const decoder = new TextDecoder();
const chunks = [];
// @ts-ignore
for await (const chunk of protocolStream) {
chunks.push(decoder.decode(chunk, { stream: true }));
}
// Verify complete chunks array
expect(chunks).toEqual([
'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n',
'event: text\n',
'data: "看来"\n\n',
'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n',
'event: text\n',
'data: "我的"\n\n',
'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n',
'event: text\n',
'data: "函数调用格式有误。"\n\n',
'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n',
'event: tool_calls\n',
'data: [{"function":{"arguments":"{\\"","name":"modelscope-time____get_current_time____mcp"},"id":"call_ff00c42325d74b979990cb","index":0,"type":"function"}]\n\n',
'id: chatcmpl-4f901cb2-91bc-9763-a2c8-3ed58e9f4075\n',
'event: tool_calls\n',
'data: [{"function":{"arguments":"timezone\\":\\"America/New_York\\"}","name":null},"id":"call_ff00c42325d74b979990cb","index":0,"type":"function"}]\n\n',
]);
});
});
describe('transformQwenStream', () => {

View File

@@ -70,8 +70,18 @@ export const transformQwenStream = (
if (item.delta?.tool_calls) {
return {
data: item.delta.tool_calls.map(
(value, index): StreamToolCallChunkData => ({
data: item.delta.tool_calls.map((value, index): StreamToolCallChunkData => {
// Store first tool call's info in streamContext for subsequent chunks
// (similar pattern to OpenAI stream handling)
if (streamContext && !streamContext.tool && value.id && value.function?.name) {
streamContext.tool = {
id: value.id,
index: typeof value.index !== 'undefined' ? value.index : index,
name: value.function.name,
};
}
return {
// Qwen models may send tool_calls in two separate chunks:
// 1. First chunk: {id, name} without arguments
// 2. Second chunk: {id, arguments} without name
@@ -81,11 +91,13 @@ export const transformQwenStream = (
arguments: value.function?.arguments ?? '',
name: value.function?.name ?? null,
},
id: value.id || generateToolCallId(index, value.function?.name),
// For incremental chunks without id, use the stored tool id from streamContext
id:
value.id || streamContext?.tool?.id || generateToolCallId(index, value.function?.name),
index: typeof value.index !== 'undefined' ? value.index : index,
type: value.type || 'function',
}),
),
};
}),
id: chunk.id,
type: 'tool_calls',
} as StreamProtocolToolCallChunk;

View File

@@ -1,5 +1,6 @@
'use client';
import { isDesktop } from '@lobechat/const';
import { Flexbox } from '@lobehub/ui';
import { cssVar } from 'antd-style';
import { memo } from 'react';
@@ -22,7 +23,7 @@ const Header = memo(() => {
}
right={
<Flexbox align={'center'} horizontal style={{ backgroundColor: cssVar.colorBgContainer }}>
<WorkingDirectory />
{isDesktop && <WorkingDirectory />}
<NotebookButton />
<ShareButton />
<HeaderActions />