@lobehub/chat 1.100.0 → 1.100.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 +173 -0
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +18 -0
- package/docs/self-hosting/environment-variables/model-provider.mdx +25 -0
- package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +25 -0
- package/docs/self-hosting/faq/vercel-ai-image-timeout.mdx +65 -0
- package/docs/self-hosting/faq/vercel-ai-image-timeout.zh-CN.mdx +63 -0
- package/docs/usage/providers/fal.mdx +6 -6
- package/docs/usage/providers/fal.zh-CN.mdx +6 -6
- package/package.json +1 -1
- package/src/app/(backend)/middleware/auth/index.ts +28 -7
- package/src/const/auth.ts +1 -0
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +1 -1
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +2 -1
- package/src/libs/oidc-provider/jwt.ts +22 -13
- package/src/libs/trpc/lambda/context.ts +8 -2
- package/src/server/globalConfig/genServerAiProviderConfig.test.ts +235 -0
- package/src/server/globalConfig/genServerAiProviderConfig.ts +9 -10
- package/src/store/aiInfra/slices/aiProvider/action.ts +2 -1
- package/src/utils/getFallbackModelProperty.test.ts +193 -0
- package/src/utils/getFallbackModelProperty.ts +36 -0
- package/src/utils/parseModels.test.ts +150 -48
- package/src/utils/parseModels.ts +26 -11
@@ -41,10 +41,7 @@ export const getJWKS = (): object => {
|
|
41
41
|
}
|
42
42
|
};
|
43
43
|
|
44
|
-
|
45
|
-
* 从环境变量中获取 JWKS 并提取第一个 RSA 密钥
|
46
|
-
*/
|
47
|
-
const getJWKSPublicKey = async () => {
|
44
|
+
const getVerificationKey = async () => {
|
48
45
|
try {
|
49
46
|
const jwksString = oidcEnv.OIDC_JWKS_KEY;
|
50
47
|
|
@@ -58,20 +55,32 @@ const getJWKSPublicKey = async () => {
|
|
58
55
|
throw new Error('JWKS 格式无效: 缺少或为空的 keys 数组');
|
59
56
|
}
|
60
57
|
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
if (!rsaKey) {
|
58
|
+
const privateRsaKey = jwks.keys.find((key: any) => key.alg === 'RS256' && key.kty === 'RSA');
|
59
|
+
if (!privateRsaKey) {
|
65
60
|
throw new Error('JWKS 中没有找到 RS256 算法的 RSA 密钥');
|
66
61
|
}
|
67
62
|
|
68
|
-
//
|
69
|
-
|
63
|
+
// 创建一个只包含公钥组件的“纯净”JWK对象。
|
64
|
+
// RSA公钥的关键字段是 kty, n, e。其他如 kid, alg, use 也是公共的。
|
65
|
+
const publicKeyJwk = {
|
66
|
+
alg: privateRsaKey.alg,
|
67
|
+
e: privateRsaKey.e,
|
68
|
+
kid: privateRsaKey.kid,
|
69
|
+
kty: privateRsaKey.kty,
|
70
|
+
n: privateRsaKey.n,
|
71
|
+
use: privateRsaKey.use,
|
72
|
+
};
|
73
|
+
|
74
|
+
// 移除任何可能存在的 undefined 字段,保持对象干净
|
75
|
+
Object.keys(publicKeyJwk).forEach(
|
76
|
+
(key) => (publicKeyJwk as any)[key] === undefined && delete (publicKeyJwk as any)[key],
|
77
|
+
);
|
70
78
|
|
71
|
-
|
79
|
+
// 现在,无论在哪个环境下,`importJWK` 都会将这个对象正确地识别为一个公钥。
|
80
|
+
return await importJWK(publicKeyJwk, 'RS256');
|
72
81
|
} catch (error) {
|
73
82
|
log('获取 JWKS 公钥失败: %O', error);
|
74
|
-
throw new Error(`JWKS
|
83
|
+
throw new Error(`JWKS 公key获取失败: ${(error as Error).message}`);
|
75
84
|
}
|
76
85
|
};
|
77
86
|
|
@@ -85,7 +94,7 @@ export const validateOIDCJWT = async (token: string) => {
|
|
85
94
|
log('开始验证 OIDC JWT token');
|
86
95
|
|
87
96
|
// 获取公钥
|
88
|
-
const publicKey = await
|
97
|
+
const publicKey = await getVerificationKey();
|
89
98
|
|
90
99
|
// 验证 JWT
|
91
100
|
const { payload } = await jwtVerify(token, publicKey, {
|
@@ -3,7 +3,13 @@ import debug from 'debug';
|
|
3
3
|
import { User } from 'next-auth';
|
4
4
|
import { NextRequest } from 'next/server';
|
5
5
|
|
6
|
-
import {
|
6
|
+
import {
|
7
|
+
JWTPayload,
|
8
|
+
LOBE_CHAT_AUTH_HEADER,
|
9
|
+
LOBE_CHAT_OIDC_AUTH_HEADER,
|
10
|
+
enableClerk,
|
11
|
+
enableNextAuth,
|
12
|
+
} from '@/const/auth';
|
7
13
|
import { oidcEnv } from '@/envs/oidc';
|
8
14
|
import { ClerkAuth, IClerkAuth } from '@/libs/clerk-auth';
|
9
15
|
import { validateOIDCJWT } from '@/libs/oidc-provider/jwt';
|
@@ -102,7 +108,7 @@ export const createLambdaContext = async (request: NextRequest): Promise<LambdaC
|
|
102
108
|
if (oidcEnv.ENABLE_OIDC) {
|
103
109
|
log('OIDC enabled, attempting OIDC authentication');
|
104
110
|
const standardAuthorization = request.headers.get('Authorization');
|
105
|
-
const oidcAuthToken = request.headers.get(
|
111
|
+
const oidcAuthToken = request.headers.get(LOBE_CHAT_OIDC_AUTH_HEADER);
|
106
112
|
log('Standard Authorization header: %s', standardAuthorization ? 'exists' : 'not found');
|
107
113
|
log('Oidc-Auth header: %s', oidcAuthToken ? 'exists' : 'not found');
|
108
114
|
|
@@ -0,0 +1,235 @@
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { ModelProvider } from '@/libs/model-runtime';
|
4
|
+
import { AiFullModelCard } from '@/types/aiModel';
|
5
|
+
|
6
|
+
import { genServerAiProvidersConfig } from './genServerAiProviderConfig';
|
7
|
+
|
8
|
+
// Mock dependencies using importOriginal to preserve real provider data
|
9
|
+
vi.mock('@/config/aiModels', async (importOriginal) => {
|
10
|
+
const actual = await importOriginal<typeof import('@/config/aiModels')>();
|
11
|
+
return {
|
12
|
+
...actual,
|
13
|
+
// Keep the original exports but we can override specific ones if needed
|
14
|
+
};
|
15
|
+
});
|
16
|
+
|
17
|
+
vi.mock('@/config/llm', () => ({
|
18
|
+
getLLMConfig: vi.fn(() => ({
|
19
|
+
ENABLED_OPENAI: true,
|
20
|
+
ENABLED_ANTHROPIC: false,
|
21
|
+
ENABLED_AI21: false,
|
22
|
+
})),
|
23
|
+
}));
|
24
|
+
|
25
|
+
vi.mock('@/utils/parseModels', () => ({
|
26
|
+
extractEnabledModels: vi.fn((providerId: string, modelString?: string) => {
|
27
|
+
if (!modelString) return undefined;
|
28
|
+
return [`${providerId}-model-1`, `${providerId}-model-2`];
|
29
|
+
}),
|
30
|
+
transformToAiModelList: vi.fn((params) => {
|
31
|
+
return params.defaultModels;
|
32
|
+
}),
|
33
|
+
}));
|
34
|
+
|
35
|
+
describe('genServerAiProvidersConfig', () => {
|
36
|
+
beforeEach(() => {
|
37
|
+
vi.clearAllMocks();
|
38
|
+
// Clear environment variables
|
39
|
+
Object.keys(process.env).forEach((key) => {
|
40
|
+
if (key.includes('MODEL_LIST')) {
|
41
|
+
delete process.env[key];
|
42
|
+
}
|
43
|
+
});
|
44
|
+
});
|
45
|
+
|
46
|
+
it('should generate basic provider config with default settings', () => {
|
47
|
+
const result = genServerAiProvidersConfig({});
|
48
|
+
|
49
|
+
expect(result).toHaveProperty('openai');
|
50
|
+
expect(result).toHaveProperty('anthropic');
|
51
|
+
|
52
|
+
expect(result.openai).toEqual({
|
53
|
+
enabled: true,
|
54
|
+
enabledModels: undefined,
|
55
|
+
serverModelLists: expect.any(Array),
|
56
|
+
});
|
57
|
+
|
58
|
+
expect(result.anthropic).toEqual({
|
59
|
+
enabled: false,
|
60
|
+
enabledModels: undefined,
|
61
|
+
serverModelLists: expect.any(Array),
|
62
|
+
});
|
63
|
+
});
|
64
|
+
|
65
|
+
it('should use custom enabled settings from specificConfig', () => {
|
66
|
+
const specificConfig = {
|
67
|
+
openai: {
|
68
|
+
enabled: false,
|
69
|
+
},
|
70
|
+
anthropic: {
|
71
|
+
enabled: true,
|
72
|
+
},
|
73
|
+
};
|
74
|
+
|
75
|
+
const result = genServerAiProvidersConfig(specificConfig);
|
76
|
+
|
77
|
+
expect(result.openai.enabled).toBe(false);
|
78
|
+
expect(result.anthropic.enabled).toBe(true);
|
79
|
+
});
|
80
|
+
|
81
|
+
it('should use custom enabledKey from specificConfig', async () => {
|
82
|
+
const specificConfig = {
|
83
|
+
openai: {
|
84
|
+
enabledKey: 'CUSTOM_OPENAI_ENABLED',
|
85
|
+
},
|
86
|
+
};
|
87
|
+
|
88
|
+
// Mock the LLM config to include our custom key
|
89
|
+
const { getLLMConfig } = vi.mocked(await import('@/config/llm'));
|
90
|
+
getLLMConfig.mockReturnValue({
|
91
|
+
ENABLED_OPENAI: true,
|
92
|
+
ENABLED_ANTHROPIC: false,
|
93
|
+
CUSTOM_OPENAI_ENABLED: true,
|
94
|
+
} as any);
|
95
|
+
|
96
|
+
const result = genServerAiProvidersConfig(specificConfig);
|
97
|
+
|
98
|
+
expect(result.openai.enabled).toBe(true);
|
99
|
+
});
|
100
|
+
|
101
|
+
it('should use environment variables for model lists', async () => {
|
102
|
+
process.env.OPENAI_MODEL_LIST = '+gpt-4,+gpt-3.5-turbo';
|
103
|
+
|
104
|
+
const { extractEnabledModels } = vi.mocked(await import('@/utils/parseModels'));
|
105
|
+
extractEnabledModels.mockReturnValue(['gpt-4', 'gpt-3.5-turbo']);
|
106
|
+
|
107
|
+
const result = genServerAiProvidersConfig({});
|
108
|
+
|
109
|
+
expect(extractEnabledModels).toHaveBeenCalledWith('openai', '+gpt-4,+gpt-3.5-turbo', false);
|
110
|
+
expect(result.openai.enabledModels).toEqual(['gpt-4', 'gpt-3.5-turbo']);
|
111
|
+
});
|
112
|
+
|
113
|
+
it('should use custom modelListKey from specificConfig', async () => {
|
114
|
+
const specificConfig = {
|
115
|
+
openai: {
|
116
|
+
modelListKey: 'CUSTOM_OPENAI_MODELS',
|
117
|
+
},
|
118
|
+
};
|
119
|
+
|
120
|
+
process.env.CUSTOM_OPENAI_MODELS = '+custom-model';
|
121
|
+
|
122
|
+
const { extractEnabledModels } = vi.mocked(await import('@/utils/parseModels'));
|
123
|
+
|
124
|
+
genServerAiProvidersConfig(specificConfig);
|
125
|
+
|
126
|
+
expect(extractEnabledModels).toHaveBeenCalledWith('openai', '+custom-model', false);
|
127
|
+
});
|
128
|
+
|
129
|
+
it('should handle withDeploymentName option', async () => {
|
130
|
+
const specificConfig = {
|
131
|
+
openai: {
|
132
|
+
withDeploymentName: true,
|
133
|
+
},
|
134
|
+
};
|
135
|
+
|
136
|
+
process.env.OPENAI_MODEL_LIST = '+gpt-4->deployment1';
|
137
|
+
|
138
|
+
const { extractEnabledModels, transformToAiModelList } = vi.mocked(
|
139
|
+
await import('@/utils/parseModels'),
|
140
|
+
);
|
141
|
+
|
142
|
+
genServerAiProvidersConfig(specificConfig);
|
143
|
+
|
144
|
+
expect(extractEnabledModels).toHaveBeenCalledWith('openai', '+gpt-4->deployment1', true);
|
145
|
+
expect(transformToAiModelList).toHaveBeenCalledWith({
|
146
|
+
defaultModels: expect.any(Array),
|
147
|
+
modelString: '+gpt-4->deployment1',
|
148
|
+
providerId: 'openai',
|
149
|
+
withDeploymentName: true,
|
150
|
+
});
|
151
|
+
});
|
152
|
+
|
153
|
+
it('should include fetchOnClient when specified in config', () => {
|
154
|
+
const specificConfig = {
|
155
|
+
openai: {
|
156
|
+
fetchOnClient: true,
|
157
|
+
},
|
158
|
+
};
|
159
|
+
|
160
|
+
const result = genServerAiProvidersConfig(specificConfig);
|
161
|
+
|
162
|
+
expect(result.openai).toHaveProperty('fetchOnClient', true);
|
163
|
+
});
|
164
|
+
|
165
|
+
it('should not include fetchOnClient when not specified in config', () => {
|
166
|
+
const result = genServerAiProvidersConfig({});
|
167
|
+
|
168
|
+
expect(result.openai).not.toHaveProperty('fetchOnClient');
|
169
|
+
});
|
170
|
+
|
171
|
+
it('should handle all available providers', () => {
|
172
|
+
const result = genServerAiProvidersConfig({});
|
173
|
+
|
174
|
+
// Check that result includes some key providers
|
175
|
+
expect(result).toHaveProperty('openai');
|
176
|
+
expect(result).toHaveProperty('anthropic');
|
177
|
+
|
178
|
+
// Check structure for each provider
|
179
|
+
Object.keys(result).forEach((provider) => {
|
180
|
+
expect(result[provider]).toHaveProperty('enabled');
|
181
|
+
expect(result[provider]).toHaveProperty('serverModelLists');
|
182
|
+
// enabled can be boolean or undefined (when no config is provided)
|
183
|
+
expect(['boolean', 'undefined']).toContain(typeof result[provider].enabled);
|
184
|
+
expect(Array.isArray(result[provider].serverModelLists)).toBe(true);
|
185
|
+
});
|
186
|
+
});
|
187
|
+
});
|
188
|
+
|
189
|
+
describe('genServerAiProvidersConfig Error Handling', () => {
|
190
|
+
it('should throw error when a provider is not found in aiModels', async () => {
|
191
|
+
// Reset all mocks to create a clean test environment
|
192
|
+
vi.resetModules();
|
193
|
+
|
194
|
+
// Mock dependencies with a missing provider scenario
|
195
|
+
vi.doMock('@/config/aiModels', () => ({
|
196
|
+
// Explicitly set openai to undefined to simulate missing provider
|
197
|
+
openai: undefined,
|
198
|
+
anthropic: [
|
199
|
+
{
|
200
|
+
id: 'claude-3',
|
201
|
+
displayName: 'Claude 3',
|
202
|
+
type: 'chat',
|
203
|
+
enabled: true,
|
204
|
+
},
|
205
|
+
],
|
206
|
+
}));
|
207
|
+
|
208
|
+
vi.doMock('@/config/llm', () => ({
|
209
|
+
getLLMConfig: vi.fn(() => ({})),
|
210
|
+
}));
|
211
|
+
|
212
|
+
vi.doMock('@/utils/parseModels', () => ({
|
213
|
+
extractEnabledModels: vi.fn(() => undefined),
|
214
|
+
transformToAiModelList: vi.fn(() => []),
|
215
|
+
}));
|
216
|
+
|
217
|
+
// Mock ModelProvider to include the missing provider
|
218
|
+
vi.doMock('@/libs/model-runtime', () => ({
|
219
|
+
ModelProvider: {
|
220
|
+
openai: 'openai', // This exists in enum
|
221
|
+
anthropic: 'anthropic', // This exists in both enum and aiModels
|
222
|
+
},
|
223
|
+
}));
|
224
|
+
|
225
|
+
// Import the function with the new mocks
|
226
|
+
const { genServerAiProvidersConfig } = await import(
|
227
|
+
'./genServerAiProviderConfig?v=' + Date.now()
|
228
|
+
);
|
229
|
+
|
230
|
+
// This should throw because 'openai' is in ModelProvider but not in aiModels
|
231
|
+
expect(() => {
|
232
|
+
genServerAiProvidersConfig({});
|
233
|
+
}).toThrow();
|
234
|
+
});
|
235
|
+
});
|
@@ -3,7 +3,7 @@ import { getLLMConfig } from '@/config/llm';
|
|
3
3
|
import { ModelProvider } from '@/libs/model-runtime';
|
4
4
|
import { AiFullModelCard } from '@/types/aiModel';
|
5
5
|
import { ProviderConfig } from '@/types/user/settings';
|
6
|
-
import { extractEnabledModels,
|
6
|
+
import { extractEnabledModels, transformToAiModelList } from '@/utils/parseModels';
|
7
7
|
|
8
8
|
interface ProviderSpecificConfig {
|
9
9
|
enabled?: boolean;
|
@@ -19,19 +19,17 @@ export const genServerAiProvidersConfig = (specificConfig: Record<any, ProviderS
|
|
19
19
|
return Object.values(ModelProvider).reduce(
|
20
20
|
(config, provider) => {
|
21
21
|
const providerUpperCase = provider.toUpperCase();
|
22
|
-
const
|
22
|
+
const aiModels = AiModels[provider] as AiFullModelCard[];
|
23
23
|
|
24
|
-
if (!
|
24
|
+
if (!aiModels)
|
25
25
|
throw new Error(
|
26
26
|
`Provider [${provider}] not found in aiModels, please make sure you have exported the provider in the \`aiModels/index.ts\``,
|
27
27
|
);
|
28
28
|
|
29
29
|
const providerConfig = specificConfig[provider as keyof typeof specificConfig] || {};
|
30
|
-
const
|
30
|
+
const modelString =
|
31
31
|
process.env[providerConfig.modelListKey ?? `${providerUpperCase}_MODEL_LIST`];
|
32
32
|
|
33
|
-
const defaultChatModels = providerCard.filter((c) => c.type === 'chat');
|
34
|
-
|
35
33
|
config[provider] = {
|
36
34
|
enabled:
|
37
35
|
typeof providerConfig.enabled !== 'undefined'
|
@@ -39,12 +37,13 @@ export const genServerAiProvidersConfig = (specificConfig: Record<any, ProviderS
|
|
39
37
|
: llmConfig[providerConfig.enabledKey || `ENABLED_${providerUpperCase}`],
|
40
38
|
|
41
39
|
enabledModels: extractEnabledModels(
|
42
|
-
|
40
|
+
provider,
|
41
|
+
modelString,
|
43
42
|
providerConfig.withDeploymentName || false,
|
44
43
|
),
|
45
|
-
serverModelLists:
|
46
|
-
|
47
|
-
modelString
|
44
|
+
serverModelLists: transformToAiModelList({
|
45
|
+
defaultModels: aiModels || [],
|
46
|
+
modelString,
|
48
47
|
providerId: provider,
|
49
48
|
withDeploymentName: providerConfig.withDeploymentName || false,
|
50
49
|
}),
|
@@ -19,6 +19,7 @@ import {
|
|
19
19
|
UpdateAiProviderConfigParams,
|
20
20
|
UpdateAiProviderParams,
|
21
21
|
} from '@/types/aiProvider';
|
22
|
+
import { getModelPropertyWithFallback } from '@/utils/getFallbackModelProperty';
|
22
23
|
|
23
24
|
enum AiProviderSwrKey {
|
24
25
|
fetchAiProviderItem = 'FETCH_AI_PROVIDER_ITEM',
|
@@ -216,7 +217,7 @@ export const createAiProviderSlice: StateCreator<
|
|
216
217
|
...(model.type === 'image' && {
|
217
218
|
parameters:
|
218
219
|
(model as AIImageModelCard).parameters ||
|
219
|
-
|
220
|
+
getModelPropertyWithFallback(model.id, 'parameters'),
|
220
221
|
}),
|
221
222
|
}));
|
222
223
|
|
@@ -0,0 +1,193 @@
|
|
1
|
+
import { vi } from 'vitest';
|
2
|
+
|
3
|
+
import { getModelPropertyWithFallback } from './getFallbackModelProperty';
|
4
|
+
|
5
|
+
// Mock LOBE_DEFAULT_MODEL_LIST for testing
|
6
|
+
vi.mock('@/config/aiModels', () => ({
|
7
|
+
LOBE_DEFAULT_MODEL_LIST: [
|
8
|
+
{
|
9
|
+
id: 'gpt-4',
|
10
|
+
providerId: 'openai',
|
11
|
+
type: 'chat',
|
12
|
+
displayName: 'GPT-4',
|
13
|
+
contextWindowTokens: 8192,
|
14
|
+
enabled: true,
|
15
|
+
abilities: {
|
16
|
+
functionCall: true,
|
17
|
+
vision: true,
|
18
|
+
},
|
19
|
+
parameters: {
|
20
|
+
temperature: 0.7,
|
21
|
+
maxTokens: 4096,
|
22
|
+
},
|
23
|
+
},
|
24
|
+
{
|
25
|
+
id: 'gpt-4',
|
26
|
+
providerId: 'azure',
|
27
|
+
type: 'chat',
|
28
|
+
displayName: 'GPT-4 Azure',
|
29
|
+
contextWindowTokens: 8192,
|
30
|
+
enabled: true,
|
31
|
+
abilities: {
|
32
|
+
functionCall: true,
|
33
|
+
},
|
34
|
+
},
|
35
|
+
{
|
36
|
+
id: 'claude-3',
|
37
|
+
providerId: 'anthropic',
|
38
|
+
type: 'chat',
|
39
|
+
displayName: 'Claude 3',
|
40
|
+
contextWindowTokens: 200000,
|
41
|
+
enabled: false,
|
42
|
+
},
|
43
|
+
{
|
44
|
+
id: 'dall-e-3',
|
45
|
+
providerId: 'openai',
|
46
|
+
type: 'image',
|
47
|
+
displayName: 'DALL-E 3',
|
48
|
+
enabled: true,
|
49
|
+
parameters: {
|
50
|
+
size: '1024x1024',
|
51
|
+
quality: 'standard',
|
52
|
+
},
|
53
|
+
},
|
54
|
+
],
|
55
|
+
}));
|
56
|
+
|
57
|
+
describe('getModelPropertyWithFallback', () => {
|
58
|
+
describe('when providerId is specified', () => {
|
59
|
+
it('should return exact match value when model exists with specified provider', () => {
|
60
|
+
const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'openai');
|
61
|
+
expect(result).toBe('GPT-4');
|
62
|
+
});
|
63
|
+
|
64
|
+
it('should return exact match type when model exists with specified provider', () => {
|
65
|
+
const result = getModelPropertyWithFallback('gpt-4', 'type', 'openai');
|
66
|
+
expect(result).toBe('chat');
|
67
|
+
});
|
68
|
+
|
69
|
+
it('should return exact match contextWindowTokens when model exists with specified provider', () => {
|
70
|
+
const result = getModelPropertyWithFallback('gpt-4', 'contextWindowTokens', 'azure');
|
71
|
+
expect(result).toBe(8192);
|
72
|
+
});
|
73
|
+
|
74
|
+
it('should fall back to other provider when exact provider match not found', () => {
|
75
|
+
const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
|
76
|
+
expect(result).toBe('GPT-4'); // Falls back to openai provider
|
77
|
+
});
|
78
|
+
|
79
|
+
it('should return nested property like abilities', () => {
|
80
|
+
const result = getModelPropertyWithFallback('gpt-4', 'abilities', 'openai');
|
81
|
+
expect(result).toEqual({
|
82
|
+
functionCall: true,
|
83
|
+
vision: true,
|
84
|
+
});
|
85
|
+
});
|
86
|
+
|
87
|
+
it('should return parameters property correctly', () => {
|
88
|
+
const result = getModelPropertyWithFallback('dall-e-3', 'parameters', 'openai');
|
89
|
+
expect(result).toEqual({
|
90
|
+
size: '1024x1024',
|
91
|
+
quality: 'standard',
|
92
|
+
});
|
93
|
+
});
|
94
|
+
});
|
95
|
+
|
96
|
+
describe('when providerId is not specified', () => {
|
97
|
+
it('should return fallback match value when model exists', () => {
|
98
|
+
const result = getModelPropertyWithFallback('claude-3', 'displayName');
|
99
|
+
expect(result).toBe('Claude 3');
|
100
|
+
});
|
101
|
+
|
102
|
+
it('should return fallback match type when model exists', () => {
|
103
|
+
const result = getModelPropertyWithFallback('claude-3', 'type');
|
104
|
+
expect(result).toBe('chat');
|
105
|
+
});
|
106
|
+
|
107
|
+
it('should return fallback match enabled property', () => {
|
108
|
+
const result = getModelPropertyWithFallback('claude-3', 'enabled');
|
109
|
+
expect(result).toBe(false);
|
110
|
+
});
|
111
|
+
});
|
112
|
+
|
113
|
+
describe('when model is not found', () => {
|
114
|
+
it('should return default value "chat" for type property', () => {
|
115
|
+
const result = getModelPropertyWithFallback('non-existent-model', 'type');
|
116
|
+
expect(result).toBe('chat');
|
117
|
+
});
|
118
|
+
|
119
|
+
it('should return default value "chat" for type property even with providerId', () => {
|
120
|
+
const result = getModelPropertyWithFallback('non-existent-model', 'type', 'fake-provider');
|
121
|
+
expect(result).toBe('chat');
|
122
|
+
});
|
123
|
+
|
124
|
+
it('should return undefined for non-type properties when model not found', () => {
|
125
|
+
const result = getModelPropertyWithFallback('non-existent-model', 'displayName');
|
126
|
+
expect(result).toBeUndefined();
|
127
|
+
});
|
128
|
+
|
129
|
+
it('should return undefined for contextWindowTokens when model not found', () => {
|
130
|
+
const result = getModelPropertyWithFallback('non-existent-model', 'contextWindowTokens');
|
131
|
+
expect(result).toBeUndefined();
|
132
|
+
});
|
133
|
+
|
134
|
+
it('should return undefined for enabled property when model not found', () => {
|
135
|
+
const result = getModelPropertyWithFallback('non-existent-model', 'enabled');
|
136
|
+
expect(result).toBeUndefined();
|
137
|
+
});
|
138
|
+
});
|
139
|
+
|
140
|
+
describe('provider precedence logic', () => {
|
141
|
+
it('should prioritize exact provider match over general match', () => {
|
142
|
+
// gpt-4 exists in both openai and azure providers with different displayNames
|
143
|
+
const openaiResult = getModelPropertyWithFallback('gpt-4', 'displayName', 'openai');
|
144
|
+
const azureResult = getModelPropertyWithFallback('gpt-4', 'displayName', 'azure');
|
145
|
+
|
146
|
+
expect(openaiResult).toBe('GPT-4');
|
147
|
+
expect(azureResult).toBe('GPT-4 Azure');
|
148
|
+
});
|
149
|
+
|
150
|
+
it('should fall back to first match when specified provider not found', () => {
|
151
|
+
// When asking for 'fake-provider', should fall back to first match (openai)
|
152
|
+
const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
|
153
|
+
expect(result).toBe('GPT-4');
|
154
|
+
});
|
155
|
+
});
|
156
|
+
|
157
|
+
describe('property existence handling', () => {
|
158
|
+
it('should handle undefined properties gracefully', () => {
|
159
|
+
// claude-3 doesn't have abilities property defined
|
160
|
+
const result = getModelPropertyWithFallback('claude-3', 'abilities');
|
161
|
+
expect(result).toBeUndefined();
|
162
|
+
});
|
163
|
+
|
164
|
+
it('should handle properties that exist but have falsy values', () => {
|
165
|
+
// claude-3 has enabled: false
|
166
|
+
const result = getModelPropertyWithFallback('claude-3', 'enabled');
|
167
|
+
expect(result).toBe(false);
|
168
|
+
});
|
169
|
+
|
170
|
+
it('should distinguish between undefined and null values', () => {
|
171
|
+
// Testing that we check for undefined specifically, not just falsy values
|
172
|
+
const result = getModelPropertyWithFallback('claude-3', 'contextWindowTokens');
|
173
|
+
expect(result).toBe(200000); // Should find the defined value
|
174
|
+
});
|
175
|
+
});
|
176
|
+
|
177
|
+
describe('edge cases', () => {
|
178
|
+
it('should handle empty string modelId', () => {
|
179
|
+
const result = getModelPropertyWithFallback('', 'type');
|
180
|
+
expect(result).toBe('chat'); // Should fall back to default
|
181
|
+
});
|
182
|
+
|
183
|
+
it('should handle empty string providerId', () => {
|
184
|
+
const result = getModelPropertyWithFallback('gpt-4', 'type', '');
|
185
|
+
expect(result).toBe('chat'); // Should still find the model via fallback
|
186
|
+
});
|
187
|
+
|
188
|
+
it('should handle case-sensitive modelId correctly', () => {
|
189
|
+
const result = getModelPropertyWithFallback('GPT-4', 'type'); // Wrong case
|
190
|
+
expect(result).toBe('chat'); // Should fall back to default since no match
|
191
|
+
});
|
192
|
+
});
|
193
|
+
});
|
@@ -0,0 +1,36 @@
|
|
1
|
+
import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
|
2
|
+
import { AiFullModelCard } from '@/types/aiModel';
|
3
|
+
|
4
|
+
/**
|
5
|
+
* Get the model property value, first from the specified provider, and then from other providers as a fallback.
|
6
|
+
* @param modelId The ID of the model.
|
7
|
+
* @param propertyName The name of the property.
|
8
|
+
* @param providerId Optional provider ID for an exact match.
|
9
|
+
* @returns The property value or a default value.
|
10
|
+
*/
|
11
|
+
export const getModelPropertyWithFallback = <T>(
|
12
|
+
modelId: string,
|
13
|
+
propertyName: keyof AiFullModelCard,
|
14
|
+
providerId?: string,
|
15
|
+
): T => {
|
16
|
+
// Step 1: If providerId is provided, prioritize an exact match (same provider + same id)
|
17
|
+
if (providerId) {
|
18
|
+
const exactMatch = LOBE_DEFAULT_MODEL_LIST.find(
|
19
|
+
(m) => m.id === modelId && m.providerId === providerId,
|
20
|
+
);
|
21
|
+
|
22
|
+
if (exactMatch && exactMatch[propertyName] !== undefined) {
|
23
|
+
return exactMatch[propertyName] as T;
|
24
|
+
}
|
25
|
+
}
|
26
|
+
|
27
|
+
// Step 2: Fallback to a match ignoring the provider (match id only)
|
28
|
+
const fallbackMatch = LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === modelId);
|
29
|
+
|
30
|
+
if (fallbackMatch && fallbackMatch[propertyName] !== undefined) {
|
31
|
+
return fallbackMatch[propertyName] as T;
|
32
|
+
}
|
33
|
+
|
34
|
+
// Step 3: Return a default value
|
35
|
+
return (propertyName === 'type' ? 'chat' : undefined) as T;
|
36
|
+
};
|