@lobehub/chat 1.41.0 → 1.42.0

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 CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 1.42.0](https://github.com/lobehub/lobe-chat/compare/v1.41.0...v1.42.0)
6
+
7
+ <sup>Released on **2024-12-29**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **misc**: Add custom stream handle support for LobeOpenAICompatibleFactory.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **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))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ## [Version 1.41.0](https://github.com/lobehub/lobe-chat/compare/v1.40.4...v1.41.0)
6
31
 
7
32
  <sup>Released on **2024-12-28**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,13 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "features": [
5
+ "Add custom stream handle support for LobeOpenAICompatibleFactory."
6
+ ]
7
+ },
8
+ "date": "2024-12-29",
9
+ "version": "1.42.0"
10
+ },
2
11
  {
3
12
  "children": {
4
13
  "features": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.41.0",
3
+ "version": "1.42.0",
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": "^10.2.0",
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.1",
317
+ "packageManager": "pnpm@9.15.2",
318
318
  "publishConfig": {
319
319
  "access": "public",
320
320
  "registry": "https://registry.npmjs.org"
@@ -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: false,
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: false,
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: false,
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 Qwen from '@/config/modelProviders/qwen';
6
- import { AgentRuntimeErrorType, ModelProvider } 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';
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: LobeQwenAI;
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
- baseURL: defaultBaseURL,
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
- /* Desensitizing is unnecessary for a public-accessible gateway endpoint. */
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 { omit } from 'lodash-es';
2
- import OpenAI, { ClientOptions } from 'openai';
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
- const DEFAULT_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1';
17
-
18
- /**
19
- * Use DashScope OpenAI compatible mode for now.
20
- * DashScope OpenAI [compatible mode](https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-vl-plus-api) currently supports base64 image input for vision models e.g. qwen-vl-plus.
21
- * You can use images input either:
22
- * 1. Use qwen-vl-* out of box with base64 image_url input;
23
- * or
24
- * 2. Set S3-* enviroment variables properly to store all uploaded files.
25
- */
26
- export class LobeQwenAI implements LobeRuntimeAI {
27
- client: OpenAI;
28
- baseURL: string;
29
-
30
- constructor({
31
- apiKey,
32
- baseURL = DEFAULT_BASE_URL,
33
- ...res
34
- }: ClientOptions & Record<string, any> = {}) {
35
- if (!apiKey) throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey);
36
- this.client = new OpenAI({ apiKey, baseURL, ...res });
37
- this.baseURL = this.client.baseURL;
38
- }
39
-
40
- async models() {
41
- return Qwen.chatModels;
42
- }
43
-
44
- async chat(payload: ChatStreamPayload, options?: ChatCompetitionOptions) {
45
- try {
46
- const params = this.buildCompletionParamsByModel(payload);
47
-
48
- const response = await this.client.chat.completions.create(
49
- params as OpenAI.ChatCompletionCreateParamsStreaming & { result_format: string },
50
- {
51
- headers: { Accept: '*/*' },
52
- signal: options?.signal,
53
- },
54
- );
55
-
56
- if (params.stream) {
57
- const [prod, debug] = response.tee();
58
-
59
- if (process.env.DEBUG_QWEN_CHAT_COMPLETION === '1') {
60
- debugStream(debug.toReadableStream()).catch(console.error);
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
- ChatStreamCallbacks,
7
- LobeOpenAICompatibleRuntime,
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 bizErrorType = 'ProviderBizError';
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 OpenAIBizError with an openai error response when OpenAI.APIError is thrown', async () => {
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: 'general',
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 NoOpenAIAPIKey if no apiKey is provided', async () => {
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 OpenAIBizError with the cause when OpenAI.APIError is thrown with cause', async () => {
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: 'general',
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 OpenAIBizError with an cause response with desensitize Url', async () => {
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: 'general',
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 InvalidSparkAPIKey error type on 401 status code', async () => {
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('Unauthorized') as any;
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: 'general',
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('Unauthorized'),
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: 'general',
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: 'general',
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
- return StreamingResponse(OpenAIStream(prod, streamOptions), {
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 stream = transformResponseToStream(response as unknown as OpenAI.ChatCompletion);
251
+ const transformHandler = chatCompletion?.handleTransformResponseToStream || transformResponseToStream;
252
+ const stream = transformHandler(response as unknown as OpenAI.ChatCompletion);
243
253
 
244
- return StreamingResponse(OpenAIStream(stream, streamOptions), {
254
+ const streamHandler = chatCompletion?.handleStream || OpenAIStream;
255
+ return StreamingResponse(streamHandler(stream, streamOptions), {
245
256
  headers: options?.headers,
246
257
  });
247
258
  } catch (error) {
@@ -7,3 +7,4 @@ export * from './ollama';
7
7
  export * from './openai';
8
8
  export * from './protocol';
9
9
  export * from './qwen';
10
+ export * from './spark';
@@ -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
+ };