@lobehub/chat 1.100.0 → 1.100.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.
@@ -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, transformToAiChatModelList } from '@/utils/parseModels';
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 providerCard = AiModels[provider] as AiFullModelCard[];
22
+ const aiModels = AiModels[provider] as AiFullModelCard[];
23
23
 
24
- if (!providerCard)
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 providerModelList =
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
- providerModelList,
40
+ provider,
41
+ modelString,
43
42
  providerConfig.withDeploymentName || false,
44
43
  ),
45
- serverModelLists: transformToAiChatModelList({
46
- defaultChatModels: defaultChatModels || [],
47
- modelString: providerModelList,
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
- LOBE_DEFAULT_MODEL_LIST.find((m) => m.id === model.id)?.parameters,
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
+ };