@lobehub/chat 1.118.8 → 1.119.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.119.0](https://github.com/lobehub/lobe-chat/compare/v1.118.8...v1.119.0)
|
6
|
+
|
7
|
+
<sup>Released on **2025-08-30**</sup>
|
8
|
+
|
9
|
+
#### ✨ Features
|
10
|
+
|
11
|
+
- **misc**: Added support for Azure OpenAI Image Generation.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### What's improved
|
19
|
+
|
20
|
+
- **misc**: Added support for Azure OpenAI Image Generation, closes [#8898](https://github.com/lobehub/lobe-chat/issues/8898) ([6042340](https://github.com/lobehub/lobe-chat/commit/6042340))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.118.8](https://github.com/lobehub/lobe-chat/compare/v1.118.7...v1.118.8)
|
6
31
|
|
7
32
|
<sup>Released on **2025-08-30**</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.119.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
|
const azureChatModels: AIChatModelCard[] = [
|
4
4
|
{
|
@@ -282,6 +282,67 @@ const azureChatModels: AIChatModelCard[] = [
|
|
282
282
|
},
|
283
283
|
];
|
284
284
|
|
285
|
-
|
285
|
+
const azureImageModels: AIImageModelCard[] = [
|
286
|
+
{
|
287
|
+
description: 'ChatGPT Image 1',
|
288
|
+
displayName: 'GPT Image 1',
|
289
|
+
enabled: true,
|
290
|
+
id: 'gpt-image-1',
|
291
|
+
parameters: {
|
292
|
+
imageUrl: { default: null },
|
293
|
+
prompt: { default: '' },
|
294
|
+
size: {
|
295
|
+
default: 'auto',
|
296
|
+
enum: ['auto', '1024x1024', '1536x1024', '1024x1536'],
|
297
|
+
},
|
298
|
+
},
|
299
|
+
type: 'image',
|
300
|
+
},
|
301
|
+
{
|
302
|
+
description: 'DALL·E 3',
|
303
|
+
displayName: 'DALL·E 3',
|
304
|
+
id: 'dall-e-3',
|
305
|
+
parameters: {
|
306
|
+
imageUrl: { default: null },
|
307
|
+
prompt: { default: '' },
|
308
|
+
size: {
|
309
|
+
default: 'auto',
|
310
|
+
enum: ['auto', '1024x1024', '1792x1024', '1024x1792'],
|
311
|
+
},
|
312
|
+
},
|
313
|
+
resolutions: ['1024x1024', '1024x1792', '1792x1024'],
|
314
|
+
type: 'image',
|
315
|
+
},
|
316
|
+
{
|
317
|
+
description: 'FLUX.1 Kontext [pro]',
|
318
|
+
displayName: 'FLUX.1 Kontext [pro]',
|
319
|
+
enabled: true,
|
320
|
+
id: 'FLUX.1-Kontext-pro',
|
321
|
+
parameters: {
|
322
|
+
imageUrl: { default: null },
|
323
|
+
prompt: { default: '' },
|
324
|
+
size: {
|
325
|
+
default: 'auto',
|
326
|
+
enum: ['auto', '1024x1024', '1792x1024', '1024x1792'],
|
327
|
+
},
|
328
|
+
},
|
329
|
+
releasedAt: '2025-06-23',
|
330
|
+
type: 'image',
|
331
|
+
},
|
332
|
+
{
|
333
|
+
description: 'FLUX.1.1 Pro',
|
334
|
+
displayName: 'FLUX.1.1 Pro',
|
335
|
+
enabled: true,
|
336
|
+
id: 'FLUX-1.1-pro',
|
337
|
+
parameters: {
|
338
|
+
imageUrl: { default: null },
|
339
|
+
prompt: { default: '' },
|
340
|
+
},
|
341
|
+
releasedAt: '2025-06-23',
|
342
|
+
type: 'image',
|
343
|
+
},
|
344
|
+
];
|
345
|
+
|
346
|
+
export const allModels = [...azureChatModels, ...azureImageModels];
|
286
347
|
|
287
348
|
export default allModels;
|
@@ -340,6 +340,153 @@ describe('LobeAzureOpenAI', () => {
|
|
340
340
|
});
|
341
341
|
});
|
342
342
|
|
343
|
+
describe('createImage', () => {
|
344
|
+
beforeEach(() => {
|
345
|
+
// ensure images namespace exists and is spy-able
|
346
|
+
expect(instance['client'].images).toBeTruthy();
|
347
|
+
});
|
348
|
+
|
349
|
+
it('should generate image and return url from object response', async () => {
|
350
|
+
const url = 'https://example.com/image.png';
|
351
|
+
const generateSpy = vi
|
352
|
+
.spyOn(instance['client'].images, 'generate')
|
353
|
+
.mockResolvedValue({ data: [{ url }] } as any);
|
354
|
+
|
355
|
+
const res = await instance.createImage({
|
356
|
+
model: 'gpt-image-1',
|
357
|
+
params: { prompt: 'a cat' },
|
358
|
+
});
|
359
|
+
|
360
|
+
expect(generateSpy).toHaveBeenCalledTimes(1);
|
361
|
+
const args = vi.mocked(generateSpy).mock.calls[0][0] as any;
|
362
|
+
expect(args).not.toHaveProperty('image');
|
363
|
+
expect(res).toEqual({ imageUrl: url });
|
364
|
+
});
|
365
|
+
|
366
|
+
it('should parse string JSON response from images.generate', async () => {
|
367
|
+
const url = 'https://example.com/str.png';
|
368
|
+
const payload = JSON.stringify({ data: [{ url }] });
|
369
|
+
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue(payload as any);
|
370
|
+
|
371
|
+
const res = await instance.createImage({ model: 'gpt-image-1', params: { prompt: 'dog' } });
|
372
|
+
expect(res).toEqual({ imageUrl: url });
|
373
|
+
});
|
374
|
+
|
375
|
+
it('should parse bodyAsText JSON response', async () => {
|
376
|
+
const url = 'https://example.com/bodyAsText.png';
|
377
|
+
const bodyAsText = JSON.stringify({ data: [{ url }] });
|
378
|
+
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({ bodyAsText } as any);
|
379
|
+
|
380
|
+
const res = await instance.createImage({ model: 'gpt-image-1', params: { prompt: 'bird' } });
|
381
|
+
expect(res).toEqual({ imageUrl: url });
|
382
|
+
});
|
383
|
+
|
384
|
+
it('should parse body JSON response', async () => {
|
385
|
+
const url = 'https://example.com/body.png';
|
386
|
+
const body = JSON.stringify({ data: [{ url }] });
|
387
|
+
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({ body } as any);
|
388
|
+
|
389
|
+
const res = await instance.createImage({ model: 'gpt-image-1', params: { prompt: 'fish' } });
|
390
|
+
expect(res).toEqual({ imageUrl: url });
|
391
|
+
});
|
392
|
+
|
393
|
+
it('should prefer b64_json and return data URL', async () => {
|
394
|
+
const b64 = 'AAA';
|
395
|
+
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({
|
396
|
+
data: [{ b64_json: b64 }],
|
397
|
+
} as any);
|
398
|
+
|
399
|
+
const res = await instance.createImage({ model: 'gpt-image-1', params: { prompt: 'sun' } });
|
400
|
+
expect(res.imageUrl).toBe(`data:image/png;base64,${b64}`);
|
401
|
+
});
|
402
|
+
|
403
|
+
it('should throw wrapped error for empty data array', async () => {
|
404
|
+
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({ data: [] } as any);
|
405
|
+
|
406
|
+
await expect(
|
407
|
+
instance.createImage({ model: 'gpt-image-1', params: { prompt: 'moon' } }),
|
408
|
+
).rejects.toMatchObject({
|
409
|
+
endpoint: 'https://***.openai.azure.com/',
|
410
|
+
errorType: 'AgentRuntimeError',
|
411
|
+
provider: 'azure',
|
412
|
+
error: {
|
413
|
+
name: 'Error',
|
414
|
+
cause: undefined,
|
415
|
+
message: expect.stringContaining('Invalid image response: missing or empty data array'),
|
416
|
+
},
|
417
|
+
});
|
418
|
+
});
|
419
|
+
|
420
|
+
it('should throw wrapped error when missing both b64_json and url', async () => {
|
421
|
+
vi.spyOn(instance['client'].images, 'generate').mockResolvedValue({
|
422
|
+
data: [{}],
|
423
|
+
} as any);
|
424
|
+
|
425
|
+
await expect(
|
426
|
+
instance.createImage({ model: 'gpt-image-1', params: { prompt: 'stars' } }),
|
427
|
+
).rejects.toEqual({
|
428
|
+
endpoint: 'https://***.openai.azure.com/',
|
429
|
+
errorType: 'AgentRuntimeError',
|
430
|
+
provider: 'azure',
|
431
|
+
error: {
|
432
|
+
name: 'Error',
|
433
|
+
cause: undefined,
|
434
|
+
message: 'Invalid image response: missing both b64_json and url fields',
|
435
|
+
},
|
436
|
+
});
|
437
|
+
});
|
438
|
+
|
439
|
+
it('should call images.edit when imageUrl provided and strip size:auto', async () => {
|
440
|
+
const url = 'https://example.com/edited.png';
|
441
|
+
const editSpy = vi
|
442
|
+
.spyOn(instance['client'].images, 'edit')
|
443
|
+
.mockResolvedValue({ data: [{ url }] } as any);
|
444
|
+
|
445
|
+
const helpers = await import('../utils/openaiHelpers');
|
446
|
+
vi.spyOn(helpers, 'convertImageUrlToFile').mockResolvedValue({} as any);
|
447
|
+
|
448
|
+
const res = await instance.createImage({
|
449
|
+
model: 'gpt-image-1',
|
450
|
+
params: { prompt: 'edit', imageUrl: 'https://example.com/in.png', size: 'auto' as any },
|
451
|
+
});
|
452
|
+
|
453
|
+
expect(editSpy).toHaveBeenCalledTimes(1);
|
454
|
+
const arg = vi.mocked(editSpy).mock.calls[0][0] as any;
|
455
|
+
expect(arg).not.toHaveProperty('size');
|
456
|
+
expect(res).toEqual({ imageUrl: url });
|
457
|
+
});
|
458
|
+
|
459
|
+
it('should convert multiple imageUrls and pass images array to edit', async () => {
|
460
|
+
const url = 'https://example.com/edited2.png';
|
461
|
+
const editSpy = vi
|
462
|
+
.spyOn(instance['client'].images, 'edit')
|
463
|
+
.mockResolvedValue({ data: [{ url }] } as any);
|
464
|
+
|
465
|
+
const helpers = await import('../utils/openaiHelpers');
|
466
|
+
const spy = vi.spyOn(helpers, 'convertImageUrlToFile').mockResolvedValue({} as any);
|
467
|
+
|
468
|
+
await instance.createImage({
|
469
|
+
model: 'gpt-image-1',
|
470
|
+
params: { prompt: 'edit', imageUrls: ['u1', 'u2'] },
|
471
|
+
});
|
472
|
+
|
473
|
+
expect(spy).toHaveBeenCalledTimes(2);
|
474
|
+
const arg = vi.mocked(editSpy).mock.calls[0][0] as any;
|
475
|
+
expect(arg).toHaveProperty('image');
|
476
|
+
});
|
477
|
+
|
478
|
+
it('should not include image in generate options', async () => {
|
479
|
+
const generateSpy = vi
|
480
|
+
.spyOn(instance['client'].images, 'generate')
|
481
|
+
.mockResolvedValue({ data: [{ url: 'https://x/y.png' }] } as any);
|
482
|
+
|
483
|
+
await instance.createImage({ model: 'gpt-image-1', params: { prompt: 'no image' } });
|
484
|
+
|
485
|
+
const arg = vi.mocked(generateSpy).mock.calls[0][0] as any;
|
486
|
+
expect(arg).not.toHaveProperty('image');
|
487
|
+
});
|
488
|
+
});
|
489
|
+
|
343
490
|
describe('private method', () => {
|
344
491
|
describe('tocamelCase', () => {
|
345
492
|
it('should convert string to camel case', () => {
|
@@ -1,3 +1,4 @@
|
|
1
|
+
import debug from 'debug';
|
1
2
|
import OpenAI, { AzureOpenAI } from 'openai';
|
2
3
|
import type { Stream } from 'openai/streaming';
|
3
4
|
|
@@ -13,13 +14,15 @@ import {
|
|
13
14
|
EmbeddingsPayload,
|
14
15
|
ModelProvider,
|
15
16
|
} from '../types';
|
17
|
+
import { CreateImagePayload, CreateImageResponse } from '../types/image';
|
16
18
|
import { AgentRuntimeError } from '../utils/createError';
|
17
19
|
import { debugStream } from '../utils/debugStream';
|
18
20
|
import { transformResponseToStream } from '../utils/openaiCompatibleFactory';
|
19
|
-
import { convertOpenAIMessages } from '../utils/openaiHelpers';
|
21
|
+
import { convertImageUrlToFile, convertOpenAIMessages } from '../utils/openaiHelpers';
|
20
22
|
import { StreamingResponse } from '../utils/response';
|
21
23
|
import { OpenAIStream } from '../utils/streams';
|
22
24
|
|
25
|
+
const azureImageLogger = debug('lobe-image:azure');
|
23
26
|
export class LobeAzureOpenAI implements LobeRuntimeAI {
|
24
27
|
client: AzureOpenAI;
|
25
28
|
|
@@ -116,6 +119,117 @@ export class LobeAzureOpenAI implements LobeRuntimeAI {
|
|
116
119
|
}
|
117
120
|
}
|
118
121
|
|
122
|
+
// Create image using Azure OpenAI Images API (gpt-image-1 or DALL·E deployments)
|
123
|
+
async createImage(payload: CreateImagePayload): Promise<CreateImageResponse> {
|
124
|
+
const { model, params } = payload;
|
125
|
+
azureImageLogger('Creating image with model: %s and params: %O', model, params);
|
126
|
+
|
127
|
+
try {
|
128
|
+
// Clone params and remap imageUrls/imageUrl -> image
|
129
|
+
const userInput: Record<string, any> = { ...params };
|
130
|
+
|
131
|
+
// Convert imageUrls to 'image' for edit API
|
132
|
+
if (Array.isArray(userInput.imageUrls) && userInput.imageUrls.length > 0) {
|
133
|
+
const imageFiles = await Promise.all(
|
134
|
+
userInput.imageUrls.map((url: string) => convertImageUrlToFile(url)),
|
135
|
+
);
|
136
|
+
userInput.image = imageFiles.length === 1 ? imageFiles[0] : imageFiles;
|
137
|
+
}
|
138
|
+
|
139
|
+
// Backward compatibility: single imageUrl -> image
|
140
|
+
if (userInput.imageUrl && !userInput.image) {
|
141
|
+
userInput.image = await convertImageUrlToFile(userInput.imageUrl);
|
142
|
+
}
|
143
|
+
|
144
|
+
// Remove non-API parameters to avoid unknown_parameter errors
|
145
|
+
delete userInput.imageUrls;
|
146
|
+
delete userInput.imageUrl;
|
147
|
+
|
148
|
+
const isImageEdit = Boolean(userInput.image);
|
149
|
+
|
150
|
+
azureImageLogger('Is Image Edit: ' + isImageEdit);
|
151
|
+
// Azure/OpenAI Images: remove unsupported/auto values where appropriate
|
152
|
+
if (userInput.size === 'auto') delete userInput.size;
|
153
|
+
|
154
|
+
// Build options: do not force response_format for gpt-image-1
|
155
|
+
const options: any = {
|
156
|
+
model,
|
157
|
+
n: 1,
|
158
|
+
...(isImageEdit ? { input_fidelity: 'high' } : {}),
|
159
|
+
...userInput,
|
160
|
+
};
|
161
|
+
|
162
|
+
// For generate, ensure no 'image' field is sent
|
163
|
+
if (!isImageEdit) delete options.image;
|
164
|
+
|
165
|
+
// Call Azure Images API
|
166
|
+
const img = isImageEdit
|
167
|
+
? await this.client.images.edit(options)
|
168
|
+
: await this.client.images.generate(options);
|
169
|
+
|
170
|
+
// Normalize possible string JSON response -- Sometimes Azure Image API returns a text/plain Content-Type
|
171
|
+
let result: any = img as any;
|
172
|
+
if (typeof result === 'string') {
|
173
|
+
try {
|
174
|
+
result = JSON.parse(result);
|
175
|
+
} catch {
|
176
|
+
const truncated = result.length > 500 ? result.slice(0, 500) + '...[truncated]' : result;
|
177
|
+
azureImageLogger(
|
178
|
+
`Failed to parse string response from images API. Raw response: ${truncated}`,
|
179
|
+
);
|
180
|
+
throw new Error('Invalid image response: expected JSON string but parsing failed');
|
181
|
+
}
|
182
|
+
} else if (result && typeof result === 'object') {
|
183
|
+
// Handle common Azure REST shapes
|
184
|
+
if (typeof (result as any).bodyAsText === 'string') {
|
185
|
+
try {
|
186
|
+
result = JSON.parse((result as any).bodyAsText);
|
187
|
+
} catch {
|
188
|
+
const rawText = (result as any).bodyAsText;
|
189
|
+
const truncated =
|
190
|
+
rawText.length > 500 ? rawText.slice(0, 500) + '...[truncated]' : rawText;
|
191
|
+
azureImageLogger(
|
192
|
+
`Failed to parse bodyAsText from images API. Raw response: ${truncated}`,
|
193
|
+
);
|
194
|
+
throw new Error('Invalid image response: bodyAsText not valid JSON');
|
195
|
+
}
|
196
|
+
} else if (typeof (result as any).body === 'string') {
|
197
|
+
try {
|
198
|
+
result = JSON.parse((result as any).body);
|
199
|
+
} catch {
|
200
|
+
azureImageLogger('Failed to parse body from images API response');
|
201
|
+
throw new Error('Invalid image response: body not valid JSON');
|
202
|
+
}
|
203
|
+
}
|
204
|
+
}
|
205
|
+
|
206
|
+
// Validate response
|
207
|
+
if (!result || !result.data || !Array.isArray(result.data) || result.data.length === 0) {
|
208
|
+
throw new Error(
|
209
|
+
`Invalid image response: missing or empty data array. Response: ${JSON.stringify(result)}`,
|
210
|
+
);
|
211
|
+
}
|
212
|
+
|
213
|
+
const imageData: any = result.data[0];
|
214
|
+
if (!imageData)
|
215
|
+
throw new Error('Invalid image response: first data item is null or undefined');
|
216
|
+
|
217
|
+
// Prefer base64 if provided, otherwise URL
|
218
|
+
if (imageData.b64_json) {
|
219
|
+
const mimeType = 'image/png';
|
220
|
+
return { imageUrl: `data:${mimeType};base64,${imageData.b64_json}` };
|
221
|
+
}
|
222
|
+
|
223
|
+
if (imageData.url) {
|
224
|
+
return { imageUrl: imageData.url };
|
225
|
+
}
|
226
|
+
|
227
|
+
throw new Error('Invalid image response: missing both b64_json and url fields');
|
228
|
+
} catch (e) {
|
229
|
+
return this.handleError(e, model);
|
230
|
+
}
|
231
|
+
}
|
232
|
+
|
119
233
|
protected handleError(e: any, model?: string): never {
|
120
234
|
let error = e as { [key: string]: any; code: string; message: string };
|
121
235
|
|