@lobehub/chat 1.129.2 → 1.129.3

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.129.3](https://github.com/lobehub/lobe-chat/compare/v1.129.2...v1.129.3)
6
+
7
+ <sup>Released on **2025-09-17**</sup>
8
+
9
+ #### 🐛 Bug Fixes
10
+
11
+ - **misc**: Add qwen provider support for image-edit model.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's fixed
19
+
20
+ - **misc**: Add qwen provider support for image-edit model, closes [#9277](https://github.com/lobehub/lobe-chat/issues/9277) [#9184](https://github.com/lobehub/lobe-chat/issues/9184) ([e137b33](https://github.com/lobehub/lobe-chat/commit/e137b33))
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.129.2](https://github.com/lobehub/lobe-chat/compare/v1.129.1...v1.129.2)
6
31
 
7
32
  <sup>Released on **2025-09-17**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,13 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "fixes": [
5
+ "Add qwen provider support for image-edit model."
6
+ ]
7
+ },
8
+ "date": "2025-09-17",
9
+ "version": "1.129.3"
10
+ },
2
11
  {
3
12
  "children": {
4
13
  "fixes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.129.2",
3
+ "version": "1.129.3",
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",
@@ -4,6 +4,7 @@ import { LobeCloudflareAI } from '../../providers/cloudflare';
4
4
  import { LobeFalAI } from '../../providers/fal';
5
5
  import { LobeGoogleAI } from '../../providers/google';
6
6
  import { LobeOpenAI } from '../../providers/openai';
7
+ import { LobeQwenAI } from '../../providers/qwen';
7
8
  import { LobeXAI } from '../../providers/xai';
8
9
 
9
10
  export const baseRuntimeMap = {
@@ -13,5 +14,6 @@ export const baseRuntimeMap = {
13
14
  fal: LobeFalAI,
14
15
  google: LobeGoogleAI,
15
16
  openai: LobeOpenAI,
17
+ qwen: LobeQwenAI,
16
18
  xai: LobeXAI,
17
19
  };
@@ -37,11 +37,11 @@ const handlePayload = (payload: ChatStreamPayload) => {
37
37
  return payload as any;
38
38
  };
39
39
 
40
- // 根据 owned_by 字段判断提供商
40
+ // 根据 owned_by 字段判断提供商(基于 NewAPI 的 channel name)
41
41
  const getProviderFromOwnedBy = (ownedBy: string): string => {
42
42
  const normalizedOwnedBy = ownedBy.toLowerCase();
43
43
 
44
- if (normalizedOwnedBy.includes('anthropic') || normalizedOwnedBy.includes('claude')) {
44
+ if (normalizedOwnedBy.includes('claude') || normalizedOwnedBy.includes('anthropic')) {
45
45
  return 'anthropic';
46
46
  }
47
47
  if (normalizedOwnedBy.includes('google') || normalizedOwnedBy.includes('gemini')) {
@@ -50,6 +50,9 @@ const getProviderFromOwnedBy = (ownedBy: string): string => {
50
50
  if (normalizedOwnedBy.includes('xai') || normalizedOwnedBy.includes('grok')) {
51
51
  return 'xai';
52
52
  }
53
+ if (normalizedOwnedBy.includes('ali') || normalizedOwnedBy.includes('qwen')) {
54
+ return 'qwen';
55
+ }
53
56
 
54
57
  // 默认为 openai
55
58
  return 'openai';
@@ -149,6 +152,8 @@ export const LobeNewAPIAI = createRouterRuntime({
149
152
  detectedProvider = 'google';
150
153
  } else if (model.supported_endpoint_types.includes('xai')) {
151
154
  detectedProvider = 'xai';
155
+ } else if (model.supported_endpoint_types.includes('qwen')) {
156
+ detectedProvider = 'qwen';
152
157
  }
153
158
  }
154
159
  // 优先级2:使用 owned_by 字段
@@ -211,6 +216,16 @@ export const LobeNewAPIAI = createRouterRuntime({
211
216
  baseURL: urlJoin(userBaseURL, '/v1'),
212
217
  },
213
218
  },
219
+ {
220
+ apiType: 'qwen',
221
+ models: LOBE_DEFAULT_MODEL_LIST.map((m) => m.id).filter(
222
+ (id) => detectModelProvider(id) === 'qwen',
223
+ ),
224
+ options: {
225
+ ...options,
226
+ baseURL: urlJoin(userBaseURL, '/v1'),
227
+ },
228
+ },
214
229
  {
215
230
  apiType: 'openai',
216
231
  options: {
@@ -591,4 +591,114 @@ describe('createQwenImage', () => {
591
591
  expect(fetch).toHaveBeenCalledTimes(4);
592
592
  });
593
593
  });
594
+
595
+ describe('qwen-image-edit model', () => {
596
+ it('should successfully generate image with qwen-image-edit model', async () => {
597
+ const mockImageUrl =
598
+ 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/test-generated-image.jpg';
599
+
600
+ // Mock fetch for multimodal-generation API
601
+ global.fetch = vi.fn().mockResolvedValueOnce({
602
+ ok: true,
603
+ json: async () => ({
604
+ output: {
605
+ choices: [
606
+ {
607
+ message: {
608
+ content: [{ image: mockImageUrl }],
609
+ },
610
+ },
611
+ ],
612
+ },
613
+ request_id: 'req-edit-123',
614
+ }),
615
+ });
616
+
617
+ const payload: CreateImagePayload = {
618
+ model: 'qwen-image-edit',
619
+ params: {
620
+ prompt: 'Edit this image to add a cat',
621
+ imageUrl: 'https://example.com/source-image.jpg',
622
+ },
623
+ };
624
+
625
+ const result = await createQwenImage(payload, mockOptions);
626
+
627
+ expect(result).toEqual({
628
+ imageUrl: mockImageUrl,
629
+ });
630
+
631
+ expect(fetch).toHaveBeenCalled();
632
+ const [url, options] = (fetch as any).mock.calls[0];
633
+
634
+ expect(url).toBe(
635
+ 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation',
636
+ );
637
+ expect(options.method).toBe('POST');
638
+ expect(options.headers).toEqual({
639
+ 'Authorization': 'Bearer test-api-key',
640
+ 'Content-Type': 'application/json',
641
+ });
642
+
643
+ const body = JSON.parse(options.body);
644
+ expect(body).toEqual({
645
+ input: {
646
+ messages: [
647
+ {
648
+ content: [
649
+ { image: 'https://example.com/source-image.jpg' },
650
+ { text: 'Edit this image to add a cat' },
651
+ ],
652
+ role: 'user',
653
+ },
654
+ ],
655
+ },
656
+ model: 'qwen-image-edit',
657
+ parameters: {},
658
+ });
659
+ });
660
+
661
+ it('should throw error when imageUrl is missing for qwen-image-edit', async () => {
662
+ const payload: CreateImagePayload = {
663
+ model: 'qwen-image-edit',
664
+ params: {
665
+ prompt: 'Edit this image',
666
+ // imageUrl is missing
667
+ },
668
+ };
669
+
670
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
671
+ expect.objectContaining({
672
+ errorType: 'ProviderBizError',
673
+ provider: 'qwen',
674
+ }),
675
+ );
676
+ });
677
+
678
+ it('should handle qwen-image-edit API errors', async () => {
679
+ global.fetch = vi.fn().mockResolvedValueOnce({
680
+ ok: false,
681
+ status: 400,
682
+ statusText: 'Bad Request',
683
+ json: async () => ({
684
+ message: 'Invalid image format',
685
+ }),
686
+ });
687
+
688
+ const payload: CreateImagePayload = {
689
+ model: 'qwen-image-edit',
690
+ params: {
691
+ prompt: 'Edit this image',
692
+ imageUrl: 'https://example.com/invalid-image.jpg',
693
+ },
694
+ };
695
+
696
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
697
+ expect.objectContaining({
698
+ errorType: 'ProviderBizError',
699
+ provider: 'qwen',
700
+ }),
701
+ );
702
+ });
703
+ });
594
704
  });
@@ -19,8 +19,22 @@ interface QwenImageTaskResponse {
19
19
  request_id: string;
20
20
  }
21
21
 
22
+ // Interface for qwen-image-edit multimodal-generation response
23
+ interface QwenImageEditResponse {
24
+ output: {
25
+ choices: Array<{
26
+ message: {
27
+ content: Array<{
28
+ image: string;
29
+ }>;
30
+ };
31
+ }>;
32
+ };
33
+ request_id: string;
34
+ }
35
+
22
36
  /**
23
- * Create an image generation task with Qwen API
37
+ * Create an image generation task with Qwen API for text-to-image models
24
38
  */
25
39
  async function createImageTask(payload: CreateImagePayload, apiKey: string): Promise<string> {
26
40
  const { model, params } = payload;
@@ -72,6 +86,78 @@ async function createImageTask(payload: CreateImagePayload, apiKey: string): Pro
72
86
  return data.output.task_id;
73
87
  }
74
88
 
89
+ /**
90
+ * Create image with Qwen image-edit API for image-to-image models
91
+ * This is a synchronous API that returns the result directly
92
+ */
93
+ async function createImageEdit(
94
+ payload: CreateImagePayload,
95
+ apiKey: string,
96
+ ): Promise<CreateImageResponse> {
97
+ const { model, params } = payload;
98
+ const endpoint = `https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation`;
99
+ log('Creating image edit with model: %s, endpoint: %s', model, endpoint);
100
+
101
+ if (!params.imageUrl) {
102
+ throw new Error('imageUrl is required for qwen-image-edit model');
103
+ }
104
+
105
+ const response = await fetch(endpoint, {
106
+ body: JSON.stringify({
107
+ input: {
108
+ messages: [
109
+ {
110
+ content: [{ image: params.imageUrl }, { text: params.prompt }],
111
+ role: 'user',
112
+ },
113
+ ],
114
+ },
115
+ model,
116
+ parameters: {
117
+ // watermark defaults to false (no watermark) unless explicitly requested
118
+ },
119
+ }),
120
+ headers: {
121
+ 'Authorization': `Bearer ${apiKey}`,
122
+ 'Content-Type': 'application/json',
123
+ },
124
+ method: 'POST',
125
+ });
126
+
127
+ if (!response.ok) {
128
+ let errorData;
129
+ try {
130
+ errorData = await response.json();
131
+ } catch {
132
+ // Failed to parse JSON error response
133
+ }
134
+ throw new Error(
135
+ `Failed to create image edit (${response.status}): ${errorData?.message || response.statusText}`,
136
+ );
137
+ }
138
+
139
+ const data: QwenImageEditResponse = await response.json();
140
+
141
+ if (!data.output.choices || data.output.choices.length === 0) {
142
+ throw new Error('No image choices returned from qwen-image-edit API');
143
+ }
144
+
145
+ const choice = data.output.choices[0];
146
+ if (!choice.message.content || choice.message.content.length === 0) {
147
+ throw new Error('No image content returned from qwen-image-edit API');
148
+ }
149
+
150
+ const imageContent = choice.message.content.find((content) => 'image' in content);
151
+ if (!imageContent) {
152
+ throw new Error('No image found in response content');
153
+ }
154
+
155
+ const imageUrl = imageContent.image;
156
+ log('Image edit generated successfully: %s', imageUrl);
157
+
158
+ return { imageUrl };
159
+ }
160
+
75
161
  /**
76
162
  * Query the status of an image generation task
77
163
  */
@@ -102,15 +188,26 @@ async function queryTaskStatus(taskId: string, apiKey: string): Promise<QwenImag
102
188
  }
103
189
 
104
190
  /**
105
- * Create image using Qwen Wanxiang API
106
- * This implementation uses async task creation and polling
191
+ * Create image using Qwen API
192
+ * Supports both text-to-image (async with polling) and image-to-image (sync) workflows
107
193
  */
108
194
  export async function createQwenImage(
109
195
  payload: CreateImagePayload,
110
196
  options: CreateImageOptions,
111
197
  ): Promise<CreateImageResponse> {
112
198
  const { apiKey, provider } = options;
199
+ const { model } = payload;
200
+
113
201
  try {
202
+ // Check if this is qwen-image-edit model for image-to-image
203
+ if (model === 'qwen-image-edit') {
204
+ log('Using multimodal-generation API for qwen-image-edit model');
205
+ return await createImageEdit(payload, apiKey);
206
+ }
207
+
208
+ // Default to text-to-image workflow for other qwen models
209
+ log('Using text2image API for model: %s', model);
210
+
114
211
  // 1. Create image generation task
115
212
  const taskId = await createImageTask(payload, apiKey);
116
213