@lobehub/chat 1.116.4 → 1.117.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 +92 -0
- package/changelog/v1.json +12 -0
- package/locales/ar/models.json +3 -0
- package/locales/bg-BG/models.json +3 -0
- package/locales/de-DE/models.json +3 -0
- package/locales/en-US/models.json +3 -0
- package/locales/es-ES/models.json +3 -0
- package/locales/fa-IR/models.json +3 -0
- package/locales/fr-FR/models.json +3 -0
- package/locales/it-IT/models.json +3 -0
- package/locales/ja-JP/models.json +3 -0
- package/locales/ko-KR/models.json +3 -0
- package/locales/nl-NL/models.json +3 -0
- package/locales/pl-PL/models.json +3 -0
- package/locales/pt-BR/models.json +3 -0
- package/locales/ru-RU/models.json +3 -0
- package/locales/tr-TR/models.json +3 -0
- package/locales/vi-VN/models.json +3 -0
- package/locales/zh-CN/models.json +3 -0
- package/locales/zh-TW/models.json +3 -0
- package/package.json +1 -1
- package/packages/const/src/image.ts +9 -0
- package/packages/database/vitest.config.mts +1 -0
- package/packages/database/vitest.config.server.mts +1 -0
- package/packages/file-loaders/package.json +1 -1
- package/packages/model-runtime/src/RouterRuntime/createRuntime.ts +11 -9
- package/packages/model-runtime/src/google/createImage.test.ts +657 -0
- package/packages/model-runtime/src/google/createImage.ts +152 -0
- package/packages/model-runtime/src/google/index.test.ts +0 -328
- package/packages/model-runtime/src/google/index.ts +3 -40
- package/packages/model-runtime/src/utils/modelParse.ts +2 -1
- package/packages/model-runtime/src/utils/openaiCompatibleFactory/createImage.ts +239 -0
- package/packages/model-runtime/src/utils/openaiCompatibleFactory/index.test.ts +22 -22
- package/packages/model-runtime/src/utils/openaiCompatibleFactory/index.ts +9 -116
- package/packages/model-runtime/src/utils/postProcessModelList.ts +55 -0
- package/packages/model-runtime/src/utils/streams/google-ai.test.ts +7 -7
- package/packages/model-runtime/src/utils/streams/google-ai.ts +15 -2
- package/packages/model-runtime/src/utils/streams/openai/openai.test.ts +41 -0
- package/packages/model-runtime/src/utils/streams/openai/openai.ts +38 -2
- package/packages/model-runtime/src/utils/streams/protocol.test.ts +32 -0
- package/packages/model-runtime/src/utils/streams/protocol.ts +7 -3
- package/packages/model-runtime/src/utils/usageConverter.test.ts +58 -0
- package/packages/model-runtime/src/utils/usageConverter.ts +5 -1
- package/packages/utils/vitest.config.mts +1 -0
- package/src/config/aiModels/google.ts +42 -22
- package/src/config/aiModels/openrouter.ts +33 -0
- package/src/config/aiModels/vertexai.ts +4 -4
- package/src/features/Conversation/Extras/Usage/UsageDetail/index.tsx +6 -0
- package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.test.ts +38 -0
- package/src/features/Conversation/Extras/Usage/UsageDetail/tokens.ts +13 -1
- package/src/locales/default/chat.ts +1 -0
- 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
|
-
//
|
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
|
-
//
|
549
|
-
//
|
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
|
-
//
|
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
|
-
//
|
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
|
-
//
|
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
|
-
//
|
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
|
-
//
|
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; //
|
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; //
|
950
|
+
mockDebugStream.toReadableStream = () => mockDebugStream; // Add toReadableStream method
|
951
951
|
|
952
|
-
//
|
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
|
-
//
|
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
|
-
//
|
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 {
|
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
|
-
|
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
|
-
//
|
338
|
-
|
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
|
493
|
-
|
494
|
-
|
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
|
-
|
61
|
-
const
|
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,
|