@lobehub/chat 1.99.2 → 1.99.4

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.
Files changed (110) hide show
  1. package/.cursor/rules/project-introduce.mdc +1 -56
  2. package/.cursor/rules/testing-guide/db-model-test.mdc +453 -0
  3. package/.cursor/rules/testing-guide/electron-ipc-test.mdc +80 -0
  4. package/.cursor/rules/testing-guide/testing-guide.mdc +401 -0
  5. package/CHANGELOG.md +50 -0
  6. package/changelog/v1.json +18 -0
  7. package/docs/usage/providers/ai21.mdx +1 -1
  8. package/docs/usage/providers/ai21.zh-CN.mdx +1 -1
  9. package/docs/usage/providers/ai360.mdx +1 -1
  10. package/docs/usage/providers/ai360.zh-CN.mdx +1 -1
  11. package/docs/usage/providers/anthropic.mdx +1 -1
  12. package/docs/usage/providers/anthropic.zh-CN.mdx +1 -1
  13. package/docs/usage/providers/azure.mdx +1 -1
  14. package/docs/usage/providers/azure.zh-CN.mdx +1 -1
  15. package/docs/usage/providers/baichuan.mdx +1 -1
  16. package/docs/usage/providers/baichuan.zh-CN.mdx +1 -1
  17. package/docs/usage/providers/bedrock.mdx +1 -1
  18. package/docs/usage/providers/bedrock.zh-CN.mdx +1 -1
  19. package/docs/usage/providers/cloudflare.mdx +1 -1
  20. package/docs/usage/providers/cloudflare.zh-CN.mdx +1 -1
  21. package/docs/usage/providers/deepseek.mdx +1 -1
  22. package/docs/usage/providers/deepseek.zh-CN.mdx +1 -1
  23. package/docs/usage/providers/fal.mdx +69 -0
  24. package/docs/usage/providers/fal.zh-CN.mdx +68 -0
  25. package/docs/usage/providers/fireworksai.mdx +1 -1
  26. package/docs/usage/providers/fireworksai.zh-CN.mdx +1 -1
  27. package/docs/usage/providers/giteeai.mdx +1 -1
  28. package/docs/usage/providers/giteeai.zh-CN.mdx +1 -1
  29. package/docs/usage/providers/github.mdx +1 -1
  30. package/docs/usage/providers/github.zh-CN.mdx +1 -1
  31. package/docs/usage/providers/google.mdx +1 -1
  32. package/docs/usage/providers/google.zh-CN.mdx +1 -1
  33. package/docs/usage/providers/groq.mdx +1 -1
  34. package/docs/usage/providers/groq.zh-CN.mdx +1 -1
  35. package/docs/usage/providers/hunyuan.mdx +1 -1
  36. package/docs/usage/providers/hunyuan.zh-CN.mdx +1 -1
  37. package/docs/usage/providers/internlm.mdx +1 -1
  38. package/docs/usage/providers/internlm.zh-CN.mdx +1 -1
  39. package/docs/usage/providers/jina.mdx +1 -1
  40. package/docs/usage/providers/jina.zh-CN.mdx +1 -1
  41. package/docs/usage/providers/minimax.mdx +1 -1
  42. package/docs/usage/providers/minimax.zh-CN.mdx +1 -1
  43. package/docs/usage/providers/mistral.mdx +1 -1
  44. package/docs/usage/providers/mistral.zh-CN.mdx +1 -1
  45. package/docs/usage/providers/moonshot.mdx +1 -1
  46. package/docs/usage/providers/moonshot.zh-CN.mdx +1 -1
  47. package/docs/usage/providers/novita.mdx +1 -1
  48. package/docs/usage/providers/novita.zh-CN.mdx +1 -1
  49. package/docs/usage/providers/ollama.mdx +1 -1
  50. package/docs/usage/providers/ollama.zh-CN.mdx +1 -1
  51. package/docs/usage/providers/openai.mdx +4 -4
  52. package/docs/usage/providers/openai.zh-CN.mdx +4 -4
  53. package/docs/usage/providers/openrouter.mdx +1 -1
  54. package/docs/usage/providers/openrouter.zh-CN.mdx +1 -1
  55. package/docs/usage/providers/perplexity.mdx +1 -1
  56. package/docs/usage/providers/perplexity.zh-CN.mdx +1 -1
  57. package/docs/usage/providers/ppio.mdx +1 -1
  58. package/docs/usage/providers/ppio.zh-CN.mdx +1 -1
  59. package/docs/usage/providers/qiniu.mdx +1 -1
  60. package/docs/usage/providers/qiniu.zh-CN.mdx +1 -1
  61. package/docs/usage/providers/qwen.mdx +1 -1
  62. package/docs/usage/providers/qwen.zh-CN.mdx +1 -1
  63. package/docs/usage/providers/sambanova.mdx +1 -1
  64. package/docs/usage/providers/sambanova.zh-CN.mdx +1 -1
  65. package/docs/usage/providers/sensenova.mdx +1 -1
  66. package/docs/usage/providers/sensenova.zh-CN.mdx +1 -1
  67. package/docs/usage/providers/siliconcloud.mdx +1 -1
  68. package/docs/usage/providers/siliconcloud.zh-CN.mdx +1 -1
  69. package/docs/usage/providers/spark.mdx +1 -1
  70. package/docs/usage/providers/spark.zh-CN.mdx +1 -1
  71. package/docs/usage/providers/stepfun.mdx +1 -1
  72. package/docs/usage/providers/stepfun.zh-CN.mdx +1 -1
  73. package/docs/usage/providers/taichu.mdx +1 -1
  74. package/docs/usage/providers/taichu.zh-CN.mdx +1 -1
  75. package/docs/usage/providers/togetherai.mdx +1 -1
  76. package/docs/usage/providers/togetherai.zh-CN.mdx +1 -1
  77. package/docs/usage/providers/upstage.mdx +1 -1
  78. package/docs/usage/providers/upstage.zh-CN.mdx +1 -1
  79. package/docs/usage/providers/vllm.mdx +1 -1
  80. package/docs/usage/providers/vllm.zh-CN.mdx +1 -1
  81. package/docs/usage/providers/wenxin.mdx +1 -1
  82. package/docs/usage/providers/wenxin.zh-CN.mdx +1 -1
  83. package/docs/usage/providers/xai.mdx +1 -1
  84. package/docs/usage/providers/xai.zh-CN.mdx +1 -1
  85. package/docs/usage/providers/zeroone.mdx +1 -1
  86. package/docs/usage/providers/zeroone.zh-CN.mdx +1 -1
  87. package/docs/usage/providers/zhipu.mdx +1 -1
  88. package/docs/usage/providers/zhipu.zh-CN.mdx +1 -1
  89. package/package.json +2 -2
  90. package/src/config/aiModels/openai.ts +24 -9
  91. package/src/libs/model-runtime/BaseAI.ts +1 -0
  92. package/src/libs/model-runtime/ModelRuntime.ts +0 -1
  93. package/src/libs/model-runtime/hunyuan/index.ts +4 -6
  94. package/src/libs/model-runtime/novita/__snapshots__/index.test.ts.snap +18 -0
  95. package/src/libs/model-runtime/openai/__snapshots__/index.test.ts.snap +28 -0
  96. package/src/libs/model-runtime/openai/index.test.ts +1 -338
  97. package/src/libs/model-runtime/openai/index.ts +0 -127
  98. package/src/libs/model-runtime/openrouter/__snapshots__/index.test.ts.snap +3 -0
  99. package/src/libs/model-runtime/ppio/__snapshots__/index.test.ts.snap +2 -0
  100. package/src/libs/model-runtime/utils/modelParse.ts +1 -0
  101. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +364 -12
  102. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +145 -43
  103. package/src/libs/model-runtime/utils/openaiHelpers.test.ts +151 -0
  104. package/src/libs/model-runtime/utils/openaiHelpers.ts +26 -1
  105. package/src/libs/model-runtime/xai/index.ts +1 -4
  106. package/src/store/aiInfra/slices/aiModel/action.ts +1 -1
  107. package/src/store/aiInfra/slices/aiProvider/action.ts +5 -2
  108. package/src/types/aiModel.ts +1 -0
  109. package/src/types/llm.ts +3 -1
  110. package/.cursor/rules/testing-guide.mdc +0 -881
