@lobehub/chat 1.108.1 → 1.108.2
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/.cursor/rules/testing-guide/testing-guide.mdc +18 -0
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +2 -2
- package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx +15 -2
- package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx +30 -3
- package/src/components/Analytics/LobeAnalyticsProvider.tsx +10 -13
- package/src/components/Analytics/LobeAnalyticsProviderWrapper.tsx +16 -4
- package/src/libs/model-runtime/RouterRuntime/createRuntime.test.ts +538 -0
- package/src/libs/model-runtime/RouterRuntime/createRuntime.ts +50 -13
- package/src/libs/model-runtime/RouterRuntime/index.ts +1 -1
- package/src/libs/model-runtime/aihubmix/index.ts +10 -5
- package/src/libs/model-runtime/ppio/index.test.ts +3 -6
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +8 -6
- package/src/server/globalConfig/genServerAiProviderConfig.test.ts +22 -25
- package/src/server/globalConfig/genServerAiProviderConfig.ts +34 -22
- package/src/server/globalConfig/index.ts +1 -1
- package/src/server/services/discover/index.ts +11 -2
- package/src/services/chat.ts +1 -1
- package/src/store/aiInfra/slices/aiProvider/__tests__/action.test.ts +211 -0
- package/src/store/aiInfra/slices/aiProvider/action.ts +46 -35
- package/src/store/user/slices/modelList/action.test.ts +5 -5
- package/src/store/user/slices/modelList/action.ts +4 -4
- package/src/utils/getFallbackModelProperty.test.ts +52 -45
- package/src/utils/getFallbackModelProperty.ts +4 -3
- package/src/utils/parseModels.test.ts +107 -98
- package/src/utils/parseModels.ts +10 -8
@@ -0,0 +1,538 @@
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { LobeRuntimeAI } from '../BaseAI';
|
4
|
+
import { createRouterRuntime } from './createRuntime';
|
5
|
+
|
6
|
+
describe('createRouterRuntime', () => {
|
7
|
+
beforeEach(() => {
|
8
|
+
vi.resetModules();
|
9
|
+
});
|
10
|
+
|
11
|
+
describe('initialization', () => {
|
12
|
+
it('should throw error when routers array is empty', () => {
|
13
|
+
expect(() => {
|
14
|
+
const Runtime = createRouterRuntime({
|
15
|
+
id: 'test-runtime',
|
16
|
+
routers: [],
|
17
|
+
});
|
18
|
+
new Runtime();
|
19
|
+
}).toThrow('empty providers');
|
20
|
+
});
|
21
|
+
|
22
|
+
it('should create UniformRuntime class with valid routers', () => {
|
23
|
+
class MockRuntime implements LobeRuntimeAI {
|
24
|
+
chat = vi.fn();
|
25
|
+
textToImage = vi.fn();
|
26
|
+
models = vi.fn();
|
27
|
+
embeddings = vi.fn();
|
28
|
+
textToSpeech = vi.fn();
|
29
|
+
}
|
30
|
+
|
31
|
+
const Runtime = createRouterRuntime({
|
32
|
+
id: 'test-runtime',
|
33
|
+
routers: [
|
34
|
+
{
|
35
|
+
apiType: 'openai',
|
36
|
+
options: { apiKey: 'test-key' },
|
37
|
+
runtime: MockRuntime as any,
|
38
|
+
models: ['gpt-4', 'gpt-3.5-turbo'],
|
39
|
+
},
|
40
|
+
],
|
41
|
+
});
|
42
|
+
|
43
|
+
const runtime = new Runtime();
|
44
|
+
expect(runtime).toBeDefined();
|
45
|
+
});
|
46
|
+
|
47
|
+
it('should merge router options with constructor options', () => {
|
48
|
+
const mockConstructor = vi.fn();
|
49
|
+
|
50
|
+
class MockRuntime implements LobeRuntimeAI {
|
51
|
+
constructor(options: any) {
|
52
|
+
mockConstructor(options);
|
53
|
+
}
|
54
|
+
chat = vi.fn();
|
55
|
+
}
|
56
|
+
|
57
|
+
const Runtime = createRouterRuntime({
|
58
|
+
id: 'test-runtime',
|
59
|
+
routers: [
|
60
|
+
{
|
61
|
+
apiType: 'openai',
|
62
|
+
options: { baseURL: 'https://api.example.com' },
|
63
|
+
runtime: MockRuntime as any,
|
64
|
+
},
|
65
|
+
],
|
66
|
+
});
|
67
|
+
|
68
|
+
new Runtime({ apiKey: 'constructor-key' });
|
69
|
+
|
70
|
+
expect(mockConstructor).toHaveBeenCalledWith(
|
71
|
+
expect.objectContaining({
|
72
|
+
baseURL: 'https://api.example.com',
|
73
|
+
apiKey: 'constructor-key',
|
74
|
+
id: 'test-runtime',
|
75
|
+
}),
|
76
|
+
);
|
77
|
+
});
|
78
|
+
});
|
79
|
+
|
80
|
+
describe('getModels', () => {
|
81
|
+
it('should return synchronous models array directly', async () => {
|
82
|
+
const mockRuntime = {
|
83
|
+
chat: vi.fn(),
|
84
|
+
} as unknown as LobeRuntimeAI;
|
85
|
+
|
86
|
+
const Runtime = createRouterRuntime({
|
87
|
+
id: 'test-runtime',
|
88
|
+
routers: [
|
89
|
+
{
|
90
|
+
apiType: 'openai',
|
91
|
+
options: {},
|
92
|
+
runtime: mockRuntime.constructor as any,
|
93
|
+
models: ['model-1', 'model-2'],
|
94
|
+
},
|
95
|
+
],
|
96
|
+
});
|
97
|
+
|
98
|
+
const runtime = new Runtime();
|
99
|
+
const models = await runtime['getModels']({
|
100
|
+
id: 'test',
|
101
|
+
models: ['model-1', 'model-2'],
|
102
|
+
runtime: mockRuntime,
|
103
|
+
});
|
104
|
+
|
105
|
+
expect(models).toEqual(['model-1', 'model-2']);
|
106
|
+
});
|
107
|
+
|
108
|
+
it('should call and cache asynchronous models function', async () => {
|
109
|
+
const mockRuntime = {
|
110
|
+
chat: vi.fn(),
|
111
|
+
} as unknown as LobeRuntimeAI;
|
112
|
+
|
113
|
+
const mockModelsFunction = vi.fn().mockResolvedValue(['async-model-1', 'async-model-2']);
|
114
|
+
|
115
|
+
const Runtime = createRouterRuntime({
|
116
|
+
id: 'test-runtime',
|
117
|
+
routers: [
|
118
|
+
{
|
119
|
+
apiType: 'openai',
|
120
|
+
options: {},
|
121
|
+
runtime: mockRuntime.constructor as any,
|
122
|
+
models: mockModelsFunction,
|
123
|
+
},
|
124
|
+
],
|
125
|
+
});
|
126
|
+
|
127
|
+
const runtime = new Runtime();
|
128
|
+
const runtimeItem = {
|
129
|
+
id: 'test',
|
130
|
+
models: mockModelsFunction,
|
131
|
+
runtime: mockRuntime,
|
132
|
+
};
|
133
|
+
|
134
|
+
// First call
|
135
|
+
const models1 = await runtime['getModels'](runtimeItem);
|
136
|
+
expect(models1).toEqual(['async-model-1', 'async-model-2']);
|
137
|
+
expect(mockModelsFunction).toHaveBeenCalledTimes(1);
|
138
|
+
|
139
|
+
// Second call should use cache
|
140
|
+
const models2 = await runtime['getModels'](runtimeItem);
|
141
|
+
expect(models2).toEqual(['async-model-1', 'async-model-2']);
|
142
|
+
expect(mockModelsFunction).toHaveBeenCalledTimes(1);
|
143
|
+
});
|
144
|
+
|
145
|
+
it('should return empty array when models is undefined', async () => {
|
146
|
+
const mockRuntime = {
|
147
|
+
chat: vi.fn(),
|
148
|
+
} as unknown as LobeRuntimeAI;
|
149
|
+
|
150
|
+
const Runtime = createRouterRuntime({
|
151
|
+
id: 'test-runtime',
|
152
|
+
routers: [
|
153
|
+
{
|
154
|
+
apiType: 'openai',
|
155
|
+
options: {},
|
156
|
+
runtime: mockRuntime.constructor as any,
|
157
|
+
},
|
158
|
+
],
|
159
|
+
});
|
160
|
+
|
161
|
+
const runtime = new Runtime();
|
162
|
+
const models = await runtime['getModels']({
|
163
|
+
id: 'test',
|
164
|
+
runtime: mockRuntime,
|
165
|
+
});
|
166
|
+
|
167
|
+
expect(models).toEqual([]);
|
168
|
+
});
|
169
|
+
});
|
170
|
+
|
171
|
+
describe('getRuntimeByModel', () => {
|
172
|
+
it('should return runtime that supports the model', async () => {
|
173
|
+
class MockRuntime1 implements LobeRuntimeAI {
|
174
|
+
chat = vi.fn();
|
175
|
+
}
|
176
|
+
|
177
|
+
class MockRuntime2 implements LobeRuntimeAI {
|
178
|
+
chat = vi.fn();
|
179
|
+
}
|
180
|
+
|
181
|
+
const Runtime = createRouterRuntime({
|
182
|
+
id: 'test-runtime',
|
183
|
+
routers: [
|
184
|
+
{
|
185
|
+
apiType: 'openai',
|
186
|
+
options: {},
|
187
|
+
runtime: MockRuntime1 as any,
|
188
|
+
models: ['gpt-4'],
|
189
|
+
},
|
190
|
+
{
|
191
|
+
apiType: 'anthropic',
|
192
|
+
options: {},
|
193
|
+
runtime: MockRuntime2 as any,
|
194
|
+
models: ['claude-3'],
|
195
|
+
},
|
196
|
+
],
|
197
|
+
});
|
198
|
+
|
199
|
+
const runtime = new Runtime();
|
200
|
+
|
201
|
+
const result1 = await runtime.getRuntimeByModel('gpt-4');
|
202
|
+
expect(result1).toBe(runtime['_runtimes'][0].runtime);
|
203
|
+
|
204
|
+
const result2 = await runtime.getRuntimeByModel('claude-3');
|
205
|
+
expect(result2).toBe(runtime['_runtimes'][1].runtime);
|
206
|
+
});
|
207
|
+
|
208
|
+
it('should return last runtime when no model matches', async () => {
|
209
|
+
class MockRuntime1 implements LobeRuntimeAI {
|
210
|
+
chat = vi.fn();
|
211
|
+
}
|
212
|
+
|
213
|
+
class MockRuntime2 implements LobeRuntimeAI {
|
214
|
+
chat = vi.fn();
|
215
|
+
}
|
216
|
+
|
217
|
+
const Runtime = createRouterRuntime({
|
218
|
+
id: 'test-runtime',
|
219
|
+
routers: [
|
220
|
+
{
|
221
|
+
apiType: 'openai',
|
222
|
+
options: {},
|
223
|
+
runtime: MockRuntime1 as any,
|
224
|
+
models: ['gpt-4'],
|
225
|
+
},
|
226
|
+
{
|
227
|
+
apiType: 'anthropic',
|
228
|
+
options: {},
|
229
|
+
runtime: MockRuntime2 as any,
|
230
|
+
models: ['claude-3'],
|
231
|
+
},
|
232
|
+
],
|
233
|
+
});
|
234
|
+
|
235
|
+
const runtime = new Runtime();
|
236
|
+
const result = await runtime.getRuntimeByModel('unknown-model');
|
237
|
+
|
238
|
+
expect(result).toBe(runtime['_runtimes'][1].runtime);
|
239
|
+
});
|
240
|
+
});
|
241
|
+
|
242
|
+
describe('chat method', () => {
|
243
|
+
it('should call chat on the correct runtime based on model', async () => {
|
244
|
+
const mockChat = vi.fn().mockResolvedValue('chat-response');
|
245
|
+
|
246
|
+
class MockRuntime implements LobeRuntimeAI {
|
247
|
+
chat = mockChat;
|
248
|
+
}
|
249
|
+
|
250
|
+
const Runtime = createRouterRuntime({
|
251
|
+
id: 'test-runtime',
|
252
|
+
routers: [
|
253
|
+
{
|
254
|
+
apiType: 'openai',
|
255
|
+
options: {},
|
256
|
+
runtime: MockRuntime as any,
|
257
|
+
models: ['gpt-4'],
|
258
|
+
},
|
259
|
+
],
|
260
|
+
});
|
261
|
+
|
262
|
+
const runtime = new Runtime();
|
263
|
+
const payload = { model: 'gpt-4', messages: [], temperature: 0.7 };
|
264
|
+
|
265
|
+
const result = await runtime.chat(payload);
|
266
|
+
expect(result).toBe('chat-response');
|
267
|
+
expect(mockChat).toHaveBeenCalledWith(payload, undefined);
|
268
|
+
});
|
269
|
+
|
270
|
+
it('should handle errors when provided with handleError', async () => {
|
271
|
+
const mockError = new Error('API Error');
|
272
|
+
const mockChat = vi.fn().mockRejectedValue(mockError);
|
273
|
+
|
274
|
+
class MockRuntime implements LobeRuntimeAI {
|
275
|
+
chat = mockChat;
|
276
|
+
}
|
277
|
+
|
278
|
+
const handleError = vi.fn().mockReturnValue({
|
279
|
+
errorType: 'APIError',
|
280
|
+
message: 'Handled error',
|
281
|
+
});
|
282
|
+
|
283
|
+
const Runtime = createRouterRuntime({
|
284
|
+
id: 'test-runtime',
|
285
|
+
routers: [
|
286
|
+
{
|
287
|
+
apiType: 'openai',
|
288
|
+
options: {},
|
289
|
+
runtime: MockRuntime as any,
|
290
|
+
models: ['gpt-4'],
|
291
|
+
},
|
292
|
+
],
|
293
|
+
});
|
294
|
+
|
295
|
+
const runtime = new Runtime({
|
296
|
+
chat: {
|
297
|
+
handleError,
|
298
|
+
},
|
299
|
+
});
|
300
|
+
|
301
|
+
await expect(
|
302
|
+
runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 }),
|
303
|
+
).rejects.toEqual({
|
304
|
+
errorType: 'APIError',
|
305
|
+
message: 'Handled error',
|
306
|
+
});
|
307
|
+
});
|
308
|
+
|
309
|
+
it('should re-throw original error when handleError returns undefined', async () => {
|
310
|
+
const mockError = new Error('API Error');
|
311
|
+
const mockChat = vi.fn().mockRejectedValue(mockError);
|
312
|
+
|
313
|
+
class MockRuntime implements LobeRuntimeAI {
|
314
|
+
chat = mockChat;
|
315
|
+
}
|
316
|
+
|
317
|
+
const handleError = vi.fn().mockReturnValue(undefined);
|
318
|
+
|
319
|
+
const Runtime = createRouterRuntime({
|
320
|
+
id: 'test-runtime',
|
321
|
+
routers: [
|
322
|
+
{
|
323
|
+
apiType: 'openai',
|
324
|
+
options: {},
|
325
|
+
runtime: MockRuntime as any,
|
326
|
+
models: ['gpt-4'],
|
327
|
+
},
|
328
|
+
],
|
329
|
+
});
|
330
|
+
|
331
|
+
const runtime = new Runtime({
|
332
|
+
chat: {
|
333
|
+
handleError,
|
334
|
+
},
|
335
|
+
});
|
336
|
+
|
337
|
+
await expect(runtime.chat({ model: 'gpt-4', messages: [], temperature: 0.7 })).rejects.toBe(
|
338
|
+
mockError,
|
339
|
+
);
|
340
|
+
});
|
341
|
+
});
|
342
|
+
|
343
|
+
describe('textToImage method', () => {
|
344
|
+
it('should call textToImage on the correct runtime based on model', async () => {
|
345
|
+
const mockTextToImage = vi.fn().mockResolvedValue('image-response');
|
346
|
+
|
347
|
+
class MockRuntime implements LobeRuntimeAI {
|
348
|
+
textToImage = mockTextToImage;
|
349
|
+
}
|
350
|
+
|
351
|
+
const Runtime = createRouterRuntime({
|
352
|
+
id: 'test-runtime',
|
353
|
+
routers: [
|
354
|
+
{
|
355
|
+
apiType: 'openai',
|
356
|
+
options: {},
|
357
|
+
runtime: MockRuntime as any,
|
358
|
+
models: ['dall-e-3'],
|
359
|
+
},
|
360
|
+
],
|
361
|
+
});
|
362
|
+
|
363
|
+
const runtime = new Runtime();
|
364
|
+
const payload = { model: 'dall-e-3', prompt: 'test prompt' };
|
365
|
+
|
366
|
+
const result = await runtime.textToImage(payload);
|
367
|
+
expect(result).toBe('image-response');
|
368
|
+
expect(mockTextToImage).toHaveBeenCalledWith(payload);
|
369
|
+
});
|
370
|
+
});
|
371
|
+
|
372
|
+
describe('models method', () => {
|
373
|
+
it('should call models method on first runtime', async () => {
|
374
|
+
const mockModels = vi.fn().mockResolvedValue(['model-1', 'model-2']);
|
375
|
+
|
376
|
+
class MockRuntime implements LobeRuntimeAI {
|
377
|
+
models = mockModels;
|
378
|
+
}
|
379
|
+
|
380
|
+
const Runtime = createRouterRuntime({
|
381
|
+
id: 'test-runtime',
|
382
|
+
routers: [
|
383
|
+
{
|
384
|
+
apiType: 'openai',
|
385
|
+
options: {},
|
386
|
+
runtime: MockRuntime as any,
|
387
|
+
},
|
388
|
+
],
|
389
|
+
});
|
390
|
+
|
391
|
+
const runtime = new Runtime();
|
392
|
+
const result = await runtime.models();
|
393
|
+
|
394
|
+
expect(result).toEqual(['model-1', 'model-2']);
|
395
|
+
expect(mockModels).toHaveBeenCalled();
|
396
|
+
});
|
397
|
+
});
|
398
|
+
|
399
|
+
describe('embeddings method', () => {
|
400
|
+
it('should call embeddings on the correct runtime based on model', async () => {
|
401
|
+
const mockEmbeddings = vi.fn().mockResolvedValue('embeddings-response');
|
402
|
+
|
403
|
+
class MockRuntime implements LobeRuntimeAI {
|
404
|
+
embeddings = mockEmbeddings;
|
405
|
+
}
|
406
|
+
|
407
|
+
const Runtime = createRouterRuntime({
|
408
|
+
id: 'test-runtime',
|
409
|
+
routers: [
|
410
|
+
{
|
411
|
+
apiType: 'openai',
|
412
|
+
options: {},
|
413
|
+
runtime: MockRuntime as any,
|
414
|
+
models: ['text-embedding-ada-002'],
|
415
|
+
},
|
416
|
+
],
|
417
|
+
});
|
418
|
+
|
419
|
+
const runtime = new Runtime();
|
420
|
+
const payload = { model: 'text-embedding-ada-002', input: 'test input' };
|
421
|
+
const options = {} as any;
|
422
|
+
|
423
|
+
const result = await runtime.embeddings(payload, options);
|
424
|
+
expect(result).toBe('embeddings-response');
|
425
|
+
expect(mockEmbeddings).toHaveBeenCalledWith(payload, options);
|
426
|
+
});
|
427
|
+
});
|
428
|
+
|
429
|
+
describe('textToSpeech method', () => {
|
430
|
+
it('should call textToSpeech on the correct runtime based on model', async () => {
|
431
|
+
const mockTextToSpeech = vi.fn().mockResolvedValue('speech-response');
|
432
|
+
|
433
|
+
class MockRuntime implements LobeRuntimeAI {
|
434
|
+
textToSpeech = mockTextToSpeech;
|
435
|
+
}
|
436
|
+
|
437
|
+
const Runtime = createRouterRuntime({
|
438
|
+
id: 'test-runtime',
|
439
|
+
routers: [
|
440
|
+
{
|
441
|
+
apiType: 'openai',
|
442
|
+
options: {},
|
443
|
+
runtime: MockRuntime as any,
|
444
|
+
models: ['tts-1'],
|
445
|
+
},
|
446
|
+
],
|
447
|
+
});
|
448
|
+
|
449
|
+
const runtime = new Runtime();
|
450
|
+
const payload = { model: 'tts-1', input: 'Hello world', voice: 'alloy' };
|
451
|
+
const options = {} as any;
|
452
|
+
|
453
|
+
const result = await runtime.textToSpeech(payload, options);
|
454
|
+
expect(result).toBe('speech-response');
|
455
|
+
expect(mockTextToSpeech).toHaveBeenCalledWith(payload, options);
|
456
|
+
});
|
457
|
+
});
|
458
|
+
|
459
|
+
describe('clearModelCache method', () => {
|
460
|
+
it('should clear specific runtime cache when runtimeId provided', async () => {
|
461
|
+
const mockModelsFunction = vi.fn().mockResolvedValue(['model-1']);
|
462
|
+
|
463
|
+
const Runtime = createRouterRuntime({
|
464
|
+
id: 'test-runtime',
|
465
|
+
routers: [
|
466
|
+
{
|
467
|
+
apiType: 'openai',
|
468
|
+
options: {},
|
469
|
+
runtime: vi.fn() as any,
|
470
|
+
models: mockModelsFunction,
|
471
|
+
},
|
472
|
+
],
|
473
|
+
});
|
474
|
+
|
475
|
+
const runtime = new Runtime();
|
476
|
+
const runtimeItem = {
|
477
|
+
id: 'test-id',
|
478
|
+
models: mockModelsFunction,
|
479
|
+
runtime: {} as any,
|
480
|
+
};
|
481
|
+
|
482
|
+
// Build cache
|
483
|
+
await runtime['getModels'](runtimeItem);
|
484
|
+
expect(mockModelsFunction).toHaveBeenCalledTimes(1);
|
485
|
+
|
486
|
+
// Clear specific cache
|
487
|
+
runtime.clearModelCache('test-id');
|
488
|
+
|
489
|
+
// Should call function again
|
490
|
+
await runtime['getModels'](runtimeItem);
|
491
|
+
expect(mockModelsFunction).toHaveBeenCalledTimes(2);
|
492
|
+
});
|
493
|
+
|
494
|
+
it('should clear all cache when no runtimeId provided', async () => {
|
495
|
+
const mockModelsFunction1 = vi.fn().mockResolvedValue(['model-1']);
|
496
|
+
const mockModelsFunction2 = vi.fn().mockResolvedValue(['model-2']);
|
497
|
+
|
498
|
+
const Runtime = createRouterRuntime({
|
499
|
+
id: 'test-runtime',
|
500
|
+
routers: [
|
501
|
+
{
|
502
|
+
apiType: 'openai',
|
503
|
+
options: {},
|
504
|
+
runtime: vi.fn() as any,
|
505
|
+
models: mockModelsFunction1,
|
506
|
+
},
|
507
|
+
],
|
508
|
+
});
|
509
|
+
|
510
|
+
const runtime = new Runtime();
|
511
|
+
const runtimeItem1 = {
|
512
|
+
id: 'test-id-1',
|
513
|
+
models: mockModelsFunction1,
|
514
|
+
runtime: {} as any,
|
515
|
+
};
|
516
|
+
const runtimeItem2 = {
|
517
|
+
id: 'test-id-2',
|
518
|
+
models: mockModelsFunction2,
|
519
|
+
runtime: {} as any,
|
520
|
+
};
|
521
|
+
|
522
|
+
// Build cache for both items
|
523
|
+
await runtime['getModels'](runtimeItem1);
|
524
|
+
await runtime['getModels'](runtimeItem2);
|
525
|
+
expect(mockModelsFunction1).toHaveBeenCalledTimes(1);
|
526
|
+
expect(mockModelsFunction2).toHaveBeenCalledTimes(1);
|
527
|
+
|
528
|
+
// Clear all cache
|
529
|
+
runtime.clearModelCache();
|
530
|
+
|
531
|
+
// Should call functions again
|
532
|
+
await runtime['getModels'](runtimeItem1);
|
533
|
+
await runtime['getModels'](runtimeItem2);
|
534
|
+
expect(mockModelsFunction1).toHaveBeenCalledTimes(2);
|
535
|
+
expect(mockModelsFunction2).toHaveBeenCalledTimes(2);
|
536
|
+
});
|
537
|
+
});
|
538
|
+
});
|
@@ -28,7 +28,7 @@ import { baseRuntimeMap } from './baseRuntimeMap';
|
|
28
28
|
|
29
29
|
export interface RuntimeItem {
|
30
30
|
id: string;
|
31
|
-
models?: string[];
|
31
|
+
models?: string[] | (() => Promise<string[]>);
|
32
32
|
runtime: LobeRuntimeAI;
|
33
33
|
}
|
34
34
|
|
@@ -44,11 +44,13 @@ interface ProviderIniOptions extends Record<string, any> {
|
|
44
44
|
sessionToken?: string;
|
45
45
|
}
|
46
46
|
|
47
|
+
export type RuntimeClass = typeof LobeOpenAI;
|
48
|
+
|
47
49
|
interface RouterInstance {
|
48
50
|
apiType: keyof typeof baseRuntimeMap;
|
49
|
-
models?: string[];
|
51
|
+
models?: string[] | (() => Promise<string[]>);
|
50
52
|
options: ProviderIniOptions;
|
51
|
-
runtime?:
|
53
|
+
runtime?: RuntimeClass;
|
52
54
|
}
|
53
55
|
|
54
56
|
type ConstructorOptions<T extends Record<string, any> = any> = ClientOptions & T;
|
@@ -117,6 +119,7 @@ export const createRouterRuntime = ({
|
|
117
119
|
return class UniformRuntime implements LobeRuntimeAI {
|
118
120
|
private _runtimes: RuntimeItem[];
|
119
121
|
private _options: ClientOptions & Record<string, any>;
|
122
|
+
private _modelCache = new Map<string, string[]>();
|
120
123
|
|
121
124
|
constructor(options: ClientOptions & Record<string, any> = {}) {
|
122
125
|
const _options = {
|
@@ -142,18 +145,40 @@ export const createRouterRuntime = ({
|
|
142
145
|
this._options = _options;
|
143
146
|
}
|
144
147
|
|
145
|
-
//
|
146
|
-
|
147
|
-
const
|
148
|
-
|
149
|
-
|
148
|
+
// 获取 runtime 的 models 列表,支持同步数组和异步函数,带缓存机制
|
149
|
+
private async getModels(runtimeItem: RuntimeItem): Promise<string[]> {
|
150
|
+
const cacheKey = runtimeItem.id;
|
151
|
+
|
152
|
+
// 如果是同步数组,直接返回不需要缓存
|
153
|
+
if (typeof runtimeItem.models !== 'function') {
|
154
|
+
return runtimeItem.models || [];
|
155
|
+
}
|
156
|
+
|
157
|
+
// 检查缓存
|
158
|
+
if (this._modelCache.has(cacheKey)) {
|
159
|
+
return this._modelCache.get(cacheKey)!;
|
160
|
+
}
|
161
|
+
|
162
|
+
// 获取模型列表并缓存结果
|
163
|
+
const models = await runtimeItem.models();
|
164
|
+
this._modelCache.set(cacheKey, models);
|
165
|
+
return models;
|
166
|
+
}
|
150
167
|
|
151
|
-
|
168
|
+
// 检查下是否能匹配到特定模型,否则默认使用最后一个 runtime
|
169
|
+
async getRuntimeByModel(model: string) {
|
170
|
+
for (const runtimeItem of this._runtimes) {
|
171
|
+
const models = await this.getModels(runtimeItem);
|
172
|
+
if (models.includes(model)) {
|
173
|
+
return runtimeItem.runtime;
|
174
|
+
}
|
175
|
+
}
|
176
|
+
return this._runtimes.at(-1)!.runtime;
|
152
177
|
}
|
153
178
|
|
154
179
|
async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
|
155
180
|
try {
|
156
|
-
const runtime = this.getRuntimeByModel(payload.model);
|
181
|
+
const runtime = await this.getRuntimeByModel(payload.model);
|
157
182
|
|
158
183
|
return await runtime.chat!(payload, options);
|
159
184
|
} catch (e) {
|
@@ -170,7 +195,7 @@ export const createRouterRuntime = ({
|
|
170
195
|
}
|
171
196
|
|
172
197
|
async textToImage(payload: TextToImagePayload) {
|
173
|
-
const runtime = this.getRuntimeByModel(payload.model);
|
198
|
+
const runtime = await this.getRuntimeByModel(payload.model);
|
174
199
|
|
175
200
|
return runtime.textToImage!(payload);
|
176
201
|
}
|
@@ -180,15 +205,27 @@ export const createRouterRuntime = ({
|
|
180
205
|
}
|
181
206
|
|
182
207
|
async embeddings(payload: EmbeddingsPayload, options?: EmbeddingsOptions) {
|
183
|
-
const runtime = this.getRuntimeByModel(payload.model);
|
208
|
+
const runtime = await this.getRuntimeByModel(payload.model);
|
184
209
|
|
185
210
|
return runtime.embeddings!(payload, options);
|
186
211
|
}
|
187
212
|
|
188
213
|
async textToSpeech(payload: TextToSpeechPayload, options?: EmbeddingsOptions) {
|
189
|
-
const runtime = this.getRuntimeByModel(payload.model);
|
214
|
+
const runtime = await this.getRuntimeByModel(payload.model);
|
190
215
|
|
191
216
|
return runtime.textToSpeech!(payload, options);
|
192
217
|
}
|
218
|
+
|
219
|
+
/**
|
220
|
+
* 清除模型列表缓存,强制下次获取时重新加载
|
221
|
+
* @param runtimeId - 可选,指定清除特定 runtime 的缓存,不传则清除所有缓存
|
222
|
+
*/
|
223
|
+
clearModelCache(runtimeId?: string) {
|
224
|
+
if (runtimeId) {
|
225
|
+
this._modelCache.delete(runtimeId);
|
226
|
+
} else {
|
227
|
+
this._modelCache.clear();
|
228
|
+
}
|
229
|
+
}
|
193
230
|
};
|
194
231
|
};
|
@@ -1,6 +1,5 @@
|
|
1
1
|
import urlJoin from 'url-join';
|
2
2
|
|
3
|
-
import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
|
4
3
|
import AiHubMixModels from '@/config/aiModels/aihubmix';
|
5
4
|
import type { ChatModelCard } from '@/types/llm';
|
6
5
|
|
@@ -98,14 +97,20 @@ export const LobeAiHubMixAI = createRouterRuntime({
|
|
98
97
|
routers: [
|
99
98
|
{
|
100
99
|
apiType: 'anthropic',
|
101
|
-
models:
|
102
|
-
|
103
|
-
|
100
|
+
models: async () => {
|
101
|
+
const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
|
102
|
+
return LOBE_DEFAULT_MODEL_LIST.map((m) => m.id).filter(
|
103
|
+
(id) => id.startsWith('claude') || id.startsWith('kimi-k2'),
|
104
|
+
);
|
105
|
+
},
|
104
106
|
options: { baseURL },
|
105
107
|
},
|
106
108
|
{
|
107
109
|
apiType: 'google',
|
108
|
-
models:
|
110
|
+
models: async () => {
|
111
|
+
const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
|
112
|
+
return LOBE_DEFAULT_MODEL_LIST.map((m) => m.id).filter((id) => id.startsWith('gemini'));
|
113
|
+
},
|
109
114
|
options: { baseURL: urlJoin(baseURL, '/gemini') },
|
110
115
|
},
|
111
116
|
{
|