@lobehub/chat 1.102.4 → 1.103.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,56 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.103.1](https://github.com/lobehub/lobe-chat/compare/v1.103.0...v1.103.1)
6
+
7
+ <sup>Released on **2025-07-23**</sup>
8
+
9
+ #### 💄 Styles
10
+
11
+ - **misc**: Update i18n.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Styles
19
+
20
+ - **misc**: Update i18n, closes [#8537](https://github.com/lobehub/lobe-chat/issues/8537) ([b16f19b](https://github.com/lobehub/lobe-chat/commit/b16f19b))
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
+
30
+ ## [Version 1.103.0](https://github.com/lobehub/lobe-chat/compare/v1.102.4...v1.103.0)
31
+
32
+ <sup>Released on **2025-07-22**</sup>
33
+
34
+ #### ✨ Features
35
+
36
+ - **misc**: Add Qwen image generation capabilities.
37
+
38
+ <br/>
39
+
40
+ <details>
41
+ <summary><kbd>Improvements and Fixes</kbd></summary>
42
+
43
+ #### What's improved
44
+
45
+ - **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))
46
+
47
+ </details>
48
+
49
+ <div align="right">
50
+
51
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
52
+
53
+ </div>
54
+
5
55
  ### [Version 1.102.4](https://github.com/lobehub/lobe-chat/compare/v1.102.3...v1.102.4)
6
56
 