@@ -1,9 +1,11 @@
1
1
  import dayjs from 'dayjs';
2
2
  import utc from 'dayjs/plugin/utc';
3
+ import createDebug from 'debug';
3
4
  import OpenAI, { ClientOptions } from 'openai';
4
5
  import { Stream } from 'openai/streaming';
5
6
 
6
- import { LOBE_DEFAULT_MODEL_LIST } from '@/config/modelProviders';
7
+ import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
8
+ import { RuntimeImageGenParamsValue } from '@/libs/standard-parameters/meta-schema';
7
9
  import type { ChatModelCard } from '@/types/llm';
8
10
 
9
11
  import { LobeRuntimeAI } from '../../BaseAI';
@@ -27,7 +29,11 @@ import { AgentRuntimeError } from '../createError';
27
29
  import { debugResponse, debugStream } from '../debugStream';
28
30
  import { desensitizeUrl } from '../desensitizeUrl';
29
31
  import { handleOpenAIError } from '../handleOpenAIError';
30
- import { convertOpenAIMessages, convertOpenAIResponseInputs } from '../openaiHelpers';
32
+ import {
33
+ convertImageUrlToFile,
34
+ convertOpenAIMessages,
35
+ convertOpenAIResponseInputs,
36
+ } from '../openaiHelpers';
31
37
  import { StreamingResponse } from '../response';
