@lobehub/chat 1.116.4 → 1.117.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +117 -0
  2. package/changelog/v1.json +21 -0
  3. package/locales/ar/models.json +3 -0
  4. package/locales/bg-BG/models.json +3 -0
  5. package/locales/de-DE/models.json +3 -0
  6. package/locales/en-US/models.json +3 -0
  7. package/locales/es-ES/models.json +3 -0
  8. package/locales/fa-IR/models.json +3 -0
  9. package/locales/fr-FR/models.json +3 -0
  10. package/locales/it-IT/models.json +3 -0
  11. package/locales/ja-JP/models.json +3 -0
  12. package/locales/ko-KR/models.json +3 -0
  13. package/locales/nl-NL/models.json +3 -0
  14. package/locales/pl-PL/models.json +3 -0
  15. package/locales/pt-BR/models.json +3 -0
  16. package/locales/ru-RU/models.json +3 -0
  17. package/locales/tr-TR/models.json +3 -0
  18. package/locales/vi-VN/models.json +3 -0
  19. package/locales/zh-CN/models.json +3 -0
  20. package/locales/zh-TW/models.json +3 -0
  21. package/package.json +1 -2
  22. package/packages/const/src/image.ts +9 -0
  23. package/packages/database/vitest.config.mts +1 -0
  24. package/packages/database/vitest.config.server.mts +1 -0
  25. package/packages/file-loaders/package.json +1 -1
  26. package/packages/model-runtime/src/RouterRuntime/createRuntime.ts +11 -9
  27. package/packages/model-runtime/src/google/createImage.test.ts +657 -0
  28. package/packages/model-runtime/src/google/createImage.ts +152 -0
  29. package/packages/model-runtime/src/google/index.test.ts +0 -328
  30. package/packages/model-runtime/src/google/index.ts +3 -40
  31. package/packages/model-runtime/src/utils/modelParse.ts +2 -1
  32. package/packages/model-runtime/src/utils/openaiCompatibleFactory/createImage.ts +239 -0
  33. package/packages/model-runtime/src/utils/openaiCompatibleFactory/index.test.ts +22 -22
  34. package/packages/model-runtime/src/utils/openaiCompatibleFactory/index.ts +9 -116
  35. package/packages/model-runtime/src/utils/postProcessModelList.ts +55 -0
  36. package/packages/model-runtime/src/utils/streams/google-ai.test.ts +7 -7
  37. package/packages/model-runtime/src/utils/streams/google-ai.ts +15 -2
  38. package/packages/model-runtime/src/utils/streams/openai/openai.test.ts +41 -0
  39. package/packages/model-runtime/src/utils/streams/openai/openai.ts +38 -2
  40. package/packages/model-runtime/src/utils/streams/protocol.test.ts +32 -0
  41. package/packages/model-runtime/src/utils/streams/protocol.ts +7 -3
  42. package/packages/model-runtime/src/utils/usageConverter.test.ts +58 -0
  43. package/packages/model-runtime/src/utils/usageConverter.ts +5 -1
  44. package/packages/utils/vitest.config.mts +1 -0
  45. package/src/components/ChatItem/ChatItem.tsx +183 -0
  46. package/src/components/ChatItem/components/Actions.tsx +25 -0
  47. package/src/components/ChatItem/components/Avatar.tsx +50 -0
  48. package/src/components/ChatItem/components/BorderSpacing.tsx +13 -0
  49. package/src/components/ChatItem/components/ErrorContent.tsx +24 -0
  50. package/src/components/ChatItem/components/Loading.tsx +26 -0
  51. package/src/components/ChatItem/components/MessageContent.tsx +76 -0
  52. package/src/components/ChatItem/components/Title.tsx +43 -0
  53. package/src/components/ChatItem/index.ts +2 -0
  54. package/src/components/ChatItem/style.ts +208 -0
  55. package/src/components/ChatItem/type.ts +80 -0
  56. package/src/config/aiModels/google.ts +42 -22
  57. package/src/config/aiModels/openrouter.ts +33 -0
  58. package/src/config/aiModels/vertexai.ts +4 -4
  59. package/src/features/ChatItem/index.tsx +1 -1
  60. package/src/features/Conversation/Extras/Usage/UsageDetail/index.tsx +6 -0
  61. package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.test.ts +38 -0
  62. package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.ts +13 -1
  63. package/src/locales/default/chat.ts +1 -0
  64. package/packages/model-runtime/src/UniformRuntime/index.ts +0 -117
