mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 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:
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user