32
38
  import { OpenAIResponsesStream, OpenAIStream, OpenAIStreamOptions } from '../streams';
33
39
 
@@ -168,7 +174,6 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
168
174
  debug,
169
175
  constructorOptions,
170
176
  chatCompletion,
171
- createImage,
172
177
  models,
173
178
  customClient,
174
179
  responses,
@@ -311,58 +316,155 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
311
316
  }
312
317
 
313
318
  async createImage(payload: CreateImagePayload) {
314
- return createImage!({
315
- ...payload,
316
- client: this.client,
317
- });
318
- }
319
+ const { model, params } = payload;
320
+ const log = createDebug(`lobe-image:model-runtime`);
319
321
 
320
- async models() {
321
- if (typeof models === 'function') return models({ client: this.client });
322
+ log('Creating image with model: %s and params: %O', model, params);
322
323
 
323
- const list = await this.client.models.list();
324
+ const defaultInput = {
325
+ n: 1,
326
+ ...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
327
+ };
324
328
 
325
- return list.data
326
- .filter((model) => {
327
- return CHAT_MODELS_BLOCK_LIST.every(
328
- (keyword) => !model.id.toLowerCase().includes(keyword),
329
+ // 映射参数名称,将 imageUrls 映射为 image
330
+ const paramsMap = new Map<RuntimeImageGenParamsValue, string>([
331
+ ['imageUrls', 'image'],
332
+ ['imageUrl', 'image'],
333
+ ]);
334
+ const userInput: Record<string, any> = Object.fromEntries(
335
+ Object.entries(params).map(([key, value]) => [
336
+ paramsMap.get(key as RuntimeImageGenParamsValue) ?? key,
337
+ value,
338
+ ]),
339
+ );
340
+
341
+ const isImageEdit = Array.isArray(userInput.image) && userInput.image.length > 0;
342
+ // 如果有 imageUrls 参数,将其转换为 File 对象
343
+ if (isImageEdit) {
344
+ log('Converting imageUrls to File objects: %O', userInput.image);
345
+ try {
346
+ // 转换所有图片 URL 为 File 对象
347
+ const imageFiles = await Promise.all(
348
+ userInput.image.map((url: string) => convertImageUrlToFile(url)),
329
349
  );
330
- })
331
- .map((item) => {
332
- if (models?.transformModel) {
333
- return models.transformModel(item);
334
- }
335
350
 
336
- const toReleasedAt = () => {
337
- if (!item.created) return;
338
- dayjs.extend(utc);
339
-
340
- // guarantee item.created in Date String format
341
- if (
342
- typeof (item.created as any) === 'string' ||
343
- // or in milliseconds
344
- item.created.toFixed(0).length === 13
345
- ) {
346
- return dayjs.utc(item.created).format('YYYY-MM-DD');
351
+ log('Successfully converted %d images to File objects', imageFiles.length);
352
+
353
+ // 根据官方文档,如果有多个图片,传递数组;如果只有一个,传递单个 File
354
+ userInput.image = imageFiles.length === 1 ? imageFiles[0] : imageFiles;
355
+ } catch (error) {
356
+ log('Error converting imageUrls to File objects: %O', error);
357
+ throw new Error(`Failed to convert image URLs to File objects: ${error}`);
358
+ }
359
+ } else {
360
+ delete userInput.image;
361
+ }
362
+
363
+ if (userInput.size === 'auto') {
364
+ delete userInput.size;
365
+ }
366
+
367
+ const options = {
368
+ model,
369
+ ...defaultInput,
370
+ ...userInput,
371
+ };
372
+
373
+ log('options: %O', options);
374
+
375
+ // 判断是否为图片编辑操作
376
+ const img = isImageEdit
377
+ ? await this.client.images.edit(options as any)
378
+ : await this.client.images.generate(options as any);
379
+
380
+ // 检查响应数据的完整性
381
+ if (!img || !img.data || !Array.isArray(img.data) || img.data.length === 0) {
382
+ log('Invalid image response: missing data array');
383
+ throw new Error('Invalid image response: missing or empty data array');
384
+ }
385
+
386
+ const imageData = img.data[0];
387
+ if (!imageData) {
388
+ log('Invalid image response: first data item is null/undefined');
389
+ throw new Error('Invalid image response: first data item is null or undefined');
390
+ }
391
+
392
+ if (!imageData.b64_json) {
393
+ log('Invalid image response: missing b64_json field');
394
+ throw new Error('Invalid image response: missing b64_json field');
395
+ }
396
+
397
+ // 确定图片的 MIME 类型,默认为 PNG
398
+ const mimeType = 'image/png'; // OpenAI 图片生成默认返回 PNG 格式
399
+
400
+ // 将 base64 字符串转换为完整的 data URL
401
+ const dataUrl = `data:${mimeType};base64,${imageData.b64_json}`;
402
+
403
+ log('Successfully converted base64 to data URL, length: %d', dataUrl.length);
404
+
405
+ return {
406
+ imageUrl: dataUrl,
407
+ };
408
+ }
409
+
410
+ async models() {
411
+ let resultModels: ChatModelCard[] = [];
412
+ if (typeof models === 'function') {
413
+ resultModels = await models({ client: this.client });
414
+ } else {
415
+ const list = await this.client.models.list();
416
+ resultModels = list.data
417
+ .filter((model) => {
418
+ return CHAT_MODELS_BLOCK_LIST.every(
419
+ (keyword) => !model.id.toLowerCase().includes(keyword),
420
+ );
421
+ })
422
+ .map((item) => {
423
+ if (models?.transformModel) {
424
+ return models.transformModel(item);
347
425
  }
348
426
 
349
- // by default, the created time is in seconds
350
- return dayjs.utc(item.created * 1000).format('YYYY-MM-DD');
351
- };
427
+ const toReleasedAt = () => {
428
+ if (!item.created) return;
429
+ dayjs.extend(utc);
352
430
 
353
- // TODO: should refactor after remove v1 user/modelList code
354
- const knownModel = LOBE_DEFAULT_MODEL_LIST.find((model) => model.id === item.id);
431
+ // guarantee item.created in Date String format
432
+ if (
433
+ typeof (item.created as any) === 'string' ||
434
+ // or in milliseconds
435
+ item.created.toFixed(0).length === 13
436
+ ) {
437
+ return dayjs.utc(item.created).format('YYYY-MM-DD');
438
+ }
355
439
 
356
- if (knownModel) {
357
- const releasedAt = knownModel.releasedAt ?? toReleasedAt();
440
+ // by default, the created time is in seconds
441
+ return dayjs.utc(item.created * 1000).format('YYYY-MM-DD');
442
+ };
358
443
 
359
- return { ...knownModel, releasedAt };
360
- }
444
+ // TODO: should refactor after remove v1 user/modelList code
445
+ const knownModel = LOBE_DEFAULT_MODEL_LIST.find((model) => model.id === item.id);
361
446
 
362
- return { id: item.id, releasedAt: toReleasedAt() };
363
- })
447
+ if (knownModel) {
448
+ const releasedAt = knownModel.releasedAt ?? toReleasedAt();
449
+
450
+ return { ...knownModel, releasedAt };
451
+ }
364
452
 
365
- .filter(Boolean) as ChatModelCard[];
453
+ return {
454
+ id: item.id,
455
+ releasedAt: toReleasedAt(),
456
+ };
457
+ })
458
+
459
+ .filter(Boolean) as ChatModelCard[];
460
+ }
461
+
462
+ return resultModels.map((model) => {
463
+ return {
464
+ ...model,
465
+ type: model.type || LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.type,
466
+ };
467
+ }) as ChatModelCard[];
366
468
  }
367
469
 
368
470
  async embeddings(
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { imageUrlToBase64 } from '@/utils/imageToBase64';
5
5
 
6
6
  import {
7
+ convertImageUrlToFile,
7
8
  convertMessageContent,
8
9
  convertOpenAIMessages,
9
10
  convertOpenAIResponseInputs,
@@ -288,3 +289,153 @@ describe('convertOpenAIResponseInputs', () => {
288
289
  ]);
289
290
  });
290
291
  });