@@ -0,0 +1,239 @@
1
+ import { imageUrlToBase64 } from '@lobechat/utils';
2
+ import createDebug from 'debug';
3
+ import OpenAI from 'openai';
4
+
5
+ import { RuntimeImageGenParamsValue } from '@/libs/standard-parameters/index';
6
+
7
+ import { CreateImagePayload, CreateImageResponse } from '../../types/image';
8
+ import { convertImageUrlToFile } from '../openaiHelpers';
9
+ import { parseDataUri } from '../uriParser';
10
+
11
+ const log = createDebug('lobe-image:openai-compatible');
12
+
13
+ /**
14
+ * Generate images using traditional OpenAI images API (DALL-E, etc.)
15
+ */
16
+ async function generateByImageMode(
17
+ client: OpenAI,
18
+ payload: CreateImagePayload,
19
+ ): Promise<CreateImageResponse> {
20
+ const { model, params } = payload;
21
+
22
+ log('Creating image with model: %s and params: %O', model, params);
23
+
24
+ // Map parameter names, mapping imageUrls to image
25
+ const paramsMap = new Map<RuntimeImageGenParamsValue, string>([
26
+ ['imageUrls', 'image'],
27
+ ['imageUrl', 'image'],
28
+ ]);
29
+ const userInput: Record<string, any> = Object.fromEntries(
30
+ Object.entries(params).map(([key, value]) => [
31
+ paramsMap.get(key as RuntimeImageGenParamsValue) ?? key,
32
+ value,
33
+ ]),
34
+ );
35
+
36
+ // https://platform.openai.com/docs/api-reference/images/createEdit
37
+ const isImageEdit = Array.isArray(userInput.image) && userInput.image.length > 0;
38
+ // If there are imageUrls parameters, convert them to File objects
39
+ if (isImageEdit) {
40
+ log('Converting imageUrls to File objects: %O', userInput.image);
41
+ try {
42
+ // Convert all image URLs to File objects
43
+ const imageFiles = await Promise.all(
44
+ userInput.image.map((url: string) => convertImageUrlToFile(url)),
45
+ );
46
+
47
+ log('Successfully converted %d images to File objects', imageFiles.length);
48
+
49
+ // According to official docs, if there are multiple images, pass an array; if only one, pass a single File
50
+ userInput.image = imageFiles.length === 1 ? imageFiles[0] : imageFiles;
51
+ } catch (error) {
52
+ log('Error converting imageUrls to File objects: %O', error);
53
+ throw new Error(`Failed to convert image URLs to File objects: ${error}`);
54
+ }
55
+ } else {
56
+ delete userInput.image;
57
+ }
58
+
59
+ if (userInput.size === 'auto') {
60
+ delete userInput.size;
61
+ }
62
+
63
+ const defaultInput = {
64
+ n: 1,
65
+ ...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
66
+ ...(isImageEdit ? { input_fidelity: 'high' } : {}),
67
+ };
68
+
69
+ const options = {
70
+ model,
71
+ ...defaultInput,
72
+ ...userInput,
73
+ };
74
+
75
+ log('options: %O', options);
76
+
77
+ // Determine if it's an image editing operation
78
+ const img = isImageEdit
79
+ ? await client.images.edit(options as any)
80
+ : await client.images.generate(options as any);
81
+
82
+ // Check the integrity of response data
83
+ if (!img || !img.data || !Array.isArray(img.data) || img.data.length === 0) {
84
+ log('Invalid image response: missing data array');
85
+ throw new Error('Invalid image response: missing or empty data array');
86
+ }
87
+
88
+ const imageData = img.data[0];
89
+ if (!imageData) {
90
+ log('Invalid image response: first data item is null/undefined');
91
+ throw new Error('Invalid image response: first data item is null or undefined');
92
+ }
93
+
94
+ let imageUrl: string;
95
+
96
+ // Handle base64 format response
97
+ if (imageData.b64_json) {
98
+ // Determine the image's MIME type, default to PNG
99
+ const mimeType = 'image/png'; // OpenAI image generation defaults to PNG format
100
+
101
+ // Convert base64 string to complete data URL
102
+ imageUrl = `data:${mimeType};base64,${imageData.b64_json}`;
103
+ log('Successfully converted base64 to data URL, length: %d', imageUrl.length);
104
+ }
105
+ // Handle URL format response
106
+ else if (imageData.url) {
107
+ imageUrl = imageData.url;
108
+ log('Using direct image URL: %s', imageUrl);
109
+ }
110
+ // If neither format exists, throw error
111
+ else {
112
+ log('Invalid image response: missing both b64_json and url fields');
113
+ throw new Error('Invalid image response: missing both b64_json and url fields');
114
+ }
115
+
116
+ return {
117
+ imageUrl,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Process image URL for chat model input
123
+ */
124
+ async function processImageUrlForChat(imageUrl: string): Promise<string> {
125
+ const { type, base64, mimeType } = parseDataUri(imageUrl);
126
+
127
+ if (type === 'base64') {
128
+ if (!base64) {
129
+ throw new TypeError("Image URL doesn't contain base64 data");
130
+ }
131
+ return `data:${mimeType || 'image/png'};base64,${base64}`;
132
+ } else if (type === 'url') {
133
+ // For URL type, convert to base64 first
134
+ const { base64: urlBase64, mimeType: urlMimeType } = await imageUrlToBase64(imageUrl);
135
+ return `data:${urlMimeType};base64,${urlBase64}`;
136
+ } else {
137
+ throw new TypeError(`Currently we don't support image url: ${imageUrl}`);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Generate images using chat completion API (OpenRouter Gemini, etc.)
143
+ */
144
+ async function generateByChatModel(
145
+ client: OpenAI,
146
+ payload: CreateImagePayload,
147
+ ): Promise<CreateImageResponse> {
148
+ const { model, params } = payload;
149
+ const actualModel = model.replace(':image', ''); // Remove :image suffix
150
+
151
+ log('Creating image via chat API with model: %s and params: %O', actualModel, params);
152
+
153
+ // Build message content array
154
+ const content: Array<any> = [
155
+ {
156
+ text: params.prompt,
157
+ type: 'text',
158
+ },
159
+ ];
160
+
161
+ // Add image for editing mode if provided
162
+ if (params.imageUrl && params.imageUrl !== null) {
163
+ log('Processing image URL for editing mode: %s', params.imageUrl);
164
+ try {
165
+ const processedImageUrl = await processImageUrlForChat(params.imageUrl);
166
+ content.push({
167
+ image_url: {
168
+ url: processedImageUrl,
169
+ },
170
+ type: 'image_url',
171
+ });
172
+ log('Successfully processed image URL for chat input');
173
+ } catch (error) {
174
+ log('Error processing image URL: %O', error);
175
+ throw new Error(`Failed to process image URL: ${error}`);
176
+ }
177
+ }
178
+
179
+ // Call chat completion API
180
+ const response = await client.chat.completions.create({
181
+ messages: [
182
+ {
183
+ content,
184
+ role: 'user',
185
+ },
186
+ ],
187
+ model: actualModel,
188
+ stream: false,
189
+ });
190
+
191
+ log('Chat API response: %O', response);
192
+
193
+ // Extract image from response
194
+ const message = response.choices[0]?.message;
195
+ if (!message) {
196
+ throw new Error('No message in chat completion response');
197
+ }
198
+
199
+ // Check if response has images in the expected format
200
+ if ((message as any).images && Array.isArray((message as any).images)) {
201
+ const { images } = message as any;
202
+ if (images.length > 0) {
203
+ const image = images[0];
204
+ if (image.image_url?.url) {
205
+ log('Successfully extracted image from chat response');
206
+ return { imageUrl: image.image_url.url };
207
+ }
208
+ }
209
+ }
210
+
211
+ // If no images found, throw error
212
+ log('No images found in chat completion response');
213
+ throw new Error('No image generated in chat completion response');
214
+ }
215
+
216
+ /**
217
+ * Create image using OpenAI Compatible API
218
+ */
219
+ export async function createOpenAICompatibleImage(
220
+ client: OpenAI,
221
+ payload: CreateImagePayload,
222
+ _provider: string, // eslint-disable-line @typescript-eslint/no-unused-vars
223
+ ): Promise<CreateImageResponse> {
224
+ try {
225
+ const { model } = payload;
226
+
227
+ // Check if it's a chat model for image generation (via :image suffix)
228
+ if (model.endsWith(':image')) {
229
+ return await generateByChatModel(client, payload);
230
+ }
231
+
232
+ // Default to traditional images API
233
+ return await generateByImageMode(client, payload);
234
+ } catch (error) {
235
+ const err = error as Error;
236
+ log('Error in createImage: %O', err);
237
+ throw err;
238
+ }
239
+ }
@@ -45,7 +45,7 @@ const LobeMockProvider = createOpenAICompatibleRuntime({
45
45
  beforeEach(() => {
46
46
  instance = new LobeMockProvider({ apiKey: 'test' });
47
47
 
48
- // 使用 vi.spyOn 来模拟 chat.completions.create 方法
48
+ // Use vi.spyOn to mock the chat.completions.create method
49
49
  vi.spyOn(instance['client'].chat.completions, 'create').mockResolvedValue(
50
50
  new ReadableStream() as any,
51
51
  );
@@ -540,16 +540,16 @@ describe('LobeOpenAICompatibleFactory', () => {
540
540
  { signal: controller.signal },
541
541
  );
542
542
 
543
- // 给一些时间让请求开始
543
+ // Give some time for the request to start
544
544
  await sleep(50);
545
545
 
546
546
  controller.abort();
547
547
 
548
- // 等待并断言 Promise 被拒绝
549
- // 使用 try-catch 来捕获和验证错误
548
+ // Wait and assert that Promise is rejected
549
+ // Use try-catch to capture and verify errors
550
550
  try {
551
551
  await chatPromise;
552
- // 如果 Promise 没有被拒绝,测试应该失败
552
+ // If Promise is not rejected, test should fail
553
553
  expect.fail('Expected promise to be rejected');
554
554
  } catch (error) {
555
555
  expect((error as any).errorType).toBe('AgentRuntimeError');
@@ -753,7 +753,7 @@ describe('LobeOpenAICompatibleFactory', () => {
753
753
 
754
754
  describe('chat with callback and headers', () => {
755
755
  it('should handle callback and headers correctly', async () => {
756
- // 模拟 chat.completions.create 方法返回一个可读流
756
+ // Mock chat.completions.create method to return a readable stream
757
757
  const mockCreateMethod = vi
758
758
  .spyOn(instance['client'].chat.completions, 'create')
759
759
  .mockResolvedValue(
@@ -774,14 +774,14 @@ describe('LobeOpenAICompatibleFactory', () => {
774
774
  }) as any,
775
775
  );
776
776
 
777
- // 准备 callback headers
777
+ // Prepare callback and headers
778
778
  const mockCallback: ChatStreamCallbacks = {
779
779
  onStart: vi.fn(),
780
780
  onCompletion: vi.fn(),
781
781
  };
782
782
  const mockHeaders = { 'Custom-Header': 'TestValue' };
783
783
 
784
- // 执行测试
784
+ // Execute test
785
785
  const result = await instance.chat(
786
786
  {
787
787
  messages: [{ content: 'Hello', role: 'user' }],
@@ -791,17 +791,17 @@ describe('LobeOpenAICompatibleFactory', () => {
791
791
  { callback: mockCallback, headers: mockHeaders },
792
792
  );
793
793
 
794
- // 验证 callback 被调用
795
- await result.text(); // 确保流被消费
794
+ // Verify callback is called
795
+ await result.text(); // Ensure stream is consumed
796
796
  expect(mockCallback.onStart).toHaveBeenCalled();
797
797
  expect(mockCallback.onCompletion).toHaveBeenCalledWith({
798
798
  text: 'hello',
799
799
  });
800
800
 
801
- // 验证 headers 被正确传递
801
+ // Verify headers are correctly passed
802
802
  expect(result.headers.get('Custom-Header')).toEqual('TestValue');
803
803
 
804
- // 清理
804
+ // Cleanup
805
805
  mockCreateMethod.mockRestore();
806
806
  });
807
807
  });
@@ -940,40 +940,40 @@ describe('LobeOpenAICompatibleFactory', () => {
940
940
  describe('DEBUG', () => {
941
941
  it('should call debugStream and return StreamingTextResponse when DEBUG_OPENROUTER_CHAT_COMPLETION is 1', async () => {
942
942
  // Arrange
943
- const mockProdStream = new ReadableStream() as any; // 模拟的 prod
943
+ const mockProdStream = new ReadableStream() as any; // Mocked prod stream
944
944
  const mockDebugStream = new ReadableStream({
945
945
  start(controller) {
946
946
  controller.enqueue('Debug stream content');
947
947
  controller.close();
948
948
  },
949
949
  }) as any;
950
- mockDebugStream.toReadableStream = () => mockDebugStream; // 添加 toReadableStream 方法
950
+ mockDebugStream.toReadableStream = () => mockDebugStream; // Add toReadableStream method
951
951
 
952
- // 模拟 chat.completions.create 返回值,包括模拟的 tee 方法
952
+ // Mock chat.completions.create return value, including mocked tee method
953
953
  (instance['client'].chat.completions.create as Mock).mockResolvedValue({
954
954
  tee: () => [mockProdStream, { toReadableStream: () => mockDebugStream }],
955
955
  });
956
956
 
957
- // 保存原始环境变量值
957
+ // Save original environment variable value
958
958
  const originalDebugValue = process.env.DEBUG_MOCKPROVIDER_CHAT_COMPLETION;
959
959
 
960
- // 模拟环境变量
960
+ // Mock environment variable
961
961
  process.env.DEBUG_MOCKPROVIDER_CHAT_COMPLETION = '1';
962
962
  vi.spyOn(debugStreamModule, 'debugStream').mockImplementation(() => Promise.resolve());
963
963
 
964
- // 执行测试
965
- // 运行你的测试函数,确保它会在条件满足时调用 debugStream
966
- // 假设的测试函数调用,你可能需要根据实际情况调整
964
+ // Execute test
965
+ // Run your test function, ensuring it calls debugStream when conditions are met
966
+ // Hypothetical test function call, you may need to adjust based on actual situation
967
967
  await instance.chat({
968
968
  messages: [{ content: 'Hello', role: 'user' }],
969
969
  model: 'mistralai/mistral-7b-instruct:free',
970
970
  temperature: 0,
971
971
  });
972
972
 
973
- // 验证 debugStream 被调用
973
+ // Verify debugStream is called
974
974
  expect(debugStreamModule.debugStream).toHaveBeenCalled();
975
975
 
976
- // 恢复原始环境变量值
976
+ // Restore original environment variable value
977
977
  process.env.DEBUG_MOCKPROVIDER_CHAT_COMPLETION = originalDebugValue;
978
978
  });
979
979
  });
@@ -1,12 +1,11 @@
1
1
  import { getModelPropertyWithFallback } from '@lobechat/utils';
2
2
  import dayjs from 'dayjs';
3
3
  import utc from 'dayjs/plugin/utc';
4
- import createDebug from 'debug';
5
4
  import OpenAI, { ClientOptions } from 'openai';
6
5
  import { Stream } from 'openai/streaming';
7
6
 
8
7
  import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
9
- import { RuntimeImageGenParamsValue } from '@/libs/standard-parameters/index';
8
+ import type { AiModelType } from '@/types/aiModel';
10
9
  import type { ChatModelCard } from '@/types/llm';
11
10
 
12
11
  import { LobeRuntimeAI } from '../../BaseAI';
@@ -30,13 +29,11 @@ import { AgentRuntimeError } from '../createError';
30
29
  import { debugResponse, debugStream } from '../debugStream';
31
30
  import { desensitizeUrl } from '../desensitizeUrl';
32
31
  import { handleOpenAIError } from '../handleOpenAIError';
33
- import {
34
- convertImageUrlToFile,
35
- convertOpenAIMessages,
36
- convertOpenAIResponseInputs,
37
- } from '../openaiHelpers';
32
+ import { convertOpenAIMessages, convertOpenAIResponseInputs } from '../openaiHelpers';
33
+ import { postProcessModelList } from '../postProcessModelList';
38
34
  import { StreamingResponse } from '../response';
39
35
  import { OpenAIResponsesStream, OpenAIStream, OpenAIStreamOptions } from '../streams';
36
+ import { createOpenAICompatibleImage } from './createImage';
40
37
 
41
38
  // the model contains the following keywords is not a chat model, so we should filter them out
42
39
  export const CHAT_MODELS_BLOCK_LIST = [
@@ -334,107 +331,8 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
334
331
  });
335
332
  }
336
333
 
337
- // Otherwise use default OpenAI compatible implementation
338
- const { model, params } = payload;
339
- const log = createDebug(`lobe-image:model-runtime`);
340
-
341
- log('Creating image with model: %s and params: %O', model, params);
342
-
343
- // 映射参数名称,将 imageUrls 映射为 image
344
- const paramsMap = new Map<RuntimeImageGenParamsValue, string>([
345
- ['imageUrls', 'image'],
346
- ['imageUrl', 'image'],
347
- ]);
348
- const userInput: Record<string, any> = Object.fromEntries(
349
- Object.entries(params).map(([key, value]) => [
350
- paramsMap.get(key as RuntimeImageGenParamsValue) ?? key,
351
- value,
352
- ]),
353
- );
354
-
355
- // https://platform.openai.com/docs/api-reference/images/createEdit
356
- const isImageEdit = Array.isArray(userInput.image) && userInput.image.length > 0;
357
- // 如果有 imageUrls 参数,将其转换为 File 对象
358
- if (isImageEdit) {
359
- log('Converting imageUrls to File objects: %O', userInput.image);
360
- try {
361
- // 转换所有图片 URL 为 File 对象
362
- const imageFiles = await Promise.all(
363
- userInput.image.map((url: string) => convertImageUrlToFile(url)),
364
- );
365
-
366
- log('Successfully converted %d images to File objects', imageFiles.length);
367
-
368
- // 根据官方文档,如果有多个图片,传递数组;如果只有一个,传递单个 File
369
- userInput.image = imageFiles.length === 1 ? imageFiles[0] : imageFiles;
370
- } catch (error) {
371
- log('Error converting imageUrls to File objects: %O', error);
372
- throw new Error(`Failed to convert image URLs to File objects: ${error}`);
373
- }
374
- } else {
375
- delete userInput.image;
376
- }
377
-
378
- if (userInput.size === 'auto') {
379
- delete userInput.size;
380
- }
381
-
382
- const defaultInput = {
383
- n: 1,
384
- ...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
385
- ...(isImageEdit ? { input_fidelity: 'high' } : {}),
386
- };
387
-
388
- const options = {
389
- model,
390
- ...defaultInput,
391
- ...userInput,
392
- };
393
-
394
- log('options: %O', options);
395
-
396
- // 判断是否为图片编辑操作
397
- const img = isImageEdit
398
- ? await this.client.images.edit(options as any)
399
- : await this.client.images.generate(options as any);
400
-
401
- // 检查响应数据的完整性
402
- if (!img || !img.data || !Array.isArray(img.data) || img.data.length === 0) {
403
- log('Invalid image response: missing data array');
404
- throw new Error('Invalid image response: missing or empty data array');
405
- }
406
-
407
- const imageData = img.data[0];
408
- if (!imageData) {
409
- log('Invalid image response: first data item is null/undefined');
410
- throw new Error('Invalid image response: first data item is null or undefined');
411
- }
412
-
413
- let imageUrl: string;
414
-
415
- // 处理 base64 格式的响应
416
- if (imageData.b64_json) {
417
- // 确定图片的 MIME 类型,默认为 PNG
418
- const mimeType = 'image/png'; // OpenAI 图片生成默认返回 PNG 格式
419
-
420
- // 将 base64 字符串转换为完整的 data URL
421
- imageUrl = `data:${mimeType};base64,${imageData.b64_json}`;
422
- log('Successfully converted base64 to data URL, length: %d', imageUrl.length);
423
- }
424
- // 处理 URL 格式的响应
425
- else if (imageData.url) {
426
- imageUrl = imageData.url;
427
- log('Using direct image URL: %s', imageUrl);
428
- }
429
- // 如果两种格式都不存在,抛出错误
430
- else {
431
- log('Invalid image response: missing both b64_json and url fields');
432
- throw new Error('Invalid image response: missing both b64_json and url fields');
433
- }
434
-
435
- return {
436
- imageUrl,
437
- };
334
+ // Use the new createOpenAICompatibleImage function
335
+ return createOpenAICompatibleImage(this.client, payload, provider);
438
336
  }
439
337
 
440
338
  async models() {
@@ -489,14 +387,9 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
489
387
  .filter(Boolean) as ChatModelCard[];
490
388
  }
491
389
 
492
- return (await Promise.all(
493
- resultModels.map(async (model) => {
494
- return {
495
- ...model,
496
- type: model.type || (await getModelPropertyWithFallback(model.id, 'type')),
497
- };
498
- }),
499
- )) as ChatModelCard[];
390
+ return await postProcessModelList(resultModels, (modelId) =>
391
+ getModelPropertyWithFallback<AiModelType>(modelId, 'type'),
392
+ );
500
393
  }
501
394
 
502
395
  async embeddings(
@@ -0,0 +1,55 @@
1
+ import { CHAT_MODEL_IMAGE_GENERATION_PARAMS } from '@/const/image';
2
+ import type { AiModelType } from '@/types/aiModel';
3
+ import type { ChatModelCard } from '@/types/llm';
4
+
5
+ // Whitelist for automatic image model generation
6
+ export const IMAGE_GENERATION_MODEL_WHITELIST = [
7
+ 'gemini-2.5-flash-image-preview',
8
+ // More models can be added in the future
9
+ ] as const;
10
+
11
+ /**
12
+ * Process model list: ensure type field exists and generate image generation models for whitelisted models
13
+ * @param models Original model list
14
+ * @param getModelTypeProperty Optional callback function to get model type property
15
+ * @returns Processed model list (including image generation models)
16
+ */
17
+ export async function postProcessModelList(
18
+ models: ChatModelCard[],
19
+ getModelTypeProperty?: (modelId: string) => Promise<AiModelType>,
20
+ ): Promise<ChatModelCard[]> {
21
+ // 1. Ensure all models have type field
22
+ const finalModels = await Promise.all(
23
+ models.map(async (model) => {
24
+ let modelType: AiModelType | undefined = model.type;
25
+
26
+ if (!modelType && getModelTypeProperty) {
27
+ modelType = await getModelTypeProperty(model.id);
28
+ }
29
+
30
+ return {
31
+ ...model,
32
+ type: modelType || 'chat',
33
+ };
34
+ }),
35
+ );
36
+
37
+ // 2. Check whitelist models and generate corresponding image versions
38
+ const imageModels: ChatModelCard[] = [];
39
+
40
+ for (const whitelistPattern of IMAGE_GENERATION_MODEL_WHITELIST) {
41
+ const matchingModels = finalModels.filter((model) => model.id.endsWith(whitelistPattern));
42
+
43
+ for (const model of matchingModels) {
44
+ imageModels.push({
45
+ ...model, // Reuse all configurations from the original model
46
+ id: `${model.id}:image`,
47
+ // Override to image type
48
+ parameters: CHAT_MODEL_IMAGE_GENERATION_PARAMS,
49
+ type: 'image', // Set image parameters
50
+ });
51
+ }
52
+ }
53
+
54
+ return [...finalModels, ...imageModels];
55
+ }
@@ -181,7 +181,7 @@ describe('GoogleGenerativeAIStream', () => {
181
181
  // usage
182
182
  'id: chat_1\n',
183
183
  'event: usage\n',
184
- `data: {"inputImageTokens":258,"inputTextTokens":8,"outputTextTokens":0,"totalInputTokens":266,"totalOutputTokens":0,"totalTokens":266}\n\n`,
184
+ `data: {"inputImageTokens":258,"inputTextTokens":8,"outputImageTokens":0,"outputTextTokens":0,"totalInputTokens":266,"totalOutputTokens":0,"totalTokens":266}\n\n`,
185
185
  ]);
186
186
  });
187
187
 
@@ -227,7 +227,7 @@ describe('GoogleGenerativeAIStream', () => {
227
227
  // usage
228
228
  'id: chat_1\n',
229
229
  'event: usage\n',
230
- `data: {"inputCachedTokens":14286,"inputTextTokens":15725,"outputTextTokens":1053,"totalInputTokens":15725,"totalOutputTokens":1053,"totalTokens":16778}\n\n`,
230
+ `data: {"inputCachedTokens":14286,"inputTextTokens":15725,"outputImageTokens":0,"outputTextTokens":1053,"totalInputTokens":15725,"totalOutputTokens":1053,"totalTokens":16778}\n\n`,
231
231
  ]);
232
232
  });
233
233
 
@@ -316,7 +316,7 @@ describe('GoogleGenerativeAIStream', () => {
316
316
  // usage
317
317
  'id: chat_1',
318
318
  'event: usage',
319
- `data: {"inputTextTokens":19,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
319
+ `data: {"inputTextTokens":19,"outputImageTokens":0,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
320
320
  ].map((i) => i + '\n'),
321
321
  );
322
322
  });
@@ -409,7 +409,7 @@ describe('GoogleGenerativeAIStream', () => {
409
409
  // usage
410
410
  'id: chat_1',
411
411
  'event: usage',
412
- `data: {"inputTextTokens":19,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`,
412
+ `data: {"inputTextTokens":19,"outputImageTokens":0,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`,
413
413
  ].map((i) => i + '\n'),
414
414
  );
415
415
  });
@@ -542,7 +542,7 @@ describe('GoogleGenerativeAIStream', () => {
542
542
  // usage
543
543
  'id: chat_1',
544
544
  'event: usage',
545
- `data: {"inputTextTokens":38,"outputReasoningTokens":304,"outputTextTokens":19,"totalInputTokens":38,"totalOutputTokens":323,"totalTokens":361}\n`,
545
+ `data: {"inputTextTokens":38,"outputImageTokens":0,"outputReasoningTokens":304,"outputTextTokens":19,"totalInputTokens":38,"totalOutputTokens":323,"totalTokens":361}\n`,
546
546
  ].map((i) => i + '\n'),
547
547
  );
548
548
  });
@@ -662,7 +662,7 @@ describe('GoogleGenerativeAIStream', () => {
662
662
  // usage
663
663
  'id: chat_1',
664
664
  'event: usage',
665
- `data: {"inputTextTokens":19,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`,
665
+ `data: {"inputTextTokens":19,"outputImageTokens":0,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`,
666
666
  ].map((i) => i + '\n'),
667
667
  );
668
668
  });
@@ -811,7 +811,7 @@ describe('GoogleGenerativeAIStream', () => {
811
811
  // usage
812
812
  'id: chat_1',
813
813
  'event: usage',
814
- `data: {"inputTextTokens":9,"outputTextTokens":122,"totalInputTokens":9,"totalOutputTokens":122,"totalTokens":131}\n`,
814
+ `data: {"inputTextTokens":9,"outputImageTokens":0,"outputTextTokens":122,"totalInputTokens":9,"totalOutputTokens":122,"totalTokens":131}\n`,
815
815
  ].map((i) => i + '\n'),
816
816
  );
817
817
  });
@@ -57,8 +57,20 @@ const transformGoogleGenerativeAIStream = (
57
57
  if (candidate?.finishReason && usage) {
58
58
  // totalTokenCount = promptTokenCount + candidatesTokenCount + thoughtsTokenCount
59
59
  const reasoningTokens = usage.thoughtsTokenCount;
60
- const outputTextTokens = usage.candidatesTokenCount ?? 0;
61
- const totalOutputTokens = outputTextTokens + (reasoningTokens ?? 0);
60
+
61
+ const candidatesDetails = usage.candidatesTokensDetails;
62
+ const candidatesTotal =
63
+ usage.candidatesTokenCount ??
64
+ candidatesDetails?.reduce((s: number, i: any) => s + (i?.tokenCount ?? 0), 0) ??
65
+ 0;
66
+
67
+ const outputImageTokens =
68
+ candidatesDetails?.find((i: any) => i.modality === 'IMAGE')?.tokenCount ?? 0;
69
+ const outputTextTokens =
70
+ candidatesDetails?.find((i: any) => i.modality === 'TEXT')?.tokenCount ??
71
+ Math.max(0, candidatesTotal - outputImageTokens);
72
+
73
+ const totalOutputTokens = candidatesTotal + (reasoningTokens ?? 0);
62
74
 
63
75
  usageChunks.push(
64
76
  { data: candidate.finishReason, id: context?.id, type: 'stop' },
@@ -69,6 +81,7 @@ const transformGoogleGenerativeAIStream = (
69
81
  ?.tokenCount,
70
82
  inputTextTokens: usage.promptTokensDetails?.find((i) => i.modality === 'TEXT')
71
83
  ?.tokenCount,
84
+ outputImageTokens,
72
85
  outputReasoningTokens: reasoningTokens,
73
86
  outputTextTokens,
74
87
  totalInputTokens: usage.promptTokenCount,