@lobehub/chat 1.41.0 → 1.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +18 -0
- package/package.json +3 -3
- package/src/app/(main)/settings/llm/components/ProviderModelList/ModelConfigModal/Form.tsx +1 -1
- package/src/config/modelProviders/spark.ts +3 -6
- package/src/libs/agent-runtime/qwen/index.test.ts +13 -188
- package/src/libs/agent-runtime/qwen/index.ts +47 -126
- package/src/libs/agent-runtime/spark/index.test.ts +24 -28
- package/src/libs/agent-runtime/spark/index.ts +4 -0
- package/src/libs/agent-runtime/utils/openaiCompatibleFactory/index.test.ts +131 -0
- package/src/libs/agent-runtime/utils/openaiCompatibleFactory/index.ts +14 -3
- package/src/libs/agent-runtime/utils/streams/index.ts +1 -0
- package/src/libs/agent-runtime/utils/streams/spark.test.ts +199 -0
- package/src/libs/agent-runtime/utils/streams/spark.ts +134 -0
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,56 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.42.1](https://github.com/lobehub/lobe-chat/compare/v1.42.0...v1.42.1)
|
6
|
+
|
7
|
+
<sup>Released on **2024-12-29**</sup>
|
8
|
+
|
9
|
+
#### 🐛 Bug Fixes
|
10
|
+
|
11
|
+
- **misc**: Fix custom max_token not saved from customModelCards.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### What's fixed
|
19
|
+
|
20
|
+
- **misc**: Fix custom max_token not saved from customModelCards, closes [#5226](https://github.com/lobehub/lobe-chat/issues/5226) ([ab6d17c](https://github.com/lobehub/lobe-chat/commit/ab6d17c))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
30
|
+
## [Version 1.42.0](https://github.com/lobehub/lobe-chat/compare/v1.41.0...v1.42.0)
|
31
|
+
|
32
|
+
<sup>Released on **2024-12-29**</sup>
|
33
|
+
|
34
|
+
#### ✨ Features
|
35
|
+
|
36
|
+
- **misc**: Add custom stream handle support for LobeOpenAICompatibleFactory.
|
37
|
+
|
38
|
+
<br/>
|
39
|
+
|
40
|
+
<details>
|
41
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
42
|
+
|
43
|
+
#### What's improved
|
44
|
+
|
45
|
+
- **misc**: Add custom stream handle support for LobeOpenAICompatibleFactory, closes [#5039](https://github.com/lobehub/lobe-chat/issues/5039) ([ea7e732](https://github.com/lobehub/lobe-chat/commit/ea7e732))
|
46
|
+
|
47
|
+
</details>
|
48
|
+
|
49
|
+
<div align="right">
|
50
|
+
|
51
|
+
[](#readme-top)
|
52
|
+
|
53
|
+
</div>
|
54
|
+
|
5
55
|
## [Version 1.41.0](https://github.com/lobehub/lobe-chat/compare/v1.40.4...v1.41.0)
|
6
56
|
|
7
57
|
<sup>Released on **2024-12-28**</sup>
|
package/changelog/v1.json
CHANGED
@@ -1,4 +1,22 @@
|
|
1
1
|
[
|
2
|
+
{
|
3
|
+
"children": {
|
4
|
+
"fixes": [
|
5
|
+
"Fix custom max_token not saved from customModelCards."
|
6
|
+
]
|
7
|
+
},
|
8
|
+
"date": "2024-12-29",
|
9
|
+
"version": "1.42.1"
|
10
|
+
},
|
11
|
+
{
|
12
|
+
"children": {
|
13
|
+
"features": [
|
14
|
+
"Add custom stream handle support for LobeOpenAICompatibleFactory."
|
15
|
+
]
|
16
|
+
},
|
17
|
+
"date": "2024-12-29",
|
18
|
+
"version": "1.42.0"
|
19
|
+
},
|
2
20
|
{
|
3
21
|
"children": {
|
4
22
|
"features": [
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.
|
3
|
+
"version": "1.42.1",
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
5
5
|
"keywords": [
|
6
6
|
"framework",
|
@@ -297,7 +297,7 @@
|
|
297
297
|
"markdown-to-txt": "^2.0.1",
|
298
298
|
"mime": "^4.0.4",
|
299
299
|
"node-fetch": "^3.3.2",
|
300
|
-
"node-gyp": "^
|
300
|
+
"node-gyp": "^11.0.0",
|
301
301
|
"openapi-typescript": "^6.7.6",
|
302
302
|
"p-map": "^7.0.2",
|
303
303
|
"prettier": "^3.3.3",
|
@@ -314,7 +314,7 @@
|
|
314
314
|
"vitest": "~1.2.2",
|
315
315
|
"vitest-canvas-mock": "^0.3.3"
|
316
316
|
},
|
317
|
-
"packageManager": "pnpm@9.15.
|
317
|
+
"packageManager": "pnpm@9.15.2",
|
318
318
|
"publishConfig": {
|
319
319
|
"access": "public",
|
320
320
|
"registry": "https://registry.npmjs.org"
|
@@ -66,7 +66,7 @@ const ModelConfigForm = memo<ModelConfigFormProps>(
|
|
66
66
|
>
|
67
67
|
<Input placeholder={t('llm.customModelCards.modelConfig.displayName.placeholder')} />
|
68
68
|
</Form.Item>
|
69
|
-
<Form.Item label={t('llm.customModelCards.modelConfig.tokens.title')} name={'
|
69
|
+
<Form.Item label={t('llm.customModelCards.modelConfig.tokens.title')} name={'contextWindowTokens'}>
|
70
70
|
<MaxTokenSlider />
|
71
71
|
</Form.Item>
|
72
72
|
<Form.Item
|
@@ -10,7 +10,6 @@ const Spark: ModelProviderCard = {
|
|
10
10
|
'Spark Lite 是一款轻量级大语言模型,具备极低的延迟与高效的处理能力,完全免费开放,支持实时在线搜索功能。其快速响应的特性使其在低算力设备上的推理应用和模型微调中表现出色,为用户带来出色的成本效益和智能体验,尤其在知识问答、内容生成及搜索场景下表现不俗。',
|
11
11
|
displayName: 'Spark Lite',
|
12
12
|
enabled: true,
|
13
|
-
functionCall: false,
|
14
13
|
id: 'lite',
|
15
14
|
maxOutput: 4096,
|
16
15
|
},
|
@@ -20,7 +19,6 @@ const Spark: ModelProviderCard = {
|
|
20
19
|
'Spark Pro 是一款为专业领域优化的高性能大语言模型,专注数学、编程、医疗、教育等多个领域,并支持联网搜索及内置天气、日期等插件。其优化后模型在复杂知识问答、语言理解及高层次文本创作中展现出色表现和高效性能,是适合专业应用场景的理想选择。',
|
21
20
|
displayName: 'Spark Pro',
|
22
21
|
enabled: true,
|
23
|
-
functionCall: false,
|
24
22
|
id: 'generalv3',
|
25
23
|
maxOutput: 8192,
|
26
24
|
},
|
@@ -30,7 +28,6 @@ const Spark: ModelProviderCard = {
|
|
30
28
|
'Spark Pro 128K 配置了特大上下文处理能力,能够处理多达128K的上下文信息,特别适合需通篇分析和长期逻辑关联处理的长文内容,可在复杂文本沟通中提供流畅一致的逻辑与多样的引用支持。',
|
31
29
|
displayName: 'Spark Pro 128K',
|
32
30
|
enabled: true,
|
33
|
-
functionCall: false,
|
34
31
|
id: 'pro-128k',
|
35
32
|
maxOutput: 4096,
|
36
33
|
},
|
@@ -40,7 +37,7 @@ const Spark: ModelProviderCard = {
|
|
40
37
|
'Spark Max 为功能最为全面的版本,支持联网搜索及众多内置插件。其全面优化的核心能力以及系统角色设定和函数调用功能,使其在各种复杂应用场景中的表现极为优异和出色。',
|
41
38
|
displayName: 'Spark Max',
|
42
39
|
enabled: true,
|
43
|
-
functionCall:
|
40
|
+
functionCall: true,
|
44
41
|
id: 'generalv3.5',
|
45
42
|
maxOutput: 8192,
|
46
43
|
},
|
@@ -50,7 +47,7 @@ const Spark: ModelProviderCard = {
|
|
50
47
|
'Spark Max 32K 配置了大上下文处理能力,更强的上下文理解和逻辑推理能力,支持32K tokens的文本输入,适用于长文档阅读、私有知识问答等场景',
|
51
48
|
displayName: 'Spark Max 32K',
|
52
49
|
enabled: true,
|
53
|
-
functionCall:
|
50
|
+
functionCall: true,
|
54
51
|
id: 'max-32k',
|
55
52
|
maxOutput: 8192,
|
56
53
|
},
|
@@ -60,7 +57,7 @@ const Spark: ModelProviderCard = {
|
|
60
57
|
'Spark Ultra 是星火大模型系列中最为强大的版本,在升级联网搜索链路同时,提升对文本内容的理解和总结能力。它是用于提升办公生产力和准确响应需求的全方位解决方案,是引领行业的智能产品。',
|
61
58
|
displayName: 'Spark 4.0 Ultra',
|
62
59
|
enabled: true,
|
63
|
-
functionCall:
|
60
|
+
functionCall: true,
|
64
61
|
id: '4.0Ultra',
|
65
62
|
maxOutput: 8192,
|
66
63
|
},
|
@@ -2,8 +2,9 @@
|
|
2
2
|
import OpenAI from 'openai';
|
3
3
|
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
4
4
|
|
5
|
-
import
|
6
|
-
import {
|
5
|
+
import { LobeOpenAICompatibleRuntime } from '@/libs/agent-runtime';
|
6
|
+
import { ModelProvider } from '@/libs/agent-runtime';
|
7
|
+
import { AgentRuntimeErrorType } from '@/libs/agent-runtime';
|
7
8
|
|
8
9
|
import * as debugStreamModule from '../utils/debugStream';
|
9
10
|
import { LobeQwenAI } from './index';
|
@@ -16,7 +17,7 @@ const invalidErrorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
|
|
16
17
|
// Mock the console.error to avoid polluting test output
|
17
18
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
18
19
|
|
19
|
-
let instance:
|
20
|
+
let instance: LobeOpenAICompatibleRuntime;
|
20
21
|
|
21
22
|
beforeEach(() => {
|
22
23
|
instance = new LobeQwenAI({ apiKey: 'test' });
|
@@ -40,183 +41,7 @@ describe('LobeQwenAI', () => {
|
|
40
41
|
});
|
41
42
|
});
|
42
43
|
|
43
|
-
describe('models', () => {
|
44
|
-
it('should correctly list available models', async () => {
|
45
|
-
const instance = new LobeQwenAI({ apiKey: 'test_api_key' });
|
46
|
-
vi.spyOn(instance, 'models').mockResolvedValue(Qwen.chatModels);
|
47
|
-
|
48
|
-
const models = await instance.models();
|
49
|
-
expect(models).toEqual(Qwen.chatModels);
|
50
|
-
});
|
51
|
-
});
|
52
|
-
|
53
44
|
describe('chat', () => {
|
54
|
-
describe('Params', () => {
|
55
|
-
it('should call llms with proper options', async () => {
|
56
|
-
const mockStream = new ReadableStream();
|
57
|
-
const mockResponse = Promise.resolve(mockStream);
|
58
|
-
|
59
|
-
(instance['client'].chat.completions.create as Mock).mockResolvedValue(mockResponse);
|
60
|
-
|
61
|
-
const result = await instance.chat({
|
62
|
-
messages: [{ content: 'Hello', role: 'user' }],
|
63
|
-
model: 'qwen-turbo',
|
64
|
-
temperature: 0.6,
|
65
|
-
top_p: 0.7,
|
66
|
-
});
|
67
|
-
|
68
|
-
// Assert
|
69
|
-
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
|
70
|
-
{
|
71
|
-
messages: [{ content: 'Hello', role: 'user' }],
|
72
|
-
model: 'qwen-turbo',
|
73
|
-
temperature: 0.6,
|
74
|
-
stream: true,
|
75
|
-
top_p: 0.7,
|
76
|
-
result_format: 'message',
|
77
|
-
},
|
78
|
-
{ headers: { Accept: '*/*' } },
|
79
|
-
);
|
80
|
-
expect(result).toBeInstanceOf(Response);
|
81
|
-
});
|
82
|
-
|
83
|
-
it('should call vlms with proper options', async () => {
|
84
|
-
const mockStream = new ReadableStream();
|
85
|
-
const mockResponse = Promise.resolve(mockStream);
|
86
|
-
|
87
|
-
(instance['client'].chat.completions.create as Mock).mockResolvedValue(mockResponse);
|
88
|
-
|
89
|
-
const result = await instance.chat({
|
90
|
-
messages: [{ content: 'Hello', role: 'user' }],
|
91
|
-
model: 'qwen-vl-plus',
|
92
|
-
temperature: 0.6,
|
93
|
-
top_p: 0.7,
|
94
|
-
});
|
95
|
-
|
96
|
-
// Assert
|
97
|
-
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
|
98
|
-
{
|
99
|
-
messages: [{ content: 'Hello', role: 'user' }],
|
100
|
-
model: 'qwen-vl-plus',
|
101
|
-
stream: true,
|
102
|
-
},
|
103
|
-
{ headers: { Accept: '*/*' } },
|
104
|
-
);
|
105
|
-
expect(result).toBeInstanceOf(Response);
|
106
|
-
});
|
107
|
-
|
108
|
-
it('should transform non-streaming response to stream correctly', async () => {
|
109
|
-
const mockResponse = {
|
110
|
-
id: 'chatcmpl-fc539f49-51a8-94be-8061',
|
111
|
-
object: 'chat.completion',
|
112
|
-
created: 1719901794,
|
113
|
-
model: 'qwen-turbo',
|
114
|
-
choices: [
|
115
|
-
{
|
116
|
-
index: 0,
|
117
|
-
message: { role: 'assistant', content: 'Hello' },
|
118
|
-
finish_reason: 'stop',
|
119
|
-
logprobs: null,
|
120
|
-
},
|
121
|
-
],
|
122
|
-
} as OpenAI.ChatCompletion;
|
123
|
-
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
|
124
|
-
mockResponse as any,
|
125
|
-
);
|
126
|
-
|
127
|
-
const result = await instance.chat({
|
128
|
-
messages: [{ content: 'Hello', role: 'user' }],
|
129
|
-
model: 'qwen-turbo',
|
130
|
-
temperature: 0.6,
|
131
|
-
stream: false,
|
132
|
-
});
|
133
|
-
|
134
|
-
const decoder = new TextDecoder();
|
135
|
-
const reader = result.body!.getReader();
|
136
|
-
const stream: string[] = [];
|
137
|
-
|
138
|
-
while (true) {
|
139
|
-
const { value, done } = await reader.read();
|
140
|
-
if (done) break;
|
141
|
-
stream.push(decoder.decode(value));
|
142
|
-
}
|
143
|
-
|
144
|
-
expect(stream).toEqual([
|
145
|
-
'id: chatcmpl-fc539f49-51a8-94be-8061\n',
|
146
|
-
'event: text\n',
|
147
|
-
'data: "Hello"\n\n',
|
148
|
-
'id: chatcmpl-fc539f49-51a8-94be-8061\n',
|
149
|
-
'event: stop\n',
|
150
|
-
'data: "stop"\n\n',
|
151
|
-
]);
|
152
|
-
|
153
|
-
expect((await reader.read()).done).toBe(true);
|
154
|
-
});
|
155
|
-
|
156
|
-
it('should set temperature to undefined if temperature is 0 or >= 2', async () => {
|
157
|
-
const temperatures = [0, 2, 3];
|
158
|
-
const expectedTemperature = undefined;
|
159
|
-
|
160
|
-
for (const temp of temperatures) {
|
161
|
-
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
|
162
|
-
new ReadableStream() as any,
|
163
|
-
);
|
164
|
-
await instance.chat({
|
165
|
-
messages: [{ content: 'Hello', role: 'user' }],
|
166
|
-
model: 'qwen-turbo',
|
167
|
-
temperature: temp,
|
168
|
-
});
|
169
|
-
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
|
170
|
-
expect.objectContaining({
|
171
|
-
messages: expect.any(Array),
|
172
|
-
model: 'qwen-turbo',
|
173
|
-
temperature: expectedTemperature,
|
174
|
-
}),
|
175
|
-
expect.any(Object),
|
176
|
-
);
|
177
|
-
}
|
178
|
-
});
|
179
|
-
|
180
|
-
it('should set temperature to original temperature', async () => {
|
181
|
-
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
|
182
|
-
new ReadableStream() as any,
|
183
|
-
);
|
184
|
-
await instance.chat({
|
185
|
-
messages: [{ content: 'Hello', role: 'user' }],
|
186
|
-
model: 'qwen-turbo',
|
187
|
-
temperature: 1.5,
|
188
|
-
});
|
189
|
-
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
|
190
|
-
expect.objectContaining({
|
191
|
-
messages: expect.any(Array),
|
192
|
-
model: 'qwen-turbo',
|
193
|
-
temperature: 1.5,
|
194
|
-
}),
|
195
|
-
expect.any(Object),
|
196
|
-
);
|
197
|
-
});
|
198
|
-
|
199
|
-
it('should set temperature to Float', async () => {
|
200
|
-
const createMock = vi.fn().mockResolvedValue(new ReadableStream() as any);
|
201
|
-
vi.spyOn(instance['client'].chat.completions, 'create').mockImplementation(createMock);
|
202
|
-
await instance.chat({
|
203
|
-
messages: [{ content: 'Hello', role: 'user' }],
|
204
|
-
model: 'qwen-turbo',
|
205
|
-
temperature: 1,
|
206
|
-
});
|
207
|
-
expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
|
208
|
-
expect.objectContaining({
|
209
|
-
messages: expect.any(Array),
|
210
|
-
model: 'qwen-turbo',
|
211
|
-
temperature: expect.any(Number),
|
212
|
-
}),
|
213
|
-
expect.any(Object),
|
214
|
-
);
|
215
|
-
const callArgs = createMock.mock.calls[0][0];
|
216
|
-
expect(Number.isInteger(callArgs.temperature)).toBe(false); // Temperature is always not an integer
|
217
|
-
});
|
218
|
-
});
|
219
|
-
|
220
45
|
describe('Error', () => {
|
221
46
|
it('should return QwenBizError with an openai error response when OpenAI.APIError is thrown', async () => {
|
222
47
|
// Arrange
|
@@ -238,7 +63,7 @@ describe('LobeQwenAI', () => {
|
|
238
63
|
try {
|
239
64
|
await instance.chat({
|
240
65
|
messages: [{ content: 'Hello', role: 'user' }],
|
241
|
-
model: 'qwen-turbo',
|
66
|
+
model: 'qwen-turbo-latest',
|
242
67
|
temperature: 0.999,
|
243
68
|
});
|
244
69
|
} catch (e) {
|
@@ -278,7 +103,7 @@ describe('LobeQwenAI', () => {
|
|
278
103
|
try {
|
279
104
|
await instance.chat({
|
280
105
|
messages: [{ content: 'Hello', role: 'user' }],
|
281
|
-
model: 'qwen-turbo',
|
106
|
+
model: 'qwen-turbo-latest',
|
282
107
|
temperature: 0.999,
|
283
108
|
});
|
284
109
|
} catch (e) {
|
@@ -304,7 +129,8 @@ describe('LobeQwenAI', () => {
|
|
304
129
|
|
305
130
|
instance = new LobeQwenAI({
|
306
131
|
apiKey: 'test',
|
307
|
-
|
132
|
+
|
133
|
+
baseURL: 'https://api.abc.com/v1',
|
308
134
|
});
|
309
135
|
|
310
136
|
vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError);
|
@@ -313,13 +139,12 @@ describe('LobeQwenAI', () => {
|
|
313
139
|
try {
|
314
140
|
await instance.chat({
|
315
141
|
messages: [{ content: 'Hello', role: 'user' }],
|
316
|
-
model: 'qwen-turbo',
|
142
|
+
model: 'qwen-turbo-latest',
|
317
143
|
temperature: 0.999,
|
318
144
|
});
|
319
145
|
} catch (e) {
|
320
146
|
expect(e).toEqual({
|
321
|
-
|
322
|
-
endpoint: defaultBaseURL,
|
147
|
+
endpoint: 'https://api.***.com/v1',
|
323
148
|
error: {
|
324
149
|
cause: { message: 'api is undefined' },
|
325
150
|
stack: 'abc',
|
@@ -339,7 +164,7 @@ describe('LobeQwenAI', () => {
|
|
339
164
|
try {
|
340
165
|
await instance.chat({
|
341
166
|
messages: [{ content: 'Hello', role: 'user' }],
|
342
|
-
model: 'qwen-turbo',
|
167
|
+
model: 'qwen-turbo-latest',
|
343
168
|
temperature: 0.999,
|
344
169
|
});
|
345
170
|
} catch (e) {
|
@@ -362,7 +187,7 @@ describe('LobeQwenAI', () => {
|
|
362
187
|
try {
|
363
188
|
await instance.chat({
|
364
189
|
messages: [{ content: 'Hello', role: 'user' }],
|
365
|
-
model: 'qwen-turbo',
|
190
|
+
model: 'qwen-turbo-latest',
|
366
191
|
temperature: 0.999,
|
367
192
|
});
|
368
193
|
} catch (e) {
|
@@ -410,7 +235,7 @@ describe('LobeQwenAI', () => {
|
|
410
235
|
// 假设的测试函数调用,你可能需要根据实际情况调整
|
411
236
|
await instance.chat({
|
412
237
|
messages: [{ content: 'Hello', role: 'user' }],
|
413
|
-
model: 'qwen-turbo',
|
238
|
+
model: 'qwen-turbo-latest',
|
414
239
|
stream: true,
|
415
240
|
temperature: 0.999,
|
416
241
|
});
|
@@ -1,129 +1,50 @@
|
|
1
|
-
import {
|
2
|
-
import
|
1
|
+
import { ModelProvider } from '../types';
|
2
|
+
import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory';
|
3
3
|
|
4
|
-
import Qwen from '@/config/modelProviders/qwen';
|
5
|
-
|
6
|
-
import { LobeRuntimeAI } from '../BaseAI';
|
7
|
-
import { AgentRuntimeErrorType } from '../error';
|
8
|
-
import { ChatCompetitionOptions, ChatStreamPayload, ModelProvider } from '../types';
|
9
|
-
import { AgentRuntimeError } from '../utils/createError';
|
10
|
-
import { debugStream } from '../utils/debugStream';
|
11
|
-
import { handleOpenAIError } from '../utils/handleOpenAIError';
|
12
|
-
import { transformResponseToStream } from '../utils/openaiCompatibleFactory';
|
13
|
-
import { StreamingResponse } from '../utils/response';
|
14
4
|
import { QwenAIStream } from '../utils/streams';
|
15
5
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
}
|
62
|
-
|
63
|
-
return StreamingResponse(QwenAIStream(prod, options?.callback), {
|
64
|
-
headers: options?.headers,
|
65
|
-
});
|
66
|
-
}
|
67
|
-
|
68
|
-
const stream = transformResponseToStream(response as unknown as OpenAI.ChatCompletion);
|
69
|
-
|
70
|
-
return StreamingResponse(QwenAIStream(stream, options?.callback), {
|
71
|
-
headers: options?.headers,
|
72
|
-
});
|
73
|
-
} catch (error) {
|
74
|
-
if ('status' in (error as any)) {
|
75
|
-
switch ((error as Response).status) {
|
76
|
-
case 401: {
|
77
|
-
throw AgentRuntimeError.chat({
|
78
|
-
endpoint: this.baseURL,
|
79
|
-
error: error as any,
|
80
|
-
errorType: AgentRuntimeErrorType.InvalidProviderAPIKey,
|
81
|
-
provider: ModelProvider.Qwen,
|
82
|
-
});
|
83
|
-
}
|
84
|
-
|
85
|
-
default: {
|
86
|
-
break;
|
87
|
-
}
|
88
|
-
}
|
89
|
-
}
|
90
|
-
const { errorResult, RuntimeError } = handleOpenAIError(error);
|
91
|
-
const errorType = RuntimeError || AgentRuntimeErrorType.ProviderBizError;
|
92
|
-
|
93
|
-
throw AgentRuntimeError.chat({
|
94
|
-
endpoint: this.baseURL,
|
95
|
-
error: errorResult,
|
96
|
-
errorType,
|
97
|
-
provider: ModelProvider.Qwen,
|
98
|
-
});
|
99
|
-
}
|
100
|
-
}
|
101
|
-
|
102
|
-
private buildCompletionParamsByModel(payload: ChatStreamPayload) {
|
103
|
-
const { model, temperature, top_p, stream, messages, tools } = payload;
|
104
|
-
const isVisionModel = model.startsWith('qwen-vl');
|
105
|
-
|
106
|
-
const params = {
|
107
|
-
...payload,
|
108
|
-
messages,
|
109
|
-
result_format: 'message',
|
110
|
-
stream: !!tools?.length ? false : (stream ?? true),
|
111
|
-
temperature:
|
112
|
-
temperature === 0 || temperature >= 2 ? undefined : temperature === 1 ? 0.999 : temperature, // 'temperature' must be Float
|
113
|
-
top_p: top_p && top_p >= 1 ? 0.999 : top_p,
|
114
|
-
};
|
115
|
-
|
116
|
-
/* Qwen-vl models temporarily do not support parameters below. */
|
117
|
-
/* Notice: `top_p` imposes significant impact on the result,the default 1 or 0.999 is not a proper choice. */
|
118
|
-
return isVisionModel
|
119
|
-
? omit(
|
120
|
-
params,
|
121
|
-
'presence_penalty',
|
122
|
-
'frequency_penalty',
|
123
|
-
'temperature',
|
124
|
-
'result_format',
|
125
|
-
'top_p',
|
126
|
-
)
|
127
|
-
: omit(params, 'frequency_penalty');
|
128
|
-
}
|
129
|
-
}
|
6
|
+
/*
|
7
|
+
QwenLegacyModels: A set of legacy Qwen models that do not support presence_penalty.
|
8
|
+
Currently, presence_penalty is only supported on Qwen commercial models and open-source models starting from Qwen 1.5 and later.
|
9
|
+
*/
|
10
|
+
export const QwenLegacyModels = new Set([
|
11
|
+
'qwen-72b-chat',
|
12
|
+
'qwen-14b-chat',
|
13
|
+
'qwen-7b-chat',
|
14
|
+
'qwen-1.8b-chat',
|
15
|
+
'qwen-1.8b-longcontext-chat',
|
16
|
+
]);
|
17
|
+
|
18
|
+
export const LobeQwenAI = LobeOpenAICompatibleFactory({
|
19
|
+
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
20
|
+
chatCompletion: {
|
21
|
+
handlePayload: (payload) => {
|
22
|
+
const { model, presence_penalty, temperature, top_p, ...rest } = payload;
|
23
|
+
|
24
|
+
return {
|
25
|
+
...rest,
|
26
|
+
frequency_penalty: undefined,
|
27
|
+
model,
|
28
|
+
presence_penalty:
|
29
|
+
QwenLegacyModels.has(model)
|
30
|
+
? undefined
|
31
|
+
: (presence_penalty !== undefined && presence_penalty >= -2 && presence_penalty <= 2)
|
32
|
+
? presence_penalty
|
33
|
+
: undefined,
|
34
|
+
stream: !payload.tools,
|
35
|
+
temperature: (temperature !== undefined && temperature >= 0 && temperature < 2) ? temperature : undefined,
|
36
|
+
...(model.startsWith('qwen-vl') ? {
|
37
|
+
top_p: (top_p !== undefined && top_p > 0 && top_p <= 1) ? top_p : undefined,
|
38
|
+
} : {
|
39
|
+
enable_search: true,
|
40
|
+
top_p: (top_p !== undefined && top_p > 0 && top_p < 1) ? top_p : undefined,
|
41
|
+
}),
|
42
|
+
} as any;
|
43
|
+
},
|
44
|
+
handleStream: QwenAIStream,
|
45
|
+
},
|
46
|
+
debug: {
|
47
|
+
chatCompletion: () => process.env.DEBUG_QWEN_CHAT_COMPLETION === '1',
|
48
|
+
},
|
49
|
+
provider: ModelProvider.Qwen,
|
50
|
+
});
|
@@ -2,20 +2,17 @@
|
|
2
2
|
import OpenAI from 'openai';
|
3
3
|
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
4
4
|
|
5
|
-
import {
|
6
|
-
|
7
|
-
|
8
|
-
ModelProvider,
|
9
|
-
} from '@/libs/agent-runtime';
|
5
|
+
import { LobeOpenAICompatibleRuntime } from '@/libs/agent-runtime';
|
6
|
+
import { ModelProvider } from '@/libs/agent-runtime';
|
7
|
+
import { AgentRuntimeErrorType } from '@/libs/agent-runtime';
|
10
8
|
|
11
9
|
import * as debugStreamModule from '../utils/debugStream';
|
12
10
|
import { LobeSparkAI } from './index';
|
13
11
|
|
14
12
|
const provider = ModelProvider.Spark;
|
15
13
|
const defaultBaseURL = 'https://spark-api-open.xf-yun.com/v1';
|
16
|
-
|
17
|
-
const
|
18
|
-
const invalidErrorType = 'InvalidProviderAPIKey';
|
14
|
+
const bizErrorType = AgentRuntimeErrorType.ProviderBizError;
|
15
|
+
const invalidErrorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
|
19
16
|
|
20
17
|
// Mock the console.error to avoid polluting test output
|
21
18
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
@@ -46,7 +43,7 @@ describe('LobeSparkAI', () => {
|
|
46
43
|
|
47
44
|
describe('chat', () => {
|
48
45
|
describe('Error', () => {
|
49
|
-
it('should return
|
46
|
+
it('should return QwenBizError with an openai error response when OpenAI.APIError is thrown', async () => {
|
50
47
|
// Arrange
|
51
48
|
const apiError = new OpenAI.APIError(
|
52
49
|
400,
|
@@ -66,8 +63,8 @@ describe('LobeSparkAI', () => {
|
|
66
63
|
try {
|
67
64
|
await instance.chat({
|
68
65
|
messages: [{ content: 'Hello', role: 'user' }],
|
69
|
-
model: '
|
70
|
-
temperature: 0,
|
66
|
+
model: 'max-32k',
|
67
|
+
temperature: 0.999,
|
71
68
|
});
|
72
69
|
} catch (e) {
|
73
70
|
expect(e).toEqual({
|
@@ -82,7 +79,7 @@ describe('LobeSparkAI', () => {
|
|
82
79
|
}
|
83
80
|
});
|
84
81
|
|
85
|
-
it('should throw AgentRuntimeError with
|
82
|
+
it('should throw AgentRuntimeError with InvalidQwenAPIKey if no apiKey is provided', async () => {
|
86
83
|
try {
|
87
84
|
new LobeSparkAI({});
|
88
85
|
} catch (e) {
|
@@ -90,7 +87,7 @@ describe('LobeSparkAI', () => {
|
|
90
87
|
}
|
91
88
|
});
|
92
89
|
|
93
|
-
it('should return
|
90
|
+
it('should return QwenBizError with the cause when OpenAI.APIError is thrown with cause', async () => {
|
94
91
|
// Arrange
|
95
92
|
const errorInfo = {
|
96
93
|
stack: 'abc',
|
@@ -106,8 +103,8 @@ describe('LobeSparkAI', () => {
|
|
106
103
|
try {
|
107
104
|
await instance.chat({
|
108
105
|
messages: [{ content: 'Hello', role: 'user' }],
|
109
|
-
model: '
|
110
|
-
temperature: 0,
|
106
|
+
model: 'max-32k',
|
107
|
+
temperature: 0.999,
|
111
108
|
});
|
112
109
|
} catch (e) {
|
113
110
|
expect(e).toEqual({
|
@@ -122,7 +119,7 @@ describe('LobeSparkAI', () => {
|
|
122
119
|
}
|
123
120
|
});
|
124
121
|
|
125
|
-
it('should return
|
122
|
+
it('should return QwenBizError with an cause response with desensitize Url', async () => {
|
126
123
|
// Arrange
|
127
124
|
const errorInfo = {
|
128
125
|
stack: 'abc',
|
@@ -142,8 +139,8 @@ describe('LobeSparkAI', () => {
|
|
142
139
|
try {
|
143
140
|
await instance.chat({
|
144
141
|
messages: [{ content: 'Hello', role: 'user' }],
|
145
|
-
model: '
|
146
|
-
temperature: 0,
|
142
|
+
model: 'max-32k',
|
143
|
+
temperature: 0.999,
|
147
144
|
});
|
148
145
|
} catch (e) {
|
149
146
|
expect(e).toEqual({
|
@@ -158,23 +155,22 @@ describe('LobeSparkAI', () => {
|
|
158
155
|
}
|
159
156
|
});
|
160
157
|
|
161
|
-
it('should throw an
|
158
|
+
it('should throw an InvalidQwenAPIKey error type on 401 status code', async () => {
|
162
159
|
// Mock the API call to simulate a 401 error
|
163
|
-
const error = new Error('
|
160
|
+
const error = new Error('InvalidApiKey') as any;
|
164
161
|
error.status = 401;
|
165
162
|
vi.mocked(instance['client'].chat.completions.create).mockRejectedValue(error);
|
166
163
|
|
167
164
|
try {
|
168
165
|
await instance.chat({
|
169
166
|
messages: [{ content: 'Hello', role: 'user' }],
|
170
|
-
model: '
|
171
|
-
temperature: 0,
|
167
|
+
model: 'max-32k',
|
168
|
+
temperature: 0.999,
|
172
169
|
});
|
173
170
|
} catch (e) {
|
174
|
-
// Expect the chat method to throw an error with InvalidSparkAPIKey
|
175
171
|
expect(e).toEqual({
|
176
172
|
endpoint: defaultBaseURL,
|
177
|
-
error: new Error('
|
173
|
+
error: new Error('InvalidApiKey'),
|
178
174
|
errorType: invalidErrorType,
|
179
175
|
provider,
|
180
176
|
});
|
@@ -191,8 +187,8 @@ describe('LobeSparkAI', () => {
|
|
191
187
|
try {
|
192
188
|
await instance.chat({
|
193
189
|
messages: [{ content: 'Hello', role: 'user' }],
|
194
|
-
model: '
|
195
|
-
temperature: 0,
|
190
|
+
model: 'max-32k',
|
191
|
+
temperature: 0.999,
|
196
192
|
});
|
197
193
|
} catch (e) {
|
198
194
|
expect(e).toEqual({
|
@@ -239,9 +235,9 @@ describe('LobeSparkAI', () => {
|
|
239
235
|
// 假设的测试函数调用,你可能需要根据实际情况调整
|
240
236
|
await instance.chat({
|
241
237
|
messages: [{ content: 'Hello', role: 'user' }],
|
242
|
-
model: '
|
238
|
+
model: 'max-32k',
|
243
239
|
stream: true,
|
244
|
-
temperature: 0,
|
240
|
+
temperature: 0.999,
|
245
241
|
});
|
246
242
|
|
247
243
|
// 验证 debugStream 被调用
|
@@ -1,9 +1,13 @@
|
|
1
1
|
import { ModelProvider } from '../types';
|
2
2
|
import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory';
|
3
3
|
|
4
|
+
import { transformSparkResponseToStream, SparkAIStream } from '../utils/streams';
|
5
|
+
|
4
6
|
export const LobeSparkAI = LobeOpenAICompatibleFactory({
|
5
7
|
baseURL: 'https://spark-api-open.xf-yun.com/v1',
|
6
8
|
chatCompletion: {
|
9
|
+
handleStream: SparkAIStream,
|
10
|
+
handleTransformResponseToStream: transformSparkResponseToStream,
|
7
11
|
noUserId: true,
|
8
12
|
},
|
9
13
|
debug: {
|
@@ -1,10 +1,13 @@
|
|
1
1
|
// @vitest-environment node
|
2
2
|
import OpenAI from 'openai';
|
3
|
+
import type { Stream } from 'openai/streaming';
|
4
|
+
|
3
5
|
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
4
6
|
|
5
7
|
import {
|
6
8
|
AgentRuntimeErrorType,
|
7
9
|
ChatStreamCallbacks,
|
10
|
+
ChatStreamPayload,
|
8
11
|
LobeOpenAICompatibleRuntime,
|
9
12
|
ModelProvider,
|
10
13
|
} from '@/libs/agent-runtime';
|
@@ -797,6 +800,134 @@ describe('LobeOpenAICompatibleFactory', () => {
|
|
797
800
|
});
|
798
801
|
});
|
799
802
|
|
803
|
+
it('should use custom stream handler when provided', async () => {
|
804
|
+
// Create a custom stream handler that handles both ReadableStream and OpenAI Stream
|
805
|
+
const customStreamHandler = vi.fn((stream: ReadableStream | Stream<OpenAI.ChatCompletionChunk>) => {
|
806
|
+
const readableStream = stream instanceof ReadableStream ? stream : stream.toReadableStream();
|
807
|
+
return new ReadableStream({
|
808
|
+
start(controller) {
|
809
|
+
const reader = readableStream.getReader();
|
810
|
+
const process = async () => {
|
811
|
+
try {
|
812
|
+
while (true) {
|
813
|
+
const { done, value } = await reader.read();
|
814
|
+
if (done) break;
|
815
|
+
controller.enqueue(value);
|
816
|
+
}
|
817
|
+
} finally {
|
818
|
+
controller.close();
|
819
|
+
}
|
820
|
+
};
|
821
|
+
process();
|
822
|
+
},
|
823
|
+
});
|
824
|
+
});
|
825
|
+
|
826
|
+
const LobeMockProvider = LobeOpenAICompatibleFactory({
|
827
|
+
baseURL: 'https://api.test.com/v1',
|
828
|
+
chatCompletion: {
|
829
|
+
handleStream: customStreamHandler,
|
830
|
+
},
|
831
|
+
provider: ModelProvider.OpenAI,
|
832
|
+
});
|
833
|
+
|
834
|
+
const instance = new LobeMockProvider({ apiKey: 'test' });
|
835
|
+
|
836
|
+
// Create a mock stream
|
837
|
+
const mockStream = new ReadableStream({
|
838
|
+
start(controller) {
|
839
|
+
controller.enqueue({
|
840
|
+
id: 'test-id',
|
841
|
+
choices: [{ delta: { content: 'Hello' }, index: 0 }],
|
842
|
+
created: Date.now(),
|
843
|
+
model: 'test-model',
|
844
|
+
object: 'chat.completion.chunk',
|
845
|
+
});
|
846
|
+
controller.close();
|
847
|
+
},
|
848
|
+
});
|
849
|
+
|
850
|
+
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue({
|
851
|
+
tee: () => [mockStream, mockStream],
|
852
|
+
} as any);
|
853
|
+
|
854
|
+
const payload: ChatStreamPayload = {
|
855
|
+
messages: [{ content: 'Test', role: 'user' }],
|
856
|
+
model: 'test-model',
|
857
|
+
temperature: 0.7,
|
858
|
+
};
|
859
|
+
|
860
|
+
await instance.chat(payload);
|
861
|
+
|
862
|
+
expect(customStreamHandler).toHaveBeenCalled();
|
863
|
+
});
|
864
|
+
|
865
|
+
it('should use custom transform handler for non-streaming response', async () => {
|
866
|
+
const customTransformHandler = vi.fn((data: OpenAI.ChatCompletion): ReadableStream => {
|
867
|
+
return new ReadableStream({
|
868
|
+
start(controller) {
|
869
|
+
// Transform the completion to chunk format
|
870
|
+
controller.enqueue({
|
871
|
+
id: data.id,
|
872
|
+
choices: data.choices.map((choice) => ({
|
873
|
+
delta: { content: choice.message.content },
|
874
|
+
index: choice.index,
|
875
|
+
})),
|
876
|
+
created: data.created,
|
877
|
+
model: data.model,
|
878
|
+
object: 'chat.completion.chunk',
|
879
|
+
});
|
880
|
+
controller.close();
|
881
|
+
},
|
882
|
+
});
|
883
|
+
});
|
884
|
+
|
885
|
+
const LobeMockProvider = LobeOpenAICompatibleFactory({
|
886
|
+
baseURL: 'https://api.test.com/v1',
|
887
|
+
chatCompletion: {
|
888
|
+
handleTransformResponseToStream: customTransformHandler,
|
889
|
+
},
|
890
|
+
provider: ModelProvider.OpenAI,
|
891
|
+
});
|
892
|
+
|
893
|
+
const instance = new LobeMockProvider({ apiKey: 'test' });
|
894
|
+
|
895
|
+
const mockResponse: OpenAI.ChatCompletion = {
|
896
|
+
id: 'test-id',
|
897
|
+
choices: [
|
898
|
+
{
|
899
|
+
index: 0,
|
900
|
+
message: {
|
901
|
+
role: 'assistant',
|
902
|
+
content: 'Test response',
|
903
|
+
refusal: null
|
904
|
+
},
|
905
|
+
logprobs: null,
|
906
|
+
finish_reason: 'stop',
|
907
|
+
},
|
908
|
+
],
|
909
|
+
created: Date.now(),
|
910
|
+
model: 'test-model',
|
911
|
+
object: 'chat.completion',
|
912
|
+
usage: { completion_tokens: 2, prompt_tokens: 1, total_tokens: 3 },
|
913
|
+
};
|
914
|
+
|
915
|
+
vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
|
916
|
+
mockResponse as any,
|
917
|
+
);
|
918
|
+
|
919
|
+
const payload: ChatStreamPayload = {
|
920
|
+
messages: [{ content: 'Test', role: 'user' }],
|
921
|
+
model: 'test-model',
|
922
|
+
temperature: 0.7,
|
923
|
+
stream: false,
|
924
|
+
};
|
925
|
+
|
926
|
+
await instance.chat(payload);
|
927
|
+
|
928
|
+
expect(customTransformHandler).toHaveBeenCalledWith(mockResponse);
|
929
|
+
});
|
930
|
+
|
800
931
|
describe('DEBUG', () => {
|
801
932
|
it('should call debugStream and return StreamingTextResponse when DEBUG_OPENROUTER_CHAT_COMPLETION is 1', async () => {
|
802
933
|
// Arrange
|
@@ -25,6 +25,7 @@ import { handleOpenAIError } from '../handleOpenAIError';
|
|
25
25
|
import { convertOpenAIMessages } from '../openaiHelpers';
|
26
26
|
import { StreamingResponse } from '../response';
|
27
27
|
import { OpenAIStream, OpenAIStreamOptions } from '../streams';
|
28
|
+
import { ChatStreamCallbacks } from '../../types';
|
28
29
|
|
29
30
|
// the model contains the following keywords is not a chat model, so we should filter them out
|
30
31
|
export const CHAT_MODELS_BLOCK_LIST = [
|
@@ -62,10 +63,17 @@ interface OpenAICompatibleFactoryOptions<T extends Record<string, any> = any> {
|
|
62
63
|
payload: ChatStreamPayload,
|
63
64
|
options: ConstructorOptions<T>,
|
64
65
|
) => OpenAI.ChatCompletionCreateParamsStreaming;
|
66
|
+
handleStream?: (
|
67
|
+
stream: Stream<OpenAI.ChatCompletionChunk> | ReadableStream,
|
68
|
+
callbacks?: ChatStreamCallbacks,
|
69
|
+
) => ReadableStream;
|
65
70
|
handleStreamBizErrorType?: (error: {
|
66
71
|
message: string;
|
67
72
|
name: string;
|
68
73
|
}) => ILobeAgentRuntimeErrorType | undefined;
|
74
|
+
handleTransformResponseToStream?: (
|
75
|
+
data: OpenAI.ChatCompletion,
|
76
|
+
) => ReadableStream<OpenAI.ChatCompletionChunk>;
|
69
77
|
noUserId?: boolean;
|
70
78
|
};
|
71
79
|
constructorOptions?: ConstructorOptions<T>;
|
@@ -228,7 +236,8 @@ export const LobeOpenAICompatibleFactory = <T extends Record<string, any> = any>
|
|
228
236
|
debugStream(useForDebugStream).catch(console.error);
|
229
237
|
}
|
230
238
|
|
231
|
-
|
239
|
+
const streamHandler = chatCompletion?.handleStream || OpenAIStream;
|
240
|
+
return StreamingResponse(streamHandler(prod, streamOptions), {
|
232
241
|
headers: options?.headers,
|
233
242
|
});
|
234
243
|
}
|
@@ -239,9 +248,11 @@ export const LobeOpenAICompatibleFactory = <T extends Record<string, any> = any>
|
|
239
248
|
|
240
249
|
if (responseMode === 'json') return Response.json(response);
|
241
250
|
|
242
|
-
const
|
251
|
+
const transformHandler = chatCompletion?.handleTransformResponseToStream || transformResponseToStream;
|
252
|
+
const stream = transformHandler(response as unknown as OpenAI.ChatCompletion);
|
243
253
|
|
244
|
-
|
254
|
+
const streamHandler = chatCompletion?.handleStream || OpenAIStream;
|
255
|
+
return StreamingResponse(streamHandler(stream, streamOptions), {
|
245
256
|
headers: options?.headers,
|
246
257
|
});
|
247
258
|
} catch (error) {
|
@@ -0,0 +1,199 @@
|
|
1
|
+
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
2
|
+
import { SparkAIStream, transformSparkResponseToStream } from './spark';
|
3
|
+
import type OpenAI from 'openai';
|
4
|
+
|
5
|
+
describe('SparkAIStream', () => {
|
6
|
+
beforeAll(() => {});
|
7
|
+
|
8
|
+
it('should transform non-streaming response to stream', async () => {
|
9
|
+
const mockResponse = {
|
10
|
+
id: "cha000ceba6@dx193d200b580b8f3532",
|
11
|
+
object: "chat.completion",
|
12
|
+
created: 1734395014,
|
13
|
+
model: "max-32k",
|
14
|
+
choices: [
|
15
|
+
{
|
16
|
+
message: {
|
17
|
+
role: "assistant",
|
18
|
+
content: "",
|
19
|
+
refusal: null,
|
20
|
+
tool_calls: {
|
21
|
+
type: "function",
|
22
|
+
function: {
|
23
|
+
arguments: '{"city":"Shanghai"}',
|
24
|
+
name: "realtime-weather____fetchCurrentWeather"
|
25
|
+
},
|
26
|
+
id: "call_1"
|
27
|
+
}
|
28
|
+
},
|
29
|
+
index: 0,
|
30
|
+
logprobs: null,
|
31
|
+
finish_reason: "tool_calls"
|
32
|
+
}
|
33
|
+
],
|
34
|
+
usage: {
|
35
|
+
prompt_tokens: 8,
|
36
|
+
completion_tokens: 0,
|
37
|
+
total_tokens: 8
|
38
|
+
}
|
39
|
+
} as unknown as OpenAI.ChatCompletion;
|
40
|
+
|
41
|
+
const stream = transformSparkResponseToStream(mockResponse);
|
42
|
+
const decoder = new TextDecoder();
|
43
|
+
const chunks = [];
|
44
|
+
|
45
|
+
// @ts-ignore
|
46
|
+
for await (const chunk of stream) {
|
47
|
+
chunks.push(chunk);
|
48
|
+
}
|
49
|
+
|
50
|
+
expect(chunks).toHaveLength(2);
|
51
|
+
expect(chunks[0].choices[0].delta.tool_calls).toEqual([{
|
52
|
+
function: {
|
53
|
+
arguments: '{"city":"Shanghai"}',
|
54
|
+
name: "realtime-weather____fetchCurrentWeather"
|
55
|
+
},
|
56
|
+
id: "call_1",
|
57
|
+
index: 0,
|
58
|
+
type: "function"
|
59
|
+
}]);
|
60
|
+
expect(chunks[1].choices[0].finish_reason).toBeDefined();
|
61
|
+
});
|
62
|
+
|
63
|
+
it('should transform streaming response with tool calls', async () => {
|
64
|
+
const mockStream = new ReadableStream({
|
65
|
+
start(controller) {
|
66
|
+
controller.enqueue({
|
67
|
+
id: "cha000b0bf9@dx193d1ffa61cb894532",
|
68
|
+
object: "chat.completion.chunk",
|
69
|
+
created: 1734395014,
|
70
|
+
model: "max-32k",
|
71
|
+
choices: [
|
72
|
+
{
|
73
|
+
delta: {
|
74
|
+
role: "assistant",
|
75
|
+
content: "",
|
76
|
+
tool_calls: {
|
77
|
+
type: "function",
|
78
|
+
function: {
|
79
|
+
arguments: '{"city":"Shanghai"}',
|
80
|
+
name: "realtime-weather____fetchCurrentWeather"
|
81
|
+
},
|
82
|
+
id: "call_1"
|
83
|
+
}
|
84
|
+
},
|
85
|
+
index: 0
|
86
|
+
}
|
87
|
+
]
|
88
|
+
} as unknown as OpenAI.ChatCompletionChunk);
|
89
|
+
controller.close();
|
90
|
+
}
|
91
|
+
});
|
92
|
+
|
93
|
+
const onToolCallMock = vi.fn();
|
94
|
+
|
95
|
+
const protocolStream = SparkAIStream(mockStream, {
|
96
|
+
onToolCall: onToolCallMock
|
97
|
+
});
|
98
|
+
|
99
|
+
const decoder = new TextDecoder();
|
100
|
+
const chunks = [];
|
101
|
+
|
102
|
+
// @ts-ignore
|
103
|
+
for await (const chunk of protocolStream) {
|
104
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
105
|
+
}
|
106
|
+
|
107
|
+
expect(chunks).toEqual([
|
108
|
+
'id: cha000b0bf9@dx193d1ffa61cb894532\n',
|
109
|
+
'event: tool_calls\n',
|
110
|
+
`data: [{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Shanghai\\\"}\",\"name\":\"realtime-weather____fetchCurrentWeather\"},\"id\":\"call_1\",\"index\":0,\"type\":\"function\"}]\n\n`
|
111
|
+
]);
|
112
|
+
|
113
|
+
expect(onToolCallMock).toHaveBeenCalledTimes(1);
|
114
|
+
});
|
115
|
+
|
116
|
+
it('should handle text content in stream', async () => {
|
117
|
+
const mockStream = new ReadableStream({
|
118
|
+
start(controller) {
|
119
|
+
controller.enqueue({
|
120
|
+
id: "test-id",
|
121
|
+
object: "chat.completion.chunk",
|
122
|
+
created: 1734395014,
|
123
|
+
model: "max-32k",
|
124
|
+
choices: [
|
125
|
+
{
|
126
|
+
delta: {
|
127
|
+
content: "Hello",
|
128
|
+
role: "assistant"
|
129
|
+
},
|
130
|
+
index: 0
|
131
|
+
}
|
132
|
+
]
|
133
|
+
} as OpenAI.ChatCompletionChunk);
|
134
|
+
controller.enqueue({
|
135
|
+
id: "test-id",
|
136
|
+
object: "chat.completion.chunk",
|
137
|
+
created: 1734395014,
|
138
|
+
model: "max-32k",
|
139
|
+
choices: [
|
140
|
+
{
|
141
|
+
delta: {
|
142
|
+
content: " World",
|
143
|
+
role: "assistant"
|
144
|
+
},
|
145
|
+
index: 0
|
146
|
+
}
|
147
|
+
]
|
148
|
+
} as OpenAI.ChatCompletionChunk);
|
149
|
+
controller.close();
|
150
|
+
}
|
151
|
+
});
|
152
|
+
|
153
|
+
const onTextMock = vi.fn();
|
154
|
+
|
155
|
+
const protocolStream = SparkAIStream(mockStream, {
|
156
|
+
onText: onTextMock
|
157
|
+
});
|
158
|
+
|
159
|
+
const decoder = new TextDecoder();
|
160
|
+
const chunks = [];
|
161
|
+
|
162
|
+
// @ts-ignore
|
163
|
+
for await (const chunk of protocolStream) {
|
164
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
165
|
+
}
|
166
|
+
|
167
|
+
expect(chunks).toEqual([
|
168
|
+
'id: test-id\n',
|
169
|
+
'event: text\n',
|
170
|
+
'data: "Hello"\n\n',
|
171
|
+
'id: test-id\n',
|
172
|
+
'event: text\n',
|
173
|
+
'data: " World"\n\n'
|
174
|
+
]);
|
175
|
+
|
176
|
+
expect(onTextMock).toHaveBeenNthCalledWith(1, '"Hello"');
|
177
|
+
expect(onTextMock).toHaveBeenNthCalledWith(2, '" World"');
|
178
|
+
});
|
179
|
+
|
180
|
+
it('should handle empty stream', async () => {
|
181
|
+
const mockStream = new ReadableStream({
|
182
|
+
start(controller) {
|
183
|
+
controller.close();
|
184
|
+
}
|
185
|
+
});
|
186
|
+
|
187
|
+
const protocolStream = SparkAIStream(mockStream);
|
188
|
+
|
189
|
+
const decoder = new TextDecoder();
|
190
|
+
const chunks = [];
|
191
|
+
|
192
|
+
// @ts-ignore
|
193
|
+
for await (const chunk of protocolStream) {
|
194
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
195
|
+
}
|
196
|
+
|
197
|
+
expect(chunks).toEqual([]);
|
198
|
+
});
|
199
|
+
});
|
@@ -0,0 +1,134 @@
|
|
1
|
+
import OpenAI from 'openai';
|
2
|
+
import type { Stream } from 'openai/streaming';
|
3
|
+
|
4
|
+
import { ChatStreamCallbacks } from '../../types';
|
5
|
+
import {
|
6
|
+
StreamProtocolChunk,
|
7
|
+
StreamProtocolToolCallChunk,
|
8
|
+
convertIterableToStream,
|
9
|
+
createCallbacksTransformer,
|
10
|
+
createSSEProtocolTransformer,
|
11
|
+
generateToolCallId,
|
12
|
+
} from './protocol';
|
13
|
+
|
14
|
+
export function transformSparkResponseToStream(data: OpenAI.ChatCompletion) {
|
15
|
+
return new ReadableStream({
|
16
|
+
start(controller) {
|
17
|
+
const chunk: OpenAI.ChatCompletionChunk = {
|
18
|
+
choices: data.choices.map((choice: OpenAI.ChatCompletion.Choice) => {
|
19
|
+
const toolCallsArray = choice.message.tool_calls
|
20
|
+
? Array.isArray(choice.message.tool_calls)
|
21
|
+
? choice.message.tool_calls
|
22
|
+
: [choice.message.tool_calls]
|
23
|
+
: []; // 如果不是数组,包装成数组
|
24
|
+
|
25
|
+
return {
|
26
|
+
delta: {
|
27
|
+
content: choice.message.content,
|
28
|
+
role: choice.message.role,
|
29
|
+
tool_calls: toolCallsArray.map(
|
30
|
+
(tool, index): OpenAI.ChatCompletionChunk.Choice.Delta.ToolCall => ({
|
31
|
+
function: tool.function,
|
32
|
+
id: tool.id,
|
33
|
+
index,
|
34
|
+
type: tool.type,
|
35
|
+
}),
|
36
|
+
),
|
37
|
+
},
|
38
|
+
finish_reason: null,
|
39
|
+
index: choice.index,
|
40
|
+
logprobs: choice.logprobs,
|
41
|
+
};
|
42
|
+
}),
|
43
|
+
created: data.created,
|
44
|
+
id: data.id,
|
45
|
+
model: data.model,
|
46
|
+
object: 'chat.completion.chunk',
|
47
|
+
};
|
48
|
+
|
49
|
+
controller.enqueue(chunk);
|
50
|
+
|
51
|
+
controller.enqueue({
|
52
|
+
choices: data.choices.map((choice: OpenAI.ChatCompletion.Choice) => ({
|
53
|
+
delta: {
|
54
|
+
content: null,
|
55
|
+
role: choice.message.role,
|
56
|
+
},
|
57
|
+
finish_reason: choice.finish_reason,
|
58
|
+
index: choice.index,
|
59
|
+
logprobs: choice.logprobs,
|
60
|
+
})),
|
61
|
+
created: data.created,
|
62
|
+
id: data.id,
|
63
|
+
model: data.model,
|
64
|
+
object: 'chat.completion.chunk',
|
65
|
+
system_fingerprint: data.system_fingerprint,
|
66
|
+
} as OpenAI.ChatCompletionChunk);
|
67
|
+
controller.close();
|
68
|
+
},
|
69
|
+
});
|
70
|
+
}
|
71
|
+
|
72
|
+
export const transformSparkStream = (chunk: OpenAI.ChatCompletionChunk): StreamProtocolChunk => {
|
73
|
+
const item = chunk.choices[0];
|
74
|
+
|
75
|
+
if (!item) {
|
76
|
+
return { data: chunk, id: chunk.id, type: 'data' };
|
77
|
+
}
|
78
|
+
|
79
|
+
if (item.delta?.tool_calls) {
|
80
|
+
const toolCallsArray = Array.isArray(item.delta.tool_calls)
|
81
|
+
? item.delta.tool_calls
|
82
|
+
: [item.delta.tool_calls]; // 如果不是数组,包装成数组
|
83
|
+
|
84
|
+
if (toolCallsArray.length > 0) {
|
85
|
+
return {
|
86
|
+
data: toolCallsArray.map((toolCall, index) => ({
|
87
|
+
function: toolCall.function,
|
88
|
+
id: toolCall.id || generateToolCallId(index, toolCall.function?.name),
|
89
|
+
index: typeof toolCall.index !== 'undefined' ? toolCall.index : index,
|
90
|
+
type: toolCall.type || 'function',
|
91
|
+
})),
|
92
|
+
id: chunk.id,
|
93
|
+
type: 'tool_calls',
|
94
|
+
} as StreamProtocolToolCallChunk;
|
95
|
+
}
|
96
|
+
}
|
97
|
+
|
98
|
+
if (item.finish_reason) {
|
99
|
+
// one-api 的流式接口,会出现既有 finish_reason ,也有 content 的情况
|
100
|
+
// {"id":"demo","model":"deepl-en","choices":[{"index":0,"delta":{"role":"assistant","content":"Introduce yourself."},"finish_reason":"stop"}]}
|
101
|
+
|
102
|
+
if (typeof item.delta?.content === 'string' && !!item.delta.content) {
|
103
|
+
return { data: item.delta.content, id: chunk.id, type: 'text' };
|
104
|
+
}
|
105
|
+
|
106
|
+
return { data: item.finish_reason, id: chunk.id, type: 'stop' };
|
107
|
+
}
|
108
|
+
|
109
|
+
if (typeof item.delta?.content === 'string') {
|
110
|
+
return { data: item.delta.content, id: chunk.id, type: 'text' };
|
111
|
+
}
|
112
|
+
|
113
|
+
if (item.delta?.content === null) {
|
114
|
+
return { data: item.delta, id: chunk.id, type: 'data' };
|
115
|
+
}
|
116
|
+
|
117
|
+
return {
|
118
|
+
data: { delta: item.delta, id: chunk.id, index: item.index },
|
119
|
+
id: chunk.id,
|
120
|
+
type: 'data',
|
121
|
+
};
|
122
|
+
};
|
123
|
+
|
124
|
+
export const SparkAIStream = (
|
125
|
+
stream: Stream<OpenAI.ChatCompletionChunk> | ReadableStream,
|
126
|
+
callbacks?: ChatStreamCallbacks,
|
127
|
+
) => {
|
128
|
+
const readableStream =
|
129
|
+
stream instanceof ReadableStream ? stream : convertIterableToStream(stream);
|
130
|
+
|
131
|
+
return readableStream
|
132
|
+
.pipeThrough(createSSEProtocolTransformer(transformSparkStream))
|
133
|
+
.pipeThrough(createCallbacksTransformer(callbacks));
|
134
|
+
};
|