292
+
293
+ describe('convertImageUrlToFile', () => {
294
+ beforeEach(() => {
295
+ vi.resetAllMocks();
296
+ });
297
+
298
+ afterEach(() => {
299
+ vi.restoreAllMocks();
300
+ });
301
+
302
+ describe('Data URL handling', () => {
303
+ it('should convert PNG data URL to File object correctly', async () => {
304
+ const base64Data =
305
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
306
+ const dataUrl = `data:image/png;base64,${base64Data}`;
307
+
308
+ const result = await convertImageUrlToFile(dataUrl);
309
+
310
+ expect(result).toBeDefined();
311
+ expect(result).toHaveProperty('name', 'image.png');
312
+ expect(result).toHaveProperty('type', 'image/png');
313
+ expect(result).toHaveProperty('size');
314
+ expect(result.size).toBeGreaterThan(0);
315
+ });
316
+
317
+ it('should convert JPEG data URL to File object correctly', async () => {
318
+ const base64Data =
319
+ '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBDAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwA9BQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==';
320
+ const dataUrl = `data:image/jpeg;base64,${base64Data}`;
321
+
322
+ const result = await convertImageUrlToFile(dataUrl);
323
+
324
+ expect(result).toBeDefined();
325
+ expect(result).toHaveProperty('name', 'image.jpeg');
326
+ expect(result).toHaveProperty('type', 'image/jpeg');
327
+ expect(result).toHaveProperty('size');
328
+ expect(result.size).toBeGreaterThan(0);
329
+ });
330
+
331
+ it('should convert WebP data URL to File object correctly', async () => {
332
+ const base64Data = 'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAAAAJaQAA6g=';
333
+ const dataUrl = `data:image/webp;base64,${base64Data}`;
334
+
335
+ const result = await convertImageUrlToFile(dataUrl);
336
+
337
+ expect(result).toBeDefined();
338
+ expect(result).toHaveProperty('name', 'image.webp');
339
+ expect(result).toHaveProperty('type', 'image/webp');
340
+ expect(result).toHaveProperty('size');
341
+ expect(result.size).toBeGreaterThan(0);
342
+ });
343
+ });
344
+
345
+ describe('HTTP URL handling', () => {
346
+ const mockFetch = vi.fn();
347
+
348
+ beforeEach(() => {
349
+ // Mock global fetch using vi.stubGlobal for better isolation
350
+ vi.stubGlobal('fetch', mockFetch);
351
+ });
352
+
353
+ afterEach(() => {
354
+ vi.unstubAllGlobals();
355
+ vi.clearAllMocks();
356
+ });
357
+
358
+ it('should convert HTTP URL to File object correctly', async () => {
359
+ const mockArrayBuffer = new ArrayBuffer(8);
360
+ const mockHeaders = new Headers();
361
+ mockHeaders.set('content-type', 'image/jpeg');
362
+
363
+ mockFetch.mockResolvedValue({
364
+ ok: true,
365
+ arrayBuffer: () => Promise.resolve(mockArrayBuffer),
366
+ headers: mockHeaders,
367
+ } satisfies Partial<Response>);
368
+
369
+ const result = await convertImageUrlToFile('https://example.com/image.jpg');
370
+
371
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
372
+ expect(result).toBeDefined();
373
+ expect(result).toHaveProperty('name', 'image.jpeg');
374
+ expect(result).toHaveProperty('type', 'image/jpeg');
375
+ expect(result).toHaveProperty('size');
376
+ expect(result.size).toEqual(8);
377
+ });
378
+
379
+ it('should handle different content types from HTTP response headers', async () => {
380
+ const testCases = [
381
+ { contentType: 'image/jpeg', expectedExtension: 'jpeg' },
382
+ { contentType: 'image/png', expectedExtension: 'png' },
383
+ { contentType: 'image/webp', expectedExtension: 'webp' },
384
+ { contentType: null, expectedExtension: 'png' }, // default fallback
385
+ ];
386
+
387
+ for (const testCase of testCases) {
388
+ const mockArrayBuffer = new ArrayBuffer(8);
389
+ const mockHeaders = new Headers();
390
+ if (testCase.contentType) {
391
+ mockHeaders.set('content-type', testCase.contentType);
392
+ }
393
+
394
+ mockFetch.mockResolvedValue({
395
+ ok: true,
396
+ arrayBuffer: () => Promise.resolve(mockArrayBuffer),
397
+ headers: mockHeaders,
398
+ } satisfies Partial<Response>);
399
+
400
+ const result = await convertImageUrlToFile('https://example.com/image.jpg');
401
+
402
+ expect(result).toHaveProperty('name', `image.${testCase.expectedExtension}`);
403
+ expect(result).toHaveProperty('type', testCase.contentType || 'image/png');
404
+
405
+ vi.clearAllMocks();
406
+ }
407
+ });
408
+
409
+ it('should throw error when HTTP request fails', async () => {
410
+ mockFetch.mockResolvedValue({
411
+ ok: false,
412
+ statusText: 'Not Found',
413
+ } satisfies Partial<Response>);
414
+
415
+ await expect(convertImageUrlToFile('https://example.com/nonexistent.jpg')).rejects.toThrow(
416
+ 'Failed to fetch image from https://example.com/nonexistent.jpg: Not Found',
417
+ );
418
+
419
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/nonexistent.jpg');
420
+ });
421
+
422
+ it('should throw error when network request fails', async () => {
423
+ mockFetch.mockRejectedValue(new Error('Network error'));
424
+
425
+ await expect(convertImageUrlToFile('https://example.com/image.jpg')).rejects.toThrow(
426
+ 'Network error',
427
+ );
428
+
429
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
430
+ });
431
+ });
432
+
433
+ describe('Edge cases', () => {
434
+ it('should handle malformed data URL gracefully', async () => {
435
+ const malformedDataUrl = 'data:invalid-format';
436
+
437
+ // 这个测试可能会抛出错误,我们需要适当处理
438
+ await expect(convertImageUrlToFile(malformedDataUrl)).rejects.toThrow();
439
+ });
440
+ });
441
+ });
@@ -1,4 +1,4 @@
1
- import OpenAI from 'openai';
1
+ import OpenAI, { toFile } from 'openai';
2
2
 
