@lobehub/chat 1.102.4 → 1.103.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 +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/src/config/aiModels/qwen.ts +22 -2
- package/src/libs/model-runtime/qwen/createImage.test.ts +613 -0
- package/src/libs/model-runtime/qwen/createImage.ts +218 -0
- package/src/libs/model-runtime/qwen/index.ts +2 -0
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +19 -1
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,31 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
## [Version 1.103.0](https://github.com/lobehub/lobe-chat/compare/v1.102.4...v1.103.0)
|
6
|
+
|
7
|
+
<sup>Released on **2025-07-22**</sup>
|
8
|
+
|
9
|
+
#### ✨ Features
|
10
|
+
|
11
|
+
- **misc**: Add Qwen image generation capabilities.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### What's improved
|
19
|
+
|
20
|
+
- **misc**: Add Qwen image generation capabilities, closes [#8534](https://github.com/lobehub/lobe-chat/issues/8534) ([7e8e5ef](https://github.com/lobehub/lobe-chat/commit/7e8e5ef))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.102.4](https://github.com/lobehub/lobe-chat/compare/v1.102.3...v1.102.4)
|
6
31
|
|
7
32
|
<sup>Released on **2025-07-22**</sup>
|
package/changelog/v1.json
CHANGED
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.
|
3
|
+
"version": "1.103.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",
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import { AIChatModelCard } from '@/types/aiModel';
|
1
|
+
import { AIChatModelCard, AIImageModelCard } from '@/types/aiModel';
|
2
2
|
|
3
3
|
// https://help.aliyun.com/zh/model-studio/models?spm=a2c4g.11186623
|
4
4
|
|
@@ -956,6 +956,26 @@ const qwenChatModels: AIChatModelCard[] = [
|
|
956
956
|
},
|
957
957
|
];
|
958
958
|
|
959
|
-
|
959
|
+
const qwenImageModels: AIImageModelCard[] = [
|
960
|
+
{
|
961
|
+
description: '阿里云通义旗下的文生图模型',
|
962
|
+
displayName: 'Wanxiang T2I Turbo',
|
963
|
+
enabled: true,
|
964
|
+
id: 'wanx2.1-t2i-turbo',
|
965
|
+
organization: 'Qwen',
|
966
|
+
parameters: {
|
967
|
+
height: { default: 1024, max: 1440, min: 512, step: 1 },
|
968
|
+
prompt: {
|
969
|
+
default: '',
|
970
|
+
},
|
971
|
+
seed: { default: null },
|
972
|
+
width: { default: 1024, max: 1440, min: 512, step: 1 },
|
973
|
+
},
|
974
|
+
releasedAt: '2025-01-08',
|
975
|
+
type: 'image',
|
976
|
+
},
|
977
|
+
];
|
978
|
+
|
979
|
+
export const allModels = [...qwenChatModels, ...qwenImageModels];
|
960
980
|
|
961
981
|
export default allModels;
|
@@ -0,0 +1,613 @@
|
|
1
|
+
// @vitest-environment edge-runtime
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { CreateImagePayload } from '../types/image';
|
5
|
+
import { CreateImageOptions } from '../utils/openaiCompatibleFactory';
|
6
|
+
import { createQwenImage } from './createImage';
|
7
|
+
|
8
|
+
// Mock the console.error to avoid polluting test output
|
9
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
10
|
+
|
11
|
+
const mockOptions: CreateImageOptions = {
|
12
|
+
apiKey: 'test-api-key',
|
13
|
+
provider: 'qwen',
|
14
|
+
};
|
15
|
+
|
16
|
+
beforeEach(() => {
|
17
|
+
// Reset all mocks before each test
|
18
|
+
vi.clearAllMocks();
|
19
|
+
});
|
20
|
+
|
21
|
+
afterEach(() => {
|
22
|
+
vi.clearAllMocks();
|
23
|
+
});
|
24
|
+
|
25
|
+
describe('createQwenImage', () => {
|
26
|
+
describe('Success scenarios', () => {
|
27
|
+
it('should successfully generate image with immediate success', async () => {
|
28
|
+
const mockTaskId = 'task-123456';
|
29
|
+
const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/test-image.jpg';
|
30
|
+
|
31
|
+
// Mock fetch for task creation and immediate success
|
32
|
+
global.fetch = vi
|
33
|
+
.fn()
|
34
|
+
.mockResolvedValueOnce({
|
35
|
+
ok: true,
|
36
|
+
json: async () => ({
|
37
|
+
output: { task_id: mockTaskId },
|
38
|
+
request_id: 'req-123',
|
39
|
+
}),
|
40
|
+
})
|
41
|
+
.mockResolvedValueOnce({
|
42
|
+
ok: true,
|
43
|
+
json: async () => ({
|
44
|
+
output: {
|
45
|
+
task_id: mockTaskId,
|
46
|
+
task_status: 'SUCCEEDED',
|
47
|
+
results: [{ url: mockImageUrl }],
|
48
|
+
},
|
49
|
+
request_id: 'req-124',
|
50
|
+
}),
|
51
|
+
});
|
52
|
+
|
53
|
+
const payload: CreateImagePayload = {
|
54
|
+
model: 'wanx2.1-t2i-turbo',
|
55
|
+
params: {
|
56
|
+
prompt: 'A beautiful sunset over the mountains',
|
57
|
+
},
|
58
|
+
};
|
59
|
+
|
60
|
+
const result = await createQwenImage(payload, mockOptions);
|
61
|
+
|
62
|
+
// Verify task creation request
|
63
|
+
expect(fetch).toHaveBeenCalledWith(
|
64
|
+
'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis',
|
65
|
+
{
|
66
|
+
method: 'POST',
|
67
|
+
headers: {
|
68
|
+
'Authorization': 'Bearer test-api-key',
|
69
|
+
'Content-Type': 'application/json',
|
70
|
+
'X-DashScope-Async': 'enable',
|
71
|
+
},
|
72
|
+
body: JSON.stringify({
|
73
|
+
input: {
|
74
|
+
prompt: 'A beautiful sunset over the mountains',
|
75
|
+
},
|
76
|
+
model: 'wanx2.1-t2i-turbo',
|
77
|
+
parameters: {
|
78
|
+
n: 1,
|
79
|
+
size: '1024*1024',
|
80
|
+
},
|
81
|
+
}),
|
82
|
+
},
|
83
|
+
);
|
84
|
+
|
85
|
+
// Verify status query request
|
86
|
+
expect(fetch).toHaveBeenCalledWith(
|
87
|
+
`https://dashscope.aliyuncs.com/api/v1/tasks/${mockTaskId}`,
|
88
|
+
{
|
89
|
+
headers: {
|
90
|
+
Authorization: 'Bearer test-api-key',
|
91
|
+
},
|
92
|
+
},
|
93
|
+
);
|
94
|
+
|
95
|
+
expect(result).toEqual({
|
96
|
+
imageUrl: mockImageUrl,
|
97
|
+
});
|
98
|
+
});
|
99
|
+
|
100
|
+
it('should handle task that needs polling before success', async () => {
|
101
|
+
const mockTaskId = 'task-polling';
|
102
|
+
const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/test-image-3.jpg';
|
103
|
+
|
104
|
+
global.fetch = vi
|
105
|
+
.fn()
|
106
|
+
.mockResolvedValueOnce({
|
107
|
+
ok: true,
|
108
|
+
json: async () => ({
|
109
|
+
output: { task_id: mockTaskId },
|
110
|
+
request_id: 'req-127',
|
111
|
+
}),
|
112
|
+
})
|
113
|
+
// First status query - still running
|
114
|
+
.mockResolvedValueOnce({
|
115
|
+
ok: true,
|
116
|
+
json: async () => ({
|
117
|
+
output: {
|
118
|
+
task_id: mockTaskId,
|
119
|
+
task_status: 'RUNNING',
|
120
|
+
},
|
121
|
+
request_id: 'req-128',
|
122
|
+
}),
|
123
|
+
})
|
124
|
+
// Second status query - succeeded
|
125
|
+
.mockResolvedValueOnce({
|
126
|
+
ok: true,
|
127
|
+
json: async () => ({
|
128
|
+
output: {
|
129
|
+
task_id: mockTaskId,
|
130
|
+
task_status: 'SUCCEEDED',
|
131
|
+
results: [{ url: mockImageUrl }],
|
132
|
+
},
|
133
|
+
request_id: 'req-129',
|
134
|
+
}),
|
135
|
+
});
|
136
|
+
|
137
|
+
const payload: CreateImagePayload = {
|
138
|
+
model: 'wanx2.1-t2i-turbo',
|
139
|
+
params: {
|
140
|
+
prompt: 'Abstract digital art',
|
141
|
+
},
|
142
|
+
};
|
143
|
+
|
144
|
+
const result = await createQwenImage(payload, mockOptions);
|
145
|
+
|
146
|
+
// Should have made 3 fetch calls: 1 create + 2 status checks
|
147
|
+
expect(fetch).toHaveBeenCalledTimes(3);
|
148
|
+
expect(result).toEqual({
|
149
|
+
imageUrl: mockImageUrl,
|
150
|
+
});
|
151
|
+
});
|
152
|
+
|
153
|
+
it('should handle custom image dimensions', async () => {
|
154
|
+
const mockTaskId = 'task-custom-size';
|
155
|
+
const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/custom-size.jpg';
|
156
|
+
|
157
|
+
global.fetch = vi
|
158
|
+
.fn()
|
159
|
+
.mockResolvedValueOnce({
|
160
|
+
ok: true,
|
161
|
+
json: async () => ({
|
162
|
+
output: { task_id: mockTaskId },
|
163
|
+
request_id: 'req-140',
|
164
|
+
}),
|
165
|
+
})
|
166
|
+
.mockResolvedValueOnce({
|
167
|
+
ok: true,
|
168
|
+
json: async () => ({
|
169
|
+
output: {
|
170
|
+
task_id: mockTaskId,
|
171
|
+
task_status: 'SUCCEEDED',
|
172
|
+
results: [{ url: mockImageUrl }],
|
173
|
+
},
|
174
|
+
request_id: 'req-141',
|
175
|
+
}),
|
176
|
+
});
|
177
|
+
|
178
|
+
const payload: CreateImagePayload = {
|
179
|
+
model: 'wanx2.1-t2i-turbo',
|
180
|
+
params: {
|
181
|
+
prompt: 'Custom size image',
|
182
|
+
width: 512,
|
183
|
+
height: 768,
|
184
|
+
},
|
185
|
+
};
|
186
|
+
|
187
|
+
await createQwenImage(payload, mockOptions);
|
188
|
+
|
189
|
+
expect(fetch).toHaveBeenCalledWith(
|
190
|
+
'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis',
|
191
|
+
expect.objectContaining({
|
192
|
+
body: JSON.stringify({
|
193
|
+
input: {
|
194
|
+
prompt: 'Custom size image',
|
195
|
+
},
|
196
|
+
model: 'wanx2.1-t2i-turbo',
|
197
|
+
parameters: {
|
198
|
+
n: 1,
|
199
|
+
size: '512*768',
|
200
|
+
},
|
201
|
+
}),
|
202
|
+
}),
|
203
|
+
);
|
204
|
+
});
|
205
|
+
|
206
|
+
it('should handle long running tasks with retries', async () => {
|
207
|
+
const mockTaskId = 'task-long-running';
|
208
|
+
|
209
|
+
// Mock status query that returns RUNNING a few times then succeeds
|
210
|
+
let statusCallCount = 0;
|
211
|
+
const statusQueryMock = vi.fn().mockImplementation(() => {
|
212
|
+
statusCallCount++;
|
213
|
+
if (statusCallCount <= 3) {
|
214
|
+
return Promise.resolve({
|
215
|
+
ok: true,
|
216
|
+
json: async () => ({
|
217
|
+
output: {
|
218
|
+
task_id: mockTaskId,
|
219
|
+
task_status: 'RUNNING',
|
220
|
+
},
|
221
|
+
request_id: `req-${133 + statusCallCount}`,
|
222
|
+
}),
|
223
|
+
});
|
224
|
+
} else {
|
225
|
+
return Promise.resolve({
|
226
|
+
ok: true,
|
227
|
+
json: async () => ({
|
228
|
+
output: {
|
229
|
+
task_id: mockTaskId,
|
230
|
+
task_status: 'SUCCEEDED',
|
231
|
+
results: [{ url: 'https://example.com/final-image.jpg' }],
|
232
|
+
},
|
233
|
+
request_id: 'req-137',
|
234
|
+
}),
|
235
|
+
});
|
236
|
+
}
|
237
|
+
});
|
238
|
+
|
239
|
+
global.fetch = vi
|
240
|
+
.fn()
|
241
|
+
.mockResolvedValueOnce({
|
242
|
+
ok: true,
|
243
|
+
json: async () => ({
|
244
|
+
output: { task_id: mockTaskId },
|
245
|
+
request_id: 'req-132',
|
246
|
+
}),
|
247
|
+
})
|
248
|
+
.mockImplementation(statusQueryMock);
|
249
|
+
|
250
|
+
const payload: CreateImagePayload = {
|
251
|
+
model: 'wanx2.1-t2i-turbo',
|
252
|
+
params: {
|
253
|
+
prompt: 'Long running task',
|
254
|
+
},
|
255
|
+
};
|
256
|
+
|
257
|
+
// Mock setTimeout to make test run faster but still allow controlled execution
|
258
|
+
vi.spyOn(global, 'setTimeout').mockImplementation((callback: any) => {
|
259
|
+
// Use setImmediate to avoid recursion issues
|
260
|
+
setImmediate(callback);
|
261
|
+
return 1 as any;
|
262
|
+
});
|
263
|
+
|
264
|
+
const result = await createQwenImage(payload, mockOptions);
|
265
|
+
|
266
|
+
expect(result).toEqual({
|
267
|
+
imageUrl: 'https://example.com/final-image.jpg',
|
268
|
+
});
|
269
|
+
|
270
|
+
// Should have made 1 create call + 4 status calls (3 RUNNING + 1 SUCCEEDED)
|
271
|
+
expect(fetch).toHaveBeenCalledTimes(5);
|
272
|
+
});
|
273
|
+
|
274
|
+
it('should handle seed value of 0 correctly', async () => {
|
275
|
+
const mockTaskId = 'task-with-zero-seed';
|
276
|
+
const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/seed-zero.jpg';
|
277
|
+
|
278
|
+
global.fetch = vi
|
279
|
+
.fn()
|
280
|
+
.mockResolvedValueOnce({
|
281
|
+
ok: true,
|
282
|
+
json: async () => ({
|
283
|
+
output: { task_id: mockTaskId },
|
284
|
+
request_id: 'req-seed-0',
|
285
|
+
}),
|
286
|
+
})
|
287
|
+
.mockResolvedValueOnce({
|
288
|
+
ok: true,
|
289
|
+
json: async () => ({
|
290
|
+
output: {
|
291
|
+
task_id: mockTaskId,
|
292
|
+
task_status: 'SUCCEEDED',
|
293
|
+
results: [{ url: mockImageUrl }],
|
294
|
+
},
|
295
|
+
request_id: 'req-seed-0-status',
|
296
|
+
}),
|
297
|
+
});
|
298
|
+
|
299
|
+
const payload: CreateImagePayload = {
|
300
|
+
model: 'wanx2.1-t2i-turbo',
|
301
|
+
params: {
|
302
|
+
prompt: 'Image with seed 0',
|
303
|
+
seed: 0,
|
304
|
+
},
|
305
|
+
};
|
306
|
+
|
307
|
+
await createQwenImage(payload, mockOptions);
|
308
|
+
|
309
|
+
// Verify that seed: 0 is included in the request
|
310
|
+
expect(fetch).toHaveBeenCalledWith(
|
311
|
+
'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis',
|
312
|
+
expect.objectContaining({
|
313
|
+
body: JSON.stringify({
|
314
|
+
input: {
|
315
|
+
prompt: 'Image with seed 0',
|
316
|
+
},
|
317
|
+
model: 'wanx2.1-t2i-turbo',
|
318
|
+
parameters: {
|
319
|
+
n: 1,
|
320
|
+
seed: 0,
|
321
|
+
size: '1024*1024',
|
322
|
+
},
|
323
|
+
}),
|
324
|
+
}),
|
325
|
+
);
|
326
|
+
});
|
327
|
+
});
|
328
|
+
|
329
|
+
describe('Error scenarios', () => {
|
330
|
+
it('should handle unsupported model', async () => {
|
331
|
+
const payload: CreateImagePayload = {
|
332
|
+
model: 'unsupported-model',
|
333
|
+
params: {
|
334
|
+
prompt: 'Test prompt',
|
335
|
+
},
|
336
|
+
};
|
337
|
+
|
338
|
+
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
|
339
|
+
expect.objectContaining({
|
340
|
+
errorType: 'ProviderBizError',
|
341
|
+
provider: 'qwen',
|
342
|
+
}),
|
343
|
+
);
|
344
|
+
|
345
|
+
// Should not make any fetch calls
|
346
|
+
expect(fetch).not.toHaveBeenCalled();
|
347
|
+
});
|
348
|
+
|
349
|
+
it('should handle task creation failure', async () => {
|
350
|
+
global.fetch = vi.fn().mockResolvedValueOnce({
|
351
|
+
ok: false,
|
352
|
+
statusText: 'Bad Request',
|
353
|
+
json: async () => ({
|
354
|
+
message: 'Invalid model name',
|
355
|
+
}),
|
356
|
+
});
|
357
|
+
|
358
|
+
const payload: CreateImagePayload = {
|
359
|
+
model: 'invalid-model',
|
360
|
+
params: {
|
361
|
+
prompt: 'Test prompt',
|
362
|
+
},
|
363
|
+
};
|
364
|
+
|
365
|
+
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
|
366
|
+
expect.objectContaining({
|
367
|
+
errorType: 'ProviderBizError',
|
368
|
+
provider: 'qwen',
|
369
|
+
}),
|
370
|
+
);
|
371
|
+
});
|
372
|
+
|
373
|
+
it('should handle non-JSON error responses', async () => {
|
374
|
+
global.fetch = vi.fn().mockResolvedValueOnce({
|
375
|
+
ok: false,
|
376
|
+
status: 500,
|
377
|
+
statusText: 'Internal Server Error',
|
378
|
+
json: async () => {
|
379
|
+
throw new Error('Failed to parse JSON');
|
380
|
+
},
|
381
|
+
});
|
382
|
+
|
383
|
+
const payload: CreateImagePayload = {
|
384
|
+
model: 'wanx2.1-t2i-turbo',
|
385
|
+
params: {
|
386
|
+
prompt: 'Test prompt',
|
387
|
+
},
|
388
|
+
};
|
389
|
+
|
390
|
+
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
|
391
|
+
expect.objectContaining({
|
392
|
+
errorType: 'ProviderBizError',
|
393
|
+
provider: 'qwen',
|
394
|
+
}),
|
395
|
+
);
|
396
|
+
});
|
397
|
+
|
398
|
+
it('should handle task failure status', async () => {
|
399
|
+
const mockTaskId = 'task-failed';
|
400
|
+
|
401
|
+
global.fetch = vi
|
402
|
+
.fn()
|
403
|
+
.mockResolvedValueOnce({
|
404
|
+
ok: true,
|
405
|
+
json: async () => ({
|
406
|
+
output: { task_id: mockTaskId },
|
407
|
+
request_id: 'req-130',
|
408
|
+
}),
|
409
|
+
})
|
410
|
+
.mockResolvedValueOnce({
|
411
|
+
ok: true,
|
412
|
+
json: async () => ({
|
413
|
+
output: {
|
414
|
+
task_id: mockTaskId,
|
415
|
+
task_status: 'FAILED',
|
416
|
+
error_message: 'Content policy violation',
|
417
|
+
},
|
418
|
+
request_id: 'req-131',
|
419
|
+
}),
|
420
|
+
});
|
421
|
+
|
422
|
+
const payload: CreateImagePayload = {
|
423
|
+
model: 'wanx2.1-t2i-turbo',
|
424
|
+
params: {
|
425
|
+
prompt: 'Invalid prompt that causes failure',
|
426
|
+
},
|
427
|
+
};
|
428
|
+
|
429
|
+
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
|
430
|
+
expect.objectContaining({
|
431
|
+
errorType: 'ProviderBizError',
|
432
|
+
provider: 'qwen',
|
433
|
+
}),
|
434
|
+
);
|
435
|
+
});
|
436
|
+
|
437
|
+
it('should handle task succeeded but no results', async () => {
|
438
|
+
const mockTaskId = 'task-no-results';
|
439
|
+
|
440
|
+
global.fetch = vi
|
441
|
+
.fn()
|
442
|
+
.mockResolvedValueOnce({
|
443
|
+
ok: true,
|
444
|
+
json: async () => ({
|
445
|
+
output: { task_id: mockTaskId },
|
446
|
+
request_id: 'req-134',
|
447
|
+
}),
|
448
|
+
})
|
449
|
+
.mockResolvedValueOnce({
|
450
|
+
ok: true,
|
451
|
+
json: async () => ({
|
452
|
+
output: {
|
453
|
+
task_id: mockTaskId,
|
454
|
+
task_status: 'SUCCEEDED',
|
455
|
+
results: [], // Empty results array
|
456
|
+
},
|
457
|
+
request_id: 'req-135',
|
458
|
+
}),
|
459
|
+
});
|
460
|
+
|
461
|
+
const payload: CreateImagePayload = {
|
462
|
+
model: 'wanx2.1-t2i-turbo',
|
463
|
+
params: {
|
464
|
+
prompt: 'Test prompt',
|
465
|
+
},
|
466
|
+
};
|
467
|
+
|
468
|
+
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
|
469
|
+
expect.objectContaining({
|
470
|
+
errorType: 'ProviderBizError',
|
471
|
+
provider: 'qwen',
|
472
|
+
}),
|
473
|
+
);
|
474
|
+
});
|
475
|
+
|
476
|
+
it('should handle status query failure', async () => {
|
477
|
+
const mockTaskId = 'task-query-fail';
|
478
|
+
|
479
|
+
global.fetch = vi
|
480
|
+
.fn()
|
481
|
+
.mockResolvedValueOnce({
|
482
|
+
ok: true,
|
483
|
+
json: async () => ({
|
484
|
+
output: { task_id: mockTaskId },
|
485
|
+
request_id: 'req-136',
|
486
|
+
}),
|
487
|
+
})
|
488
|
+
.mockResolvedValueOnce({
|
489
|
+
ok: false,
|
490
|
+
statusText: 'Unauthorized',
|
491
|
+
json: async () => ({
|
492
|
+
message: 'Invalid API key',
|
493
|
+
}),
|
494
|
+
});
|
495
|
+
|
496
|
+
const payload: CreateImagePayload = {
|
497
|
+
model: 'wanx2.1-t2i-turbo',
|
498
|
+
params: {
|
499
|
+
prompt: 'Test prompt',
|
500
|
+
},
|
501
|
+
};
|
502
|
+
|
503
|
+
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
|
504
|
+
expect.objectContaining({
|
505
|
+
errorType: 'ProviderBizError',
|
506
|
+
provider: 'qwen',
|
507
|
+
}),
|
508
|
+
);
|
509
|
+
});
|
510
|
+
|
511
|
+
it('should handle transient status query failures and retry', async () => {
|
512
|
+
const mockTaskId = 'task-transient-failure';
|
513
|
+
const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/retry-success.jpg';
|
514
|
+
|
515
|
+
let statusQueryCount = 0;
|
516
|
+
const statusQueryMock = vi.fn().mockImplementation(() => {
|
517
|
+
statusQueryCount++;
|
518
|
+
if (statusQueryCount === 1 || statusQueryCount === 2) {
|
519
|
+
// First two calls fail
|
520
|
+
return Promise.reject(new Error('Network timeout'));
|
521
|
+
} else {
|
522
|
+
// Third call succeeds
|
523
|
+
return Promise.resolve({
|
524
|
+
ok: true,
|
525
|
+
json: async () => ({
|
526
|
+
output: {
|
527
|
+
task_id: mockTaskId,
|
528
|
+
task_status: 'SUCCEEDED',
|
529
|
+
results: [{ url: mockImageUrl }],
|
530
|
+
},
|
531
|
+
request_id: 'req-retry-success',
|
532
|
+
}),
|
533
|
+
});
|
534
|
+
}
|
535
|
+
});
|
536
|
+
|
537
|
+
global.fetch = vi
|
538
|
+
.fn()
|
539
|
+
.mockResolvedValueOnce({
|
540
|
+
ok: true,
|
541
|
+
json: async () => ({
|
542
|
+
output: { task_id: mockTaskId },
|
543
|
+
request_id: 'req-transient',
|
544
|
+
}),
|
545
|
+
})
|
546
|
+
.mockImplementation(statusQueryMock);
|
547
|
+
|
548
|
+
const payload: CreateImagePayload = {
|
549
|
+
model: 'wanx2.1-t2i-turbo',
|
550
|
+
params: {
|
551
|
+
prompt: 'Test transient failure',
|
552
|
+
},
|
553
|
+
};
|
554
|
+
|
555
|
+
// Mock setTimeout to make test run faster
|
556
|
+
vi.spyOn(global, 'setTimeout').mockImplementation((callback: any) => {
|
557
|
+
setImmediate(callback);
|
558
|
+
return 1 as any;
|
559
|
+
});
|
560
|
+
|
561
|
+
const result = await createQwenImage(payload, mockOptions);
|
562
|
+
|
563
|
+
expect(result).toEqual({
|
564
|
+
imageUrl: mockImageUrl,
|
565
|
+
});
|
566
|
+
|
567
|
+
// Verify the mock was called the expected number of times
|
568
|
+
expect(statusQueryMock).toHaveBeenCalledTimes(3); // 2 failures + 1 success
|
569
|
+
|
570
|
+
// Should have made 1 create call + 3 status calls (2 failed + 1 succeeded)
|
571
|
+
expect(fetch).toHaveBeenCalledTimes(4);
|
572
|
+
});
|
573
|
+
|
574
|
+
it('should fail after consecutive query failures', async () => {
|
575
|
+
const mockTaskId = 'task-consecutive-failures';
|
576
|
+
|
577
|
+
global.fetch = vi
|
578
|
+
.fn()
|
579
|
+
.mockResolvedValueOnce({
|
580
|
+
ok: true,
|
581
|
+
json: async () => ({
|
582
|
+
output: { task_id: mockTaskId },
|
583
|
+
request_id: 'req-will-fail',
|
584
|
+
}),
|
585
|
+
})
|
586
|
+
// All subsequent calls fail
|
587
|
+
.mockRejectedValue(new Error('Persistent network error'));
|
588
|
+
|
589
|
+
const payload: CreateImagePayload = {
|
590
|
+
model: 'wanx2.1-t2i-turbo',
|
591
|
+
params: {
|
592
|
+
prompt: 'Test persistent failure',
|
593
|
+
},
|
594
|
+
};
|
595
|
+
|
596
|
+
// Mock setTimeout to make test run faster
|
597
|
+
vi.spyOn(global, 'setTimeout').mockImplementation((callback: any) => {
|
598
|
+
setImmediate(callback);
|
599
|
+
return 1 as any;
|
600
|
+
});
|
601
|
+
|
602
|
+
await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
|
603
|
+
expect.objectContaining({
|
604
|
+
errorType: 'ProviderBizError',
|
605
|
+
provider: 'qwen',
|
606
|
+
}),
|
607
|
+
);
|
608
|
+
|
609
|
+
// Should have made 1 create call + 3 failed status calls (maxConsecutiveFailures)
|
610
|
+
expect(fetch).toHaveBeenCalledTimes(4);
|
611
|
+
});
|
612
|
+
});
|
613
|
+
});
|
@@ -0,0 +1,218 @@
|
|
1
|
+
import createDebug from 'debug';
|
2
|
+
|
3
|
+
import { CreateImagePayload, CreateImageResponse } from '../types/image';
|
4
|
+
import { AgentRuntimeError } from '../utils/createError';
|
5
|
+
import { CreateImageOptions } from '../utils/openaiCompatibleFactory';
|
6
|
+
|
7
|
+
const log = createDebug('lobe-image:qwen');
|
8
|
+
|
9
|
+
interface QwenImageTaskResponse {
|
10
|
+
output: {
|
11
|
+
error_message?: string;
|
12
|
+
results?: Array<{
|
13
|
+
url: string;
|
14
|
+
}>;
|
15
|
+
task_id: string;
|
16
|
+
task_status: 'PENDING' | 'RUNNING' | 'SUCCEEDED' | 'FAILED';
|
17
|
+
};
|
18
|
+
request_id: string;
|
19
|
+
}
|
20
|
+
|
21
|
+
/**
|
22
|
+
* Create an image generation task with Qwen API
|
23
|
+
*/
|
24
|
+
async function createImageTask(payload: CreateImagePayload, apiKey: string): Promise<string> {
|
25
|
+
const { model, params } = payload;
|
26
|
+
// I can only say that the design of Alibaba Cloud's API is really bad; each model has a different endpoint path.
|
27
|
+
const modelEndpointMap: Record<string, string> = {
|
28
|
+
'wanx2.1-t2i-turbo':
|
29
|
+
'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis',
|
30
|
+
};
|
31
|
+
|
32
|
+
const endpoint = modelEndpointMap[model];
|
33
|
+
if (!endpoint) {
|
34
|
+
throw new Error(`Unsupported model: ${model}`);
|
35
|
+
}
|
36
|
+
log('Creating image task with model: %s, endpoint: %s', model, endpoint);
|
37
|
+
|
38
|
+
const response = await fetch(endpoint, {
|
39
|
+
body: JSON.stringify({
|
40
|
+
input: {
|
41
|
+
prompt: params.prompt,
|
42
|
+
// negativePrompt is not part of standard parameters
|
43
|
+
// but can be supported by extending the params type if needed
|
44
|
+
},
|
45
|
+
model,
|
46
|
+
parameters: {
|
47
|
+
n: 1,
|
48
|
+
...(params.seed !== undefined ? { seed: params.seed } : {}),
|
49
|
+
...(params.width && params.height
|
50
|
+
? { size: `${params.width}*${params.height}` }
|
51
|
+
: { size: '1024*1024' }),
|
52
|
+
},
|
53
|
+
}),
|
54
|
+
headers: {
|
55
|
+
'Authorization': `Bearer ${apiKey}`,
|
56
|
+
'Content-Type': 'application/json',
|
57
|
+
'X-DashScope-Async': 'enable',
|
58
|
+
},
|
59
|
+
method: 'POST',
|
60
|
+
});
|
61
|
+
|
62
|
+
if (!response.ok) {
|
63
|
+
let errorData;
|
64
|
+
try {
|
65
|
+
errorData = await response.json();
|
66
|
+
} catch {
|
67
|
+
// Failed to parse JSON error response
|
68
|
+
}
|
69
|
+
throw new Error(
|
70
|
+
`Failed to create image task (${response.status}): ${errorData?.message || response.statusText}`,
|
71
|
+
);
|
72
|
+
}
|
73
|
+
|
74
|
+
const data: QwenImageTaskResponse = await response.json();
|
75
|
+
log('Task created with ID: %s', data.output.task_id);
|
76
|
+
|
77
|
+
return data.output.task_id;
|
78
|
+
}
|
79
|
+
|
80
|
+
/**
|
81
|
+
* Query the status of an image generation task
|
82
|
+
*/
|
83
|
+
async function queryTaskStatus(taskId: string, apiKey: string): Promise<QwenImageTaskResponse> {
|
84
|
+
const endpoint = `https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`;
|
85
|
+
|
86
|
+
log('Querying task status for: %s', taskId);
|
87
|
+
|
88
|
+
const response = await fetch(endpoint, {
|
89
|
+
headers: {
|
90
|
+
Authorization: `Bearer ${apiKey}`,
|
91
|
+
},
|
92
|
+
});
|
93
|
+
|
94
|
+
if (!response.ok) {
|
95
|
+
let errorData;
|
96
|
+
try {
|
97
|
+
errorData = await response.json();
|
98
|
+
} catch {
|
99
|
+
// Failed to parse JSON error response
|
100
|
+
}
|
101
|
+
throw new Error(
|
102
|
+
`Failed to query task status (${response.status}): ${errorData?.message || response.statusText}`,
|
103
|
+
);
|
104
|
+
}
|
105
|
+
|
106
|
+
return response.json();
|
107
|
+
}
|
108
|
+
|
109
|
+
/**
|
110
|
+
* Create image using Qwen Wanxiang API
|
111
|
+
* This implementation uses async task creation and polling
|
112
|
+
*/
|
113
|
+
export async function createQwenImage(
|
114
|
+
payload: CreateImagePayload,
|
115
|
+
options: CreateImageOptions,
|
116
|
+
): Promise<CreateImageResponse> {
|
117
|
+
const { apiKey, provider } = options;
|
118
|
+
try {
|
119
|
+
// 1. Create image generation task
|
120
|
+
const taskId = await createImageTask(payload, apiKey);
|
121
|
+
|
122
|
+
// 2. Poll task status until completion
|
123
|
+
let taskStatus: QwenImageTaskResponse | null = null;
|
124
|
+
let retries = 0;
|
125
|
+
let consecutiveFailures = 0;
|
126
|
+
const maxConsecutiveFailures = 3; // Allow up to 3 consecutive query failures
|
127
|
+
// Using Infinity for maxRetries is safe because:
|
128
|
+
// 1. Vercel runtime has execution time limits
|
129
|
+
// 2. Qwen's API will eventually return FAILED status for timed-out tasks
|
130
|
+
// 3. Our exponential backoff ensures reasonable retry intervals
|
131
|
+
const maxRetries = Infinity;
|
132
|
+
const initialRetryInterval = 500; // 500ms initial interval
|
133
|
+
const maxRetryInterval = 5000; // 5 seconds max interval
|
134
|
+
const backoffMultiplier = 1.5; // exponential backoff multiplier
|
135
|
+
|
136
|
+
while (retries < maxRetries) {
|
137
|
+
try {
|
138
|
+
taskStatus = await queryTaskStatus(taskId, apiKey);
|
139
|
+
consecutiveFailures = 0; // Reset consecutive failures on success
|
140
|
+
} catch (error) {
|
141
|
+
consecutiveFailures++;
|
142
|
+
log(
|
143
|
+
'Failed to query task status (attempt %d/%d, consecutive failures: %d/%d): %O',
|
144
|
+
retries + 1,
|
145
|
+
maxRetries,
|
146
|
+
consecutiveFailures,
|
147
|
+
maxConsecutiveFailures,
|
148
|
+
error,
|
149
|
+
);
|
150
|
+
|
151
|
+
// If we've failed too many times in a row, give up
|
152
|
+
if (consecutiveFailures >= maxConsecutiveFailures) {
|
153
|
+
throw new Error(
|
154
|
+
`Failed to query task status after ${consecutiveFailures} consecutive attempts: ${error}`,
|
155
|
+
);
|
156
|
+
}
|
157
|
+
|
158
|
+
// Wait before retrying
|
159
|
+
const currentRetryInterval = Math.min(
|
160
|
+
initialRetryInterval * Math.pow(backoffMultiplier, retries),
|
161
|
+
maxRetryInterval,
|
162
|
+
);
|
163
|
+
await new Promise((resolve) => {
|
164
|
+
setTimeout(resolve, currentRetryInterval);
|
165
|
+
});
|
166
|
+
retries++;
|
167
|
+
continue; // Skip the rest of the loop and retry
|
168
|
+
}
|
169
|
+
|
170
|
+
// At this point, taskStatus should not be null since we just got it successfully
|
171
|
+
log(
|
172
|
+
'Task %s status: %s (attempt %d/%d)',
|
173
|
+
taskId,
|
174
|
+
taskStatus!.output.task_status,
|
175
|
+
retries + 1,
|
176
|
+
maxRetries,
|
177
|
+
);
|
178
|
+
|
179
|
+
if (taskStatus!.output.task_status === 'SUCCEEDED') {
|
180
|
+
if (!taskStatus!.output.results || taskStatus!.output.results.length === 0) {
|
181
|
+
throw new Error('Task succeeded but no images generated');
|
182
|
+
}
|
183
|
+
|
184
|
+
// Return the first generated image
|
185
|
+
const imageUrl = taskStatus!.output.results[0].url;
|
186
|
+
log('Image generated successfully: %s', imageUrl);
|
187
|
+
|
188
|
+
return { imageUrl };
|
189
|
+
} else if (taskStatus!.output.task_status === 'FAILED') {
|
190
|
+
throw new Error(taskStatus!.output.error_message || 'Image generation task failed');
|
191
|
+
}
|
192
|
+
|
193
|
+
// Calculate dynamic retry interval with exponential backoff
|
194
|
+
const currentRetryInterval = Math.min(
|
195
|
+
initialRetryInterval * Math.pow(backoffMultiplier, retries),
|
196
|
+
maxRetryInterval,
|
197
|
+
);
|
198
|
+
|
199
|
+
log('Waiting %dms before next retry', currentRetryInterval);
|
200
|
+
|
201
|
+
// Wait before retrying
|
202
|
+
await new Promise((resolve) => {
|
203
|
+
setTimeout(resolve, currentRetryInterval);
|
204
|
+
});
|
205
|
+
retries++;
|
206
|
+
}
|
207
|
+
|
208
|
+
throw new Error(`Image generation timeout after ${maxRetries} attempts`);
|
209
|
+
} catch (error) {
|
210
|
+
log('Error in createQwenImage: %O', error);
|
211
|
+
|
212
|
+
throw AgentRuntimeError.createImage({
|
213
|
+
error: error as any,
|
214
|
+
errorType: 'ProviderBizError',
|
215
|
+
provider,
|
216
|
+
});
|
217
|
+
}
|
218
|
+
}
|
@@ -2,6 +2,7 @@ import { ModelProvider } from '../types';
|
|
2
2
|
import { processMultiProviderModelList } from '../utils/modelParse';
|
3
3
|
import { createOpenAICompatibleRuntime } from '../utils/openaiCompatibleFactory';
|
4
4
|
import { QwenAIStream } from '../utils/streams';
|
5
|
+
import { createQwenImage } from './createImage';
|
5
6
|
|
6
7
|
export interface QwenModelCard {
|
7
8
|
id: string;
|
@@ -73,6 +74,7 @@ export const LobeQwenAI = createOpenAICompatibleRuntime({
|
|
73
74
|
},
|
74
75
|
handleStream: QwenAIStream,
|
75
76
|
},
|
77
|
+
createImage: createQwenImage,
|
76
78
|
debug: {
|
77
79
|
chatCompletion: () => process.env.DEBUG_QWEN_CHAT_COMPLETION === '1',
|
78
80
|
},
|
@@ -52,6 +52,10 @@ export const CHAT_MODELS_BLOCK_LIST = [
|
|
52
52
|
];
|
53
53
|
|
54
54
|
type ConstructorOptions<T extends Record<string, any> = any> = ClientOptions & T;
|
55
|
+
export type CreateImageOptions = Omit<ClientOptions, 'apiKey'> & {
|
56
|
+
apiKey: string;
|
57
|
+
provider: string;
|
58
|
+
};
|
55
59
|
|
56
60
|
export interface CustomClientOptions<T extends Record<string, any> = any> {
|
57
61
|
createChatCompletionStream?: (
|
@@ -89,7 +93,10 @@ interface OpenAICompatibleFactoryOptions<T extends Record<string, any> = any> {
|
|
89
93
|
noUserId?: boolean;
|
90
94
|
};
|
91
95
|
constructorOptions?: ConstructorOptions<T>;
|
92
|
-
createImage?: (
|
96
|
+
createImage?: (
|
97
|
+
payload: CreateImagePayload,
|
98
|
+
options: CreateImageOptions,
|
99
|
+
) => Promise<CreateImageResponse>;
|
93
100
|
customClient?: CustomClientOptions<T>;
|
94
101
|
debug?: {
|
95
102
|
chatCompletion: () => boolean;
|
@@ -178,6 +185,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
|
178
185
|
models,
|
179
186
|
customClient,
|
180
187
|
responses,
|
188
|
+
createImage: customCreateImage,
|
181
189
|
}: OpenAICompatibleFactoryOptions<T>) => {
|
182
190
|
const ErrorType = {
|
183
191
|
bizError: errorType?.bizError || AgentRuntimeErrorType.ProviderBizError,
|
@@ -317,6 +325,16 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
|
317
325
|
}
|
318
326
|
|
319
327
|
async createImage(payload: CreateImagePayload) {
|
328
|
+
// If custom createImage implementation is provided, use it
|
329
|
+
if (customCreateImage) {
|
330
|
+
return customCreateImage(payload, {
|
331
|
+
...this._options,
|
332
|
+
apiKey: this._options.apiKey!,
|
333
|
+
provider,
|
334
|
+
});
|
335
|
+
}
|
336
|
+
|
337
|
+
// Otherwise use default OpenAI compatible implementation
|
320
338
|
const { model, params } = payload;
|
321
339
|
const log = createDebug(`lobe-image:model-runtime`);
|
322
340
|
|