7
57
  <sup>Released on **2025-07-22**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "improvements": [
5
+ "Update i18n."
6
+ ]
7
+ },
8
+ "date": "2025-07-23",
9
+ "version": "1.103.1"
10
+ },
11
+ {
12
+ "children": {
13
+ "features": [
14
+ "Add Qwen image generation capabilities."
15
+ ]
16
+ },
17
+ "date": "2025-07-22",
18
+ "version": "1.103.0"
19
+ },
2
20
  {
3
21
  "children": {
4
22
  "improvements": [
@@ -16,7 +16,7 @@ tags:
16
16
 
17
17
  # Desktop Application
18
18
 
19
- <Image alt={'Desktop Application'} borderless cover src={'https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96'}> />
19
+ <Image alt={'Desktop Application'} borderless cover src={'https://github.com/user-attachments/assets/a7bac8d3-ea96-4000-bb39-fadc9b610f96'} />
20
20
 
21
21
  **Peak Performance, Zero Distractions**
22
22
 
@@ -16,7 +16,7 @@ tags:
16
16
 
17
17
  # MCP Marketplace
18
18
 
19
- <Image alt={'MCP Marketplace'} borderless cover src={'https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0'}> />
19
+ <Image alt={'MCP Marketplace'} borderless cover src={'https://github.com/user-attachments/assets/bb114f9f-24c5-4000-a984-c10d187da5a0'} />
20
20
 
21
21
  **Discover, Connect, Expand**
22
22
 
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "نموذج v0-1.5-md مناسب للمهام اليومية وتوليد واجهات المستخدم (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "نموذج توليد الصور التابع لشركة علي بابا كلاود Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "نموذج التعرف على الصوت العام، يدعم التعرف على الصوت بعدة لغات، الترجمة الصوتية، والتعرف على اللغة."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "Моделът v0-1.5-md е подходящ за ежедневни задачи и генериране на потребителски интерфейс (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Модел за генериране на изображения от текст на Alibaba Cloud Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Универсален модел за разпознаване на реч, поддържащ многоезично разпознаване на реч, превод на реч и разпознаване на език."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "Das Modell v0-1.5-md ist für alltägliche Aufgaben und die Generierung von Benutzeroberflächen (UI) geeignet"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Text-zu-Bild-Modell von Aliyun Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Universelles Spracherkennungsmodell, unterstützt mehrsprachige Spracherkennung, Sprachübersetzung und Spracherkennung."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "The v0-1.5-md model is suitable for everyday tasks and user interface (UI) generation."
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Text-to-image model under Alibaba Cloud Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "A general-purpose speech recognition model supporting multilingual speech recognition, speech translation, and language identification."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "El modelo v0-1.5-md es adecuado para tareas cotidianas y generación de interfaces de usuario (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Modelo de generación de imágenes de texto a imagen de Tongyi de Alibaba Cloud"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Modelo universal de reconocimiento de voz que soporta reconocimiento de voz multilingüe, traducción de voz y detección de idioma."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "مدل v0-1.5-md برای وظایف روزمره و تولید رابط کاربری (UI) مناسب است"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "مدل تولید تصویر مبتنی بر متن زیرمجموعه‌ی علی‌بابا کلود Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "مدل شناسایی گفتار عمومی که از شناسایی گفتار چندزبانه، ترجمه گفتار و شناسایی زبان پشتیبانی می‌کند."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "Le modèle v0-1.5-md convient aux tâches quotidiennes et à la génération d'interfaces utilisateur (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Modèle de génération d'images par texte de Tongyi d'Aliyun"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Modèle universel de reconnaissance vocale, prenant en charge la reconnaissance vocale multilingue, la traduction vocale et la reconnaissance de langue."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "Il modello v0-1.5-md è adatto per compiti quotidiani e generazione di interfacce utente (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Modello di generazione di immagini basato su testo di Tongyi di Alibaba Cloud"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Modello universale di riconoscimento vocale, supporta riconoscimento vocale multilingue, traduzione vocale e identificazione della lingua."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "v0-1.5-md モデルは、日常的なタスクやユーザーインターフェース(UI)生成に適しています"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "アリババクラウドのTongyiが提供するテキストから画像生成モデル"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "汎用音声認識モデルで、多言語の音声認識、音声翻訳、言語識別をサポートします。"
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "v0-1.5-md 모델은 일상 작업 및 사용자 인터페이스(UI) 생성에 적합합니다"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "알리클라우드 통의(通义) 산하의 텍스트-이미지 생성 모델"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "범용 음성 인식 모델로, 다국어 음성 인식, 음성 번역 및 언어 인식을 지원합니다."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "Het v0-1.5-md model is geschikt voor dagelijkse taken en het genereren van gebruikersinterfaces (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Tekst-naar-beeldmodel van Alibaba Cloud Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Algemeen spraakherkenningsmodel, ondersteunt meertalige spraakherkenning, spraakvertaling en taalherkenning."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "Model v0-1.5-md jest odpowiedni do codziennych zadań i generowania interfejsu użytkownika (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Model generowania obrazów firmy Alibaba Cloud Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Uniwersalny model rozpoznawania mowy, obsługujący wielojęzyczne rozpoznawanie mowy, tłumaczenie mowy oraz identyfikację języka."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "O modelo v0-1.5-md é adequado para tarefas diárias e geração de interfaces de usuário (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Modelo de geração de imagens da Alibaba Cloud Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Modelo universal de reconhecimento de voz, suportando reconhecimento de voz multilíngue, tradução de voz e identificação de idioma."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "Модель v0-1.5-md подходит для повседневных задач и генерации пользовательского интерфейса (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Модель генерации изображений от Alibaba Cloud Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Универсальная модель распознавания речи, поддерживающая многоязычное распознавание речи, перевод речи и идентификацию языка."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "v0-1.5-md modeli, günlük görevler ve kullanıcı arayüzü (UI) oluşturma için uygundur"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Alibaba Cloud Tongyi tarafından geliştirilen metinden görsele model"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Genel amaçlı konuşma tanıma modeli olup, çok dilli konuşma tanıma, konuşma çevirisi ve dil tanıma destekler."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "Mô hình v0-1.5-md phù hợp cho các nhiệm vụ hàng ngày và tạo giao diện người dùng (UI)"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "Mô hình tạo hình ảnh từ văn bản thuộc Alibaba Cloud Tongyi"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "Mô hình nhận dạng giọng nói đa năng, hỗ trợ nhận dạng giọng nói đa ngôn ngữ, dịch giọng nói và nhận diện ngôn ngữ."
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "v0-1.5-md 模型适用于日常任务和用户界面(UI)生成"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "阿里云通义旗下的文生图模型"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "通用语音识别模型,支持多语言语音识别、语音翻译和语言识别。"
2434
2437
  },
@@ -2429,6 +2429,9 @@
2429
2429
  "v0-1.5-md": {
2430
2430
  "description": "v0-1.5-md 模型適用於日常任務和使用者介面(UI)生成"
2431
2431
  },
2432
+ "wanx2.1-t2i-turbo": {
2433
+ "description": "阿里雲通義旗下的文生圖模型"
2434
+ },
2432
2435
  "whisper-1": {
2433
2436
  "description": "通用語音識別模型,支持多語言語音識別、語音翻譯和語言識別。"
2434
2437
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.102.4",
3
+ "version": "1.103.1",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -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
- export const allModels = [...qwenChatModels];
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?: (payload: CreateImagePayload & { client: OpenAI }) => Promise<CreateImageResponse>;
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