3
3
  import { disableStreamModels, systemToUserModels } from '@/const/models';
4
4
  import { ChatStreamPayload, OpenAIChatMessage } from '@/libs/model-runtime';
@@ -119,3 +119,28 @@ export const pruneReasoningPayload = (payload: ChatStreamPayload) => {
119
119
  top_p: 1,
120
120
  };
121
121
  };
122
+
123
+ /**
124
+ * Convert image URL (data URL or HTTP URL) to File object for OpenAI API
125
+ */
126
+ export const convertImageUrlToFile = async (imageUrl: string) => {
127
+ let buffer: Buffer;
128
+ let mimeType: string;
129
+
130
+ if (imageUrl.startsWith('data:')) {
131
+ // a base64 image
132
+ const [mimeTypePart, base64Data] = imageUrl.split(',');
133
+ mimeType = mimeTypePart.split(':')[1].split(';')[0];
134
+ buffer = Buffer.from(base64Data, 'base64');
135
+ } else {
136
+ // a http url
137
+ const response = await fetch(imageUrl);
138
+ if (!response.ok) {
139
+ throw new Error(`Failed to fetch image from ${imageUrl}: ${response.statusText}`);
140
+ }
141
+ buffer = Buffer.from(await response.arrayBuffer());
142
+ mimeType = response.headers.get('content-type') || 'image/png';
143
+ }
144
+
145
+ return toFile(buffer, `image.${mimeType.split('/')[1]}`, { type: mimeType });
146
+ };
@@ -7,10 +7,7 @@ export interface XAIModelCard {
7
7
  id: string;
8
8
  }
9
9
 
10
- export const GrokReasoningModels = new Set([
11
- 'grok-3-mini',
12
- 'grok-4',
13
- ]);
10
+ export const GrokReasoningModels = new Set(['grok-3-mini', 'grok-4']);
14
11
 
15
12
  export const isGrokReasoningModel = (model: string) =>
16
13
  Array.from(GrokReasoningModels).some((id) => model.includes(id));
@@ -84,7 +84,7 @@ export const createAiModelSlice: StateCreator<
84
84
  },
85
85
  enabled: model.enabled || false,
86
86
  source: 'remote',
87
- type: 'chat',
87
+ type: model.type || 'chat',
88
88
  })),
89
89
  );
90
90
 
@@ -203,6 +203,8 @@ export const createAiProviderSlice: StateCreator<
203
203
  onSuccess: async (data) => {
204
204
  if (!data) return;
205
205
 
206
+ const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
207
+
206
208
  const getModelListByType = (providerId: string, type: string) => {
207
209
  const models = data.enabledAiModels
208
210
  .filter((model) => model.providerId === providerId && model.type === type)
@@ -212,7 +214,9 @@ export const createAiProviderSlice: StateCreator<
212
214
  displayName: model.displayName ?? '',
213
215
  id: model.id,
214
216
  ...(model.type === 'image' && {
215
- parameters: (model as AIImageModelCard).parameters,
217
+ parameters:
218
+ (model as AIImageModelCard).parameters ||
219
+ LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.parameters,
216
220
  }),
217
221
  }));
218
222
 
@@ -231,7 +235,6 @@ export const createAiProviderSlice: StateCreator<
231
235
  children: getModelListByType(provider.id, 'image'),
232
236
  name: provider.name || provider.id,
233
237
  }));
234
- const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
235
238
 
236
239
  set(
237
240
  {
@@ -268,6 +268,7 @@ export interface AiFullModelCard extends AIBaseModelCard {
268
268
  displayName?: string;
269
269
  id: string;
270
270
  maxDimension?: number;
271
+ parameters?: ModelParamsSchema;
271
272
  pricing?: ChatModelPricing;
272
273
  type: AiModelType;
273
274
  }
package/src/types/llm.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ReactNode } from 'react';
2
2
 
3
- import { ChatModelPricing } from '@/types/aiModel';
3
+ import { AiModelType, ChatModelPricing } from '@/types/aiModel';
4
4
  import { AiProviderSettings } from '@/types/aiProvider';
5
5
 
6
6
  export type ModelPriceCurrency = 'CNY' | 'USD';
@@ -53,6 +53,8 @@ export interface ChatModelCard {
53
53
  */
54
54
  releasedAt?: string;
55
55
 
56
+ type?: AiModelType;
57
+
56
58
  /**
57
59
  * whether model supports vision
58
60
  */