@lobehub/chat 1.4.3 → 1.5.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.5.0](https://github.com/lobehub/lobe-chat/compare/v1.4.3...v1.5.0)
6
+
7
+ <sup>Released on **2024-07-17**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **misc**: Spport qwen-vl and tool call for qwen.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **misc**: Spport qwen-vl and tool call for qwen, closes [#3114](https://github.com/lobehub/lobe-chat/issues/3114) ([5216a85](https://github.com/lobehub/lobe-chat/commit/5216a85))
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.4.3](https://github.com/lobehub/lobe-chat/compare/v1.4.2...v1.4.3)
6
31
 
7
32
  <sup>Released on **2024-07-15**</sup>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.4.3",
3
+ "version": "1.5.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",
@@ -267,6 +267,7 @@
267
267
  "vitest": "~1.2.2",
268
268
  "vitest-canvas-mock": "^0.3.3"
269
269
  },
270
+ "packageManager": "pnpm@9.5.0",
270
271
  "publishConfig": {
271
272
  "access": "public",
272
273
  "registry": "https://registry.npmjs.org"
@@ -7,6 +7,7 @@ const Qwen: ModelProviderCard = {
7
7
  description: '通义千问超大规模语言模型,支持中文、英文等不同语言输入',
8
8
  displayName: 'Qwen Turbo',
9
9
  enabled: true,
10
+ functionCall: true,
10
11
  id: 'qwen-turbo',
11
12
  tokens: 8000,
12
13
  },
@@ -14,6 +15,7 @@ const Qwen: ModelProviderCard = {
14
15
  description: '通义千问超大规模语言模型增强版,支持中文、英文等不同语言输入',
15
16
  displayName: 'Qwen Plus',
16
17
  enabled: true,
18
+ functionCall: true,
17
19
  id: 'qwen-plus',
18
20
  tokens: 32_000,
19
21
  },
@@ -22,6 +24,7 @@ const Qwen: ModelProviderCard = {
22
24
  '通义千问千亿级别超大规模语言模型,支持中文、英文等不同语言输入,当前通义千问2.5产品版本背后的API模型',
23
25
  displayName: 'Qwen Max',
24
26
  enabled: true,
27
+ functionCall: true,
25
28
  id: 'qwen-max',
26
29
  tokens: 8000,
27
30
  },
@@ -29,6 +32,7 @@ const Qwen: ModelProviderCard = {
29
32
  description:
30
33
  '通义千问千亿级别超大规模语言模型,支持中文、英文等不同语言输入,扩展了上下文窗口',
31
34
  displayName: 'Qwen Max LongContext',
35
+ functionCall: true,
32
36
  id: 'qwen-max-longcontext',
33
37
  tokens: 30_000,
34
38
  },
@@ -50,6 +54,24 @@ const Qwen: ModelProviderCard = {
50
54
  id: 'qwen2-72b-instruct',
51
55
  tokens: 131_072,
52
56
  },
57
+ {
58
+ description:
59
+ '通义千问大规模视觉语言模型增强版。大幅提升细节识别能力和文字识别能力,支持超百万像素分辨率和任意长宽比规格的图像。',
60
+ displayName: 'Qwen VL Plus',
61
+ enabled: true,
62
+ id: 'qwen-vl-plus',
63
+ tokens: 6144,
64
+ vision: true,
65
+ },
66
+ {
67
+ description:
68
+ '通义千问超大规模视觉语言模型。相比增强版,再次提升视觉推理能力和指令遵循能力,提供更高的视觉感知和认知水平。',
69
+ displayName: 'Qwen VL Max',
70
+ enabled: true,
71
+ id: 'qwen-vl-max',
72
+ tokens: 6144,
73
+ vision: true,
74
+ },
53
75
  ],
54
76
  checkModel: 'qwen-turbo',
55
77
  disableBrowserRequest: true,
@@ -2,6 +2,7 @@
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';
5
6
  import { LobeOpenAICompatibleRuntime } from '@/libs/agent-runtime';
6
7
  import { ModelProvider } from '@/libs/agent-runtime';
7
8
  import { AgentRuntimeErrorType } from '@/libs/agent-runtime';
@@ -17,7 +18,7 @@ const invalidErrorType = AgentRuntimeErrorType.InvalidProviderAPIKey;
17
18
  // Mock the console.error to avoid polluting test output
18
19
  vi.spyOn(console, 'error').mockImplementation(() => {});
19
20
 
20
- let instance: LobeOpenAICompatibleRuntime;
21
+ let instance: LobeQwenAI;
21
22
 
22
23
  beforeEach(() => {
23
24
  instance = new LobeQwenAI({ apiKey: 'test' });
@@ -41,7 +42,116 @@ describe('LobeQwenAI', () => {
41
42
  });
42
43
  });
43
44
 
45
+ describe('models', () => {
46
+ it('should correctly list available models', async () => {
47
+ const instance = new LobeQwenAI({ apiKey: 'test_api_key' });
48
+ vi.spyOn(instance, 'models').mockResolvedValue(Qwen.chatModels);
49
+
50
+ const models = await instance.models();
51
+ expect(models).toEqual(Qwen.chatModels);
52
+ });
53
+ });
54
+
44
55
  describe('chat', () => {
56
+ describe('Params', () => {
57
+ it('should call llms with proper options', async () => {
58
+ const mockStream = new ReadableStream();
59
+ const mockResponse = Promise.resolve(mockStream);
60
+
61
+ (instance['client'].chat.completions.create as Mock).mockResolvedValue(mockResponse);
62
+
63
+ const result = await instance.chat({
64
+ messages: [{ content: 'Hello', role: 'user' }],
65
+ model: 'qwen-turbo',
66
+ temperature: 0.6,
67
+ top_p: 0.7,
68
+ });
69
+
70
+ // Assert
71
+ expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
72
+ {
73
+ messages: [{ content: 'Hello', role: 'user' }],
74
+ model: 'qwen-turbo',
75
+ temperature: 0.6,
76
+ stream: true,
77
+ top_p: 0.7,
78
+ result_format: 'message',
79
+ },
80
+ { headers: { Accept: '*/*' } },
81
+ );
82
+ expect(result).toBeInstanceOf(Response);
83
+ });
84
+
85
+ it('should call vlms with proper options', async () => {
86
+ const mockStream = new ReadableStream();
87
+ const mockResponse = Promise.resolve(mockStream);
88
+
89
+ (instance['client'].chat.completions.create as Mock).mockResolvedValue(mockResponse);
90
+
91
+ const result = await instance.chat({
92
+ messages: [{ content: 'Hello', role: 'user' }],
93
+ model: 'qwen-vl-plus',
94
+ temperature: 0.6,
95
+ top_p: 0.7,
96
+ });
97
+
98
+ // Assert
99
+ expect(instance['client'].chat.completions.create).toHaveBeenCalledWith(
100
+ {
101
+ messages: [{ content: 'Hello', role: 'user' }],
102
+ model: 'qwen-vl-plus',
103
+ stream: true,
104
+ },
105
+ { headers: { Accept: '*/*' } },
106
+ );
107
+ expect(result).toBeInstanceOf(Response);
108
+ });
109
+
110
+ it('should transform non-streaming response to stream correctly', async () => {
111
+ const mockResponse: OpenAI.ChatCompletion = {
112
+ id: 'chatcmpl-fc539f49-51a8-94be-8061',
113
+ object: 'chat.completion',
114
+ created: 1719901794,
115
+ model: 'qwen-turbo',
116
+ choices: [
117
+ {
118
+ index: 0,
119
+ message: { role: 'assistant', content: 'Hello' },
120
+ finish_reason: 'stop',
121
+ logprobs: null,
122
+ },
123
+ ],
124
+ };
125
+ vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
126
+ mockResponse as any,
127
+ );
128
+
129
+ const result = await instance.chat({
130
+ messages: [{ content: 'Hello', role: 'user' }],
131
+ model: 'qwen-turbo',
132
+ temperature: 0.6,
133
+ stream: false,
134
+ });
135
+
136
+ const decoder = new TextDecoder();
137
+
138
+ const reader = result.body!.getReader();
139
+ expect(decoder.decode((await reader.read()).value)).toContain(
140
+ 'id: chatcmpl-fc539f49-51a8-94be-8061\n',
141
+ );
142
+ expect(decoder.decode((await reader.read()).value)).toContain('event: text\n');
143
+ expect(decoder.decode((await reader.read()).value)).toContain('data: "Hello"\n\n');
144
+
145
+ expect(decoder.decode((await reader.read()).value)).toContain(
146
+ 'id: chatcmpl-fc539f49-51a8-94be-8061\n',
147
+ );
148
+ expect(decoder.decode((await reader.read()).value)).toContain('event: stop\n');
149
+ expect(decoder.decode((await reader.read()).value)).toContain('');
150
+
151
+ expect((await reader.read()).done).toBe(true);
152
+ });
153
+ });
154
+
45
155
  describe('Error', () => {
46
156
  it('should return QwenBizError with an openai error response when OpenAI.APIError is thrown', async () => {
47
157
  // Arrange
@@ -129,8 +239,7 @@ describe('LobeQwenAI', () => {
129
239
 
130
240
  instance = new LobeQwenAI({
131
241
  apiKey: 'test',
132
-
133
- baseURL: 'https://api.abc.com/v1',
242
+ baseURL: defaultBaseURL,
134
243
  });
135
244
 
136
245
  vi.spyOn(instance['client'].chat.completions, 'create').mockRejectedValue(apiError);
@@ -144,7 +253,8 @@ describe('LobeQwenAI', () => {
144
253
  });
145
254
  } catch (e) {
146
255
  expect(e).toEqual({
147
- endpoint: 'https://api.***.com/v1',
256
+ /* Desensitizing is unnecessary for a public-accessible gateway endpoint. */
257
+ endpoint: defaultBaseURL,
148
258
  error: {
149
259
  cause: { message: 'api is undefined' },
150
260
  stack: 'abc',
@@ -1,28 +1,128 @@
1
- import OpenAI from 'openai';
2
-
3
- import { ModelProvider } from '../types';
4
- import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory';
5
-
6
- export const LobeQwenAI = LobeOpenAICompatibleFactory({
7
- baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
8
- chatCompletion: {
9
- handlePayload: (payload) => {
10
- const top_p = payload.top_p;
11
- return {
12
- ...payload,
13
- stream: payload.stream ?? true,
14
- top_p: top_p && top_p >= 1 ? 0.9999 : top_p,
15
- } as OpenAI.ChatCompletionCreateParamsStreaming;
16
- },
17
- },
18
- constructorOptions: {
19
- defaultHeaders: {
20
- 'Content-Type': 'application/json',
21
- },
22
- },
23
- debug: {
24
- chatCompletion: () => process.env.DEBUG_QWEN_CHAT_COMPLETION === '1',
25
- },
26
-
27
- provider: ModelProvider.Qwen,
28
- });
1
+ import { omit } from 'lodash-es';
2
+ import OpenAI, { ClientOptions } from 'openai';
3
+
4
+ import Qwen from '@/config/modelProviders/qwen';
5
+
6
+ import { LobeOpenAICompatibleRuntime, 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
+ import { QwenAIStream } from '../utils/streams';
15
+
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 extends LobeOpenAICompatibleRuntime 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
+ super();
36
+ if (!apiKey) throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidProviderAPIKey);
37
+ this.client = new OpenAI({ apiKey, baseURL, ...res });
38
+ this.baseURL = this.client.baseURL;
39
+ }
40
+
41
+ async models() {
42
+ return Qwen.chatModels;
43
+ }
44
+
45
+ async chat(payload: ChatStreamPayload, options?: ChatCompetitionOptions) {
46
+ try {
47
+ const params = this.buildCompletionParamsByModel(payload);
48
+
49
+ const response = await this.client.chat.completions.create(
50
+ params as OpenAI.ChatCompletionCreateParamsStreaming & { result_format: string },
51
+ {
52
+ headers: { Accept: '*/*' },
53
+ signal: options?.signal,
54
+ },
55
+ );
56
+
57
+ if (params.stream) {
58
+ const [prod, debug] = response.tee();
59
+
60
+ if (process.env.DEBUG_QWEN_CHAT_COMPLETION === '1') {
61
+ debugStream(debug.toReadableStream()).catch(console.error);
62
+ }
63
+
64
+ return StreamingResponse(QwenAIStream(prod, options?.callback), {
65
+ headers: options?.headers,
66
+ });
67
+ }
68
+
69
+ const stream = transformResponseToStream(response as unknown as OpenAI.ChatCompletion);
70
+
71
+ return StreamingResponse(QwenAIStream(stream, options?.callback), {
72
+ headers: options?.headers,
73
+ });
74
+ } catch (error) {
75
+ if ('status' in (error as any)) {
76
+ switch ((error as Response).status) {
77
+ case 401: {
78
+ throw AgentRuntimeError.chat({
79
+ endpoint: this.baseURL,
80
+ error: error as any,
81
+ errorType: AgentRuntimeErrorType.InvalidProviderAPIKey,
82
+ provider: ModelProvider.Qwen,
83
+ });
84
+ }
85
+
86
+ default: {
87
+ break;
88
+ }
89
+ }
90
+ }
91
+ const { errorResult, RuntimeError } = handleOpenAIError(error);
92
+ const errorType = RuntimeError || AgentRuntimeErrorType.ProviderBizError;
93
+
94
+ throw AgentRuntimeError.chat({
95
+ endpoint: this.baseURL,
96
+ error: errorResult,
97
+ errorType,
98
+ provider: ModelProvider.Qwen,
99
+ });
100
+ }
101
+ }
102
+
103
+ private buildCompletionParamsByModel(payload: ChatStreamPayload) {
104
+ const { model, top_p, stream, messages, tools } = payload;
105
+ const isVisionModel = model.startsWith('qwen-vl');
106
+
107
+ const params = {
108
+ ...payload,
109
+ messages,
110
+ result_format: 'message',
111
+ stream: !!tools?.length ? false : stream ?? true,
112
+ top_p: top_p && top_p >= 1 ? 0.999 : top_p,
113
+ };
114
+
115
+ /* Qwen-vl models temporarily do not support parameters below. */
116
+ /* Notice: `top_p` imposes significant impact on the result,the default 1 or 0.999 is not a proper choice. */
117
+ return isVisionModel
118
+ ? omit(
119
+ params,
120
+ 'presence_penalty',
121
+ 'frequency_penalty',
122
+ 'temperature',
123
+ 'result_format',
124
+ 'top_p',
125
+ )
126
+ : params;
127
+ }
128
+ }
@@ -54,6 +54,59 @@ interface OpenAICompatibleFactoryOptions<T extends Record<string, any> = any> {
54
54
  provider: string;
55
55
  }
56
56
 
57
+ /**
58
+ * make the OpenAI response data as a stream
59
+ */
60
+ export function transformResponseToStream(data: OpenAI.ChatCompletion) {
61
+ return new ReadableStream({
62
+ start(controller) {
63
+ const chunk: OpenAI.ChatCompletionChunk = {
64
+ choices: data.choices.map((choice: OpenAI.ChatCompletion.Choice) => ({
65
+ delta: {
66
+ content: choice.message.content,
67
+ role: choice.message.role,
68
+ tool_calls: choice.message.tool_calls?.map(
69
+ (tool, index): OpenAI.ChatCompletionChunk.Choice.Delta.ToolCall => ({
70
+ function: tool.function,
71
+ id: tool.id,
72
+ index,
73
+ type: tool.type,
74
+ }),
75
+ ),
76
+ },
77
+ finish_reason: null,
78
+ index: choice.index,
79
+ logprobs: choice.logprobs,
80
+ })),
81
+ created: data.created,
82
+ id: data.id,
83
+ model: data.model,
84
+ object: 'chat.completion.chunk',
85
+ };
86
+
87
+ controller.enqueue(chunk);
88
+
89
+ controller.enqueue({
90
+ choices: data.choices.map((choice: OpenAI.ChatCompletion.Choice) => ({
91
+ delta: {
92
+ content: choice.message.content,
93
+ role: choice.message.role,
94
+ },
95
+ finish_reason: choice.finish_reason,
96
+ index: choice.index,
97
+ logprobs: choice.logprobs,
98
+ })),
99
+ created: data.created,
100
+ id: data.id,
101
+ model: data.model,
102
+ object: 'chat.completion.chunk',
103
+ system_fingerprint: data.system_fingerprint,
104
+ } as OpenAI.ChatCompletionChunk);
105
+ controller.close();
106
+ },
107
+ });
108
+ }
109
+
57
110
  export const LobeOpenAICompatibleFactory = <T extends Record<string, any> = any>({
58
111
  provider,
59
112
  baseURL: DEFAULT_BASE_URL,
@@ -117,7 +170,7 @@ export const LobeOpenAICompatibleFactory = <T extends Record<string, any> = any>
117
170
  debugResponse(response);
118
171
  }
119
172
 
120
- const stream = this.transformResponseToStream(response as unknown as OpenAI.ChatCompletion);
173
+ const stream = transformResponseToStream(response as unknown as OpenAI.ChatCompletion);
121
174
 
122
175
  return StreamingResponse(OpenAIStream(stream, options?.callback), {
123
176
  headers: options?.headers,
@@ -162,60 +215,6 @@ export const LobeOpenAICompatibleFactory = <T extends Record<string, any> = any>
162
215
  }
163
216
  }
164
217
 
165
- /**
166
- * make the OpenAI response data as a stream
167
- * @private
168
- */
169
- private transformResponseToStream(data: OpenAI.ChatCompletion) {
170
- return new ReadableStream({
171
- start(controller) {
172
- const chunk: OpenAI.ChatCompletionChunk = {
173
- choices: data.choices.map((choice: OpenAI.ChatCompletion.Choice) => ({
174
- delta: {
175
- content: choice.message.content,
176
- role: choice.message.role,
177
- tool_calls: choice.message.tool_calls?.map(
178
- (tool, index): OpenAI.ChatCompletionChunk.Choice.Delta.ToolCall => ({
179
- function: tool.function,
180
- id: tool.id,
181
- index,
182
- type: tool.type,
183
- }),
184
- ),
185
- },
186
- finish_reason: null,
187
- index: choice.index,
188
- logprobs: choice.logprobs,
189
- })),
190
- created: data.created,
191
- id: data.id,
192
- model: data.model,
193
- object: 'chat.completion.chunk',
194
- };
195
-
196
- controller.enqueue(chunk);
197
-
198
- controller.enqueue({
199
- choices: data.choices.map((choice: OpenAI.ChatCompletion.Choice) => ({
200
- delta: {
201
- content: choice.message.content,
202
- role: choice.message.role,
203
- },
204
- finish_reason: choice.finish_reason,
205
- index: choice.index,
206
- logprobs: choice.logprobs,
207
- })),
208
- created: data.created,
209
- id: data.id,
210
- model: data.model,
211
- object: 'chat.completion.chunk',
212
- system_fingerprint: data.system_fingerprint,
213
- } as OpenAI.ChatCompletionChunk);
214
- controller.close();
215
- },
216
- });
217
- }
218
-
219
218
  private handleError(error: any): ChatCompletionErrorPayload {
220
219
  let desensitizedEndpoint = this.baseURL;
221
220
 
@@ -6,3 +6,4 @@ export * from './minimax';
6
6
  export * from './ollama';
7
7
  export * from './openai';
8
8
  export * from './protocol';
9
+ export * from './qwen';
@@ -0,0 +1,350 @@
1
+ import { beforeAll, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { QwenAIStream } from './qwen';
4
+
5
+ describe('QwenAIStream', () => {
6
+ beforeAll(() => {});
7
+
8
+ it('should transform OpenAI stream to protocol stream', async () => {
9
+ const mockOpenAIStream = new ReadableStream({
10
+ start(controller) {
11
+ controller.enqueue({
12
+ choices: [
13
+ {
14
+ delta: { content: 'Hello' },
15
+ index: 0,
16
+ },
17
+ ],
18
+ id: '1',
19
+ });
20
+ controller.enqueue({
21
+ choices: [
22
+ {
23
+ delta: { content: ' world!' },
24
+ index: 1,
25
+ },
26
+ ],
27
+ id: '1',
28
+ });
29
+ controller.enqueue({
30
+ choices: [
31
+ {
32
+ delta: null,
33
+ finish_reason: 'stop',
34
+ index: 2,
35
+ },
36
+ ],
37
+ id: '1',
38
+ });
39
+
40
+ controller.close();
41
+ },
42
+ });
43
+
44
+ const onStartMock = vi.fn();
45
+ const onTextMock = vi.fn();
46
+ const onTokenMock = vi.fn();
47
+ const onCompletionMock = vi.fn();
48
+
49
+ const protocolStream = QwenAIStream(mockOpenAIStream, {
50
+ onStart: onStartMock,
51
+ onText: onTextMock,
52
+ onToken: onTokenMock,
53
+ onCompletion: onCompletionMock,
54
+ });
55
+
56
+ const decoder = new TextDecoder();
57
+ const chunks = [];
58
+
59
+ // @ts-ignore
60
+ for await (const chunk of protocolStream) {
61
+ chunks.push(decoder.decode(chunk, { stream: true }));
62
+ }
63
+
64
+ expect(chunks).toEqual([
65
+ 'id: 1\n',
66
+ 'event: text\n',
67
+ `data: "Hello"\n\n`,
68
+ 'id: 1\n',
69
+ 'event: text\n',
70
+ `data: " world!"\n\n`,
71
+ 'id: 1\n',
72
+ 'event: stop\n',
73
+ `data: "stop"\n\n`,
74
+ ]);
75
+
76
+ expect(onStartMock).toHaveBeenCalledTimes(1);
77
+ expect(onTextMock).toHaveBeenNthCalledWith(1, '"Hello"');
78
+ expect(onTextMock).toHaveBeenNthCalledWith(2, '" world!"');
79
+ expect(onTokenMock).toHaveBeenCalledTimes(2);
80
+ expect(onCompletionMock).toHaveBeenCalledTimes(1);
81
+ });
82
+
83
+ it('should handle tool calls', async () => {
84
+ const mockOpenAIStream = new ReadableStream({
85
+ start(controller) {
86
+ controller.enqueue({
87
+ choices: [
88
+ {
89
+ delta: {
90
+ tool_calls: [
91
+ {
92
+ function: { name: 'tool1', arguments: '{}' },
93
+ id: 'call_1',
94
+ index: 0,
95
+ type: 'function',
96
+ },
97
+ {
98
+ function: { name: 'tool2', arguments: '{}' },
99
+ id: 'call_2',
100
+ index: 1,
101
+ },
102
+ ],
103
+ },
104
+ index: 0,
105
+ },
106
+ ],
107
+ id: '2',
108
+ });
109
+
110
+ controller.close();
111
+ },
112
+ });
113
+
114
+ const onToolCallMock = vi.fn();
115
+
116
+ const protocolStream = QwenAIStream(mockOpenAIStream, {
117
+ onToolCall: onToolCallMock,
118
+ });
119
+
120
+ const decoder = new TextDecoder();
121
+ const chunks = [];
122
+
123
+ // @ts-ignore
124
+ for await (const chunk of protocolStream) {
125
+ chunks.push(decoder.decode(chunk, { stream: true }));
126
+ }
127
+
128
+ expect(chunks).toEqual([
129
+ 'id: 2\n',
130
+ 'event: tool_calls\n',
131
+ `data: [{"function":{"name":"tool1","arguments":"{}"},"id":"call_1","index":0,"type":"function"},{"function":{"name":"tool2","arguments":"{}"},"id":"call_2","index":1,"type":"function"}]\n\n`,
132
+ ]);
133
+
134
+ expect(onToolCallMock).toHaveBeenCalledTimes(1);
135
+ });
136
+
137
+ it('should handle empty stream', async () => {
138
+ const mockStream = new ReadableStream({
139
+ start(controller) {
140
+ controller.close();
141
+ },
142
+ });
143
+
144
+ const protocolStream = QwenAIStream(mockStream);
145
+
146
+ const decoder = new TextDecoder();
147
+ const chunks = [];
148
+
149
+ // @ts-ignore
150
+ for await (const chunk of protocolStream) {
151
+ chunks.push(decoder.decode(chunk, { stream: true }));
152
+ }
153
+
154
+ expect(chunks).toEqual([]);
155
+ });
156
+
157
+ it('should handle chunk with no choices', async () => {
158
+ const mockStream = new ReadableStream({
159
+ start(controller) {
160
+ controller.enqueue({
161
+ choices: [],
162
+ id: '1',
163
+ });
164
+
165
+ controller.close();
166
+ },
167
+ });
168
+
169
+ const protocolStream = QwenAIStream(mockStream);
170
+
171
+ const decoder = new TextDecoder();
172
+ const chunks = [];
173
+
174
+ // @ts-ignore
175
+ for await (const chunk of protocolStream) {
176
+ chunks.push(decoder.decode(chunk, { stream: true }));
177
+ }
178
+
179
+ expect(chunks).toEqual(['id: 1\n', 'event: data\n', 'data: {"choices":[],"id":"1"}\n\n']);
180
+ });
181
+
182
+ it('should handle vision model stream', async () => {
183
+ const mockStream = new ReadableStream({
184
+ start(controller) {
185
+ controller.enqueue({
186
+ choices: [
187
+ {
188
+ delta: {
189
+ content: [
190
+ {
191
+ text: '图中是一只小狗',
192
+ },
193
+ ],
194
+ },
195
+ },
196
+ ],
197
+ id: '3',
198
+ });
199
+
200
+ /**
201
+ * Just for test against the description of 'output.choices[x].message.content' in [documents](https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-vl-plus-api)
202
+ * You're not likely to get image outputs from current versions of vl models.
203
+ */
204
+ controller.enqueue({
205
+ choices: [
206
+ {
207
+ delta: {
208
+ content: [
209
+ {
210
+ image: 'https://hello.mock/test.png',
211
+ },
212
+ ],
213
+ },
214
+ },
215
+ ],
216
+ id: '3',
217
+ });
218
+ controller.close();
219
+ },
220
+ });
221
+
222
+ const protocolStream = QwenAIStream(mockStream);
223
+
224
+ const decoder = new TextDecoder();
225
+ const chunks = [];
226
+
227
+ // @ts-ignore
228
+ for await (const chunk of protocolStream) {
229
+ chunks.push(decoder.decode(chunk, { stream: true }));
230
+ }
231
+
232
+ expect(chunks).toEqual([
233
+ 'id: 3\n',
234
+ 'event: text\n',
235
+ 'data: "图中是一只小狗"\n\n',
236
+ 'id: 3\n',
237
+ 'event: text\n',
238
+ 'data: "![image](https://hello.mock/test.png)"\n\n',
239
+ ]);
240
+ });
241
+
242
+ it('should delta content null', async () => {
243
+ const mockOpenAIStream = new ReadableStream({
244
+ start(controller) {
245
+ controller.enqueue({
246
+ choices: [
247
+ {
248
+ delta: { content: null },
249
+ index: 0,
250
+ },
251
+ ],
252
+ id: '3',
253
+ });
254
+
255
+ controller.close();
256
+ },
257
+ });
258
+
259
+ const protocolStream = QwenAIStream(mockOpenAIStream);
260
+
261
+ const decoder = new TextDecoder();
262
+ const chunks = [];
263
+
264
+ // @ts-ignore
265
+ for await (const chunk of protocolStream) {
266
+ chunks.push(decoder.decode(chunk, { stream: true }));
267
+ }
268
+
269
+ expect(chunks).toEqual(['id: 3\n', 'event: data\n', `data: {"content":null}\n\n`]);
270
+ });
271
+
272
+ it('should handle other delta data', async () => {
273
+ const mockOpenAIStream = new ReadableStream({
274
+ start(controller) {
275
+ controller.enqueue({
276
+ choices: [
277
+ {
278
+ delta: { custom_field: 'custom_value' },
279
+ index: 0,
280
+ },
281
+ ],
282
+ id: '4',
283
+ });
284
+
285
+ controller.close();
286
+ },
287
+ });
288
+
289
+ const protocolStream = QwenAIStream(mockOpenAIStream);
290
+
291
+ const decoder = new TextDecoder();
292
+ const chunks = [];
293
+
294
+ // @ts-ignore
295
+ for await (const chunk of protocolStream) {
296
+ chunks.push(decoder.decode(chunk, { stream: true }));
297
+ }
298
+
299
+ expect(chunks).toEqual([
300
+ 'id: 4\n',
301
+ 'event: data\n',
302
+ `data: {"delta":{"custom_field":"custom_value"},"id":"4","index":0}\n\n`,
303
+ ]);
304
+ });
305
+
306
+ it('should handle tool calls without index and type', async () => {
307
+ const mockOpenAIStream = new ReadableStream({
308
+ start(controller) {
309
+ controller.enqueue({
310
+ choices: [
311
+ {
312
+ delta: {
313
+ tool_calls: [
314
+ {
315
+ function: { name: 'tool1', arguments: '{}' },
316
+ id: 'call_1',
317
+ },
318
+ {
319
+ function: { name: 'tool2', arguments: '{}' },
320
+ id: 'call_2',
321
+ },
322
+ ],
323
+ },
324
+ index: 0,
325
+ },
326
+ ],
327
+ id: '5',
328
+ });
329
+
330
+ controller.close();
331
+ },
332
+ });
333
+
334
+ const protocolStream = QwenAIStream(mockOpenAIStream);
335
+
336
+ const decoder = new TextDecoder();
337
+ const chunks = [];
338
+
339
+ // @ts-ignore
340
+ for await (const chunk of protocolStream) {
341
+ chunks.push(decoder.decode(chunk, { stream: true }));
342
+ }
343
+
344
+ expect(chunks).toEqual([
345
+ 'id: 5\n',
346
+ 'event: tool_calls\n',
347
+ `data: [{"function":{"name":"tool1","arguments":"{}"},"id":"call_1","index":0,"type":"function"},{"function":{"name":"tool2","arguments":"{}"},"id":"call_2","index":1,"type":"function"}]\n\n`,
348
+ ]);
349
+ });
350
+ });
@@ -0,0 +1,94 @@
1
+ import { readableFromAsyncIterable } from 'ai';
2
+ import { ChatCompletionContentPartText } from 'ai/prompts';
3
+ import OpenAI from 'openai';
4
+ import { ChatCompletionContentPart } from 'openai/resources/index.mjs';
5
+ import type { Stream } from 'openai/streaming';
6
+
7
+ import { ChatStreamCallbacks } from '../../types';
8
+ import {
9
+ StreamProtocolChunk,
10
+ StreamProtocolToolCallChunk,
11
+ StreamToolCallChunkData,
12
+ chatStreamable,
13
+ createCallbacksTransformer,
14
+ createSSEProtocolTransformer,
15
+ generateToolCallId,
16
+ } from './protocol';
17
+
18
+ export const transformQwenStream = (chunk: OpenAI.ChatCompletionChunk): StreamProtocolChunk => {
19
+ const item = chunk.choices[0];
20
+
21
+ if (!item) {
22
+ return { data: chunk, id: chunk.id, type: 'data' };
23
+ }
24
+
25
+ if (Array.isArray(item.delta?.content)) {
26
+ const part = item.delta.content[0];
27
+ const process = (part: ChatCompletionContentPart): ChatCompletionContentPartText => {
28
+ let [key, value] = Object.entries(part)[0];
29
+ if (key === 'image') {
30
+ return {
31
+ text: `![image](${value})`,
32
+ type: 'text',
33
+ };
34
+ }
35
+ return {
36
+ text: value,
37
+ type: 'text',
38
+ };
39
+ };
40
+
41
+ const data = process(part);
42
+
43
+ return {
44
+ data: data.text,
45
+ id: chunk.id,
46
+ type: 'text',
47
+ };
48
+ }
49
+
50
+ if (item.delta?.tool_calls) {
51
+ return {
52
+ data: item.delta.tool_calls.map(
53
+ (value, index): StreamToolCallChunkData => ({
54
+ function: value.function,
55
+ id: value.id || generateToolCallId(index, value.function?.name),
56
+ index: typeof value.index !== 'undefined' ? value.index : index,
57
+ type: value.type || 'function',
58
+ }),
59
+ ),
60
+ id: chunk.id,
61
+ type: 'tool_calls',
62
+ } as StreamProtocolToolCallChunk;
63
+ }
64
+
65
+ if (item.finish_reason) {
66
+ return { data: item.finish_reason, id: chunk.id, type: 'stop' };
67
+ }
68
+
69
+ if (typeof item.delta?.content === 'string') {
70
+ return { data: item.delta.content, id: chunk.id, type: 'text' };
71
+ }
72
+
73
+ if (item.delta?.content === null) {
74
+ return { data: item.delta, id: chunk.id, type: 'data' };
75
+ }
76
+
77
+ return {
78
+ data: { delta: item.delta, id: chunk.id, index: item.index },
79
+ id: chunk.id,
80
+ type: 'data',
81
+ };
82
+ };
83
+
84
+ export const QwenAIStream = (
85
+ stream: Stream<OpenAI.ChatCompletionChunk> | ReadableStream,
86
+ callbacks?: ChatStreamCallbacks,
87
+ ) => {
88
+ const readableStream =
89
+ stream instanceof ReadableStream ? stream : readableFromAsyncIterable(chatStreamable(stream));
90
+
91
+ return readableStream
92
+ .pipeThrough(createSSEProtocolTransformer(transformQwenStream))
93
+ .pipeThrough(createCallbacksTransformer(callbacks));
94
+ };
package/vitest.config.ts CHANGED
@@ -24,9 +24,6 @@ export default defineConfig({
24
24
  reporter: ['text', 'json', 'lcov', 'text-summary'],
25
25
  reportsDirectory: './coverage/app',
26
26
  },
27
- deps: {
28
- inline: ['vitest-canvas-mock'],
29
- },
30
27
  environment: 'happy-dom',
31
28
  exclude: [
32
29
  '**/node_modules/**',
@@ -36,6 +33,11 @@ export default defineConfig({
36
33
  'src/server/modules/**/**',
37
34
  ],
38
35
  globals: true,
36
+ server: {
37
+ deps: {
38
+ inline: ['vitest-canvas-mock'],
39
+ },
40
+ },
39
41
  setupFiles: './tests/setup.ts',
40
42
  },
41
43
  });