@lobehub/chat 1.108.1 → 1.109.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.
Files changed (28) hide show
  1. package/.cursor/rules/testing-guide/testing-guide.mdc +18 -0
  2. package/CHANGELOG.md +50 -0
  3. package/changelog/v1.json +18 -0
  4. package/package.json +2 -2
  5. package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/Checker.tsx +15 -2
  6. package/src/app/[variants]/(main)/settings/provider/features/ProviderConfig/index.tsx +30 -3
  7. package/src/components/Analytics/LobeAnalyticsProvider.tsx +10 -13
  8. package/src/components/Analytics/LobeAnalyticsProviderWrapper.tsx +16 -4
  9. package/src/config/aiModels/ollama.ts +27 -0
  10. package/src/libs/model-runtime/RouterRuntime/createRuntime.test.ts +538 -0
  11. package/src/libs/model-runtime/RouterRuntime/createRuntime.ts +50 -13
  12. package/src/libs/model-runtime/RouterRuntime/index.ts +1 -1
  13. package/src/libs/model-runtime/aihubmix/index.ts +10 -5
  14. package/src/libs/model-runtime/ppio/index.test.ts +3 -6
  15. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +8 -6
  16. package/src/server/globalConfig/genServerAiProviderConfig.test.ts +22 -25
  17. package/src/server/globalConfig/genServerAiProviderConfig.ts +34 -22
  18. package/src/server/globalConfig/index.ts +1 -1
  19. package/src/server/services/discover/index.ts +11 -2
  20. package/src/services/chat.ts +1 -1
  21. package/src/store/aiInfra/slices/aiProvider/__tests__/action.test.ts +211 -0
  22. package/src/store/aiInfra/slices/aiProvider/action.ts +46 -35
  23. package/src/store/user/slices/modelList/action.test.ts +5 -5
  24. package/src/store/user/slices/modelList/action.ts +4 -4
  25. package/src/utils/getFallbackModelProperty.test.ts +52 -45
  26. package/src/utils/getFallbackModelProperty.ts +4 -3
  27. package/src/utils/parseModels.test.ts +107 -98
  28. package/src/utils/parseModels.ts +10 -8
@@ -2,12 +2,11 @@ import { uniqBy } from 'lodash-es';
2
2
  import { SWRResponse, mutate } from 'swr';
3
3
  import { StateCreator } from 'zustand/vanilla';
4
4
 
5
- import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
6
5
  import { isDeprecatedEdition, isDesktop, isUsePgliteDB } from '@/const/version';
7
6
  import { useClientDataSWR } from '@/libs/swr';
8
7
  import { aiProviderService } from '@/services/aiProvider';
9
8
  import { AiInfraStore } from '@/store/aiInfra/store';
10
- import { AIImageModelCard, ModelAbilities } from '@/types/aiModel';
9
+ import { AIImageModelCard, LobeDefaultAiModelListItem, ModelAbilities } from '@/types/aiModel';
11
10
  import {
12
11
  AiProviderDetailItem,
13
12
  AiProviderListItem,
@@ -21,12 +20,37 @@ import {
21
20
  } from '@/types/aiProvider';
22
21
  import { getModelPropertyWithFallback } from '@/utils/getFallbackModelProperty';
23
22
 
23
+ /**
24
+ * Get models by provider ID and type, with proper formatting and deduplication
25
+ */
26
+ export const getModelListByType = (enabledAiModels: any[], providerId: string, type: string) => {
27
+ const models = enabledAiModels
28
+ .filter((model) => model.providerId === providerId && model.type === type)
29
+ .map((model) => ({
30
+ abilities: (model.abilities || {}) as ModelAbilities,
31
+ contextWindowTokens: model.contextWindowTokens,
32
+ displayName: model.displayName ?? '',
33
+ id: model.id,
34
+ ...(model.type === 'image' && {
35
+ parameters:
36
+ (model as AIImageModelCard).parameters ||
37
+ getModelPropertyWithFallback(model.id, 'parameters'),
38
+ }),
39
+ }));
40
+
41
+ return uniqBy(models, 'id');
42
+ };
43
+
24
44
  enum AiProviderSwrKey {
25
45
  fetchAiProviderItem = 'FETCH_AI_PROVIDER_ITEM',
26
46
  fetchAiProviderList = 'FETCH_AI_PROVIDER',
27
47
  fetchAiProviderRuntimeState = 'FETCH_AI_PROVIDER_RUNTIME_STATE',
28
48
  }
29
49
 
50
+ type AiProviderRuntimeStateWithBuiltinModels = AiProviderRuntimeState & {
51
+ builtinAiModelList: LobeDefaultAiModelListItem[];
52
+ };
53
+
30
54
  export interface AiProviderAction {
31
55
  createNewAiProvider: (params: CreateAiProviderParams) => Promise<void>;
32
56
  deleteAiProvider: (id: string) => Promise<void>;
@@ -49,7 +73,7 @@ export interface AiProviderAction {
49
73
  */
50
74
  useFetchAiProviderRuntimeState: (
51
75
  isLoginOnInit: boolean | undefined,
52
- ) => SWRResponse<AiProviderRuntimeState | undefined>;
76
+ ) => SWRResponse<AiProviderRuntimeStateWithBuiltinModels | undefined>;
53
77
  }
54
78
 
55
79
  export const createAiProviderSlice: StateCreator<
@@ -171,27 +195,35 @@ export const createAiProviderSlice: StateCreator<
171
195
  ),
172
196
 
173
197
  useFetchAiProviderRuntimeState: (isLogin) =>
174
- useClientDataSWR<AiProviderRuntimeState | undefined>(
198
+ useClientDataSWR<AiProviderRuntimeStateWithBuiltinModels | undefined>(
175
199
  !isDeprecatedEdition ? [AiProviderSwrKey.fetchAiProviderRuntimeState, isLogin] : null,
176
200
  async ([, isLogin]) => {
177
- if (isLogin) return aiProviderService.getAiProviderRuntimeState();
201
+ const [{ LOBE_DEFAULT_MODEL_LIST: builtinAiModelList }, { DEFAULT_MODEL_PROVIDER_LIST }] =
202
+ await Promise.all([import('@/config/aiModels'), import('@/config/modelProviders')]);
203
+
204
+ if (isLogin) {
205
+ const data = await aiProviderService.getAiProviderRuntimeState();
206
+ return {
207
+ ...data,
208
+ builtinAiModelList,
209
+ };
210
+ }
178
211
 
179
- const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
180
212
  const enabledAiProviders: EnabledProvider[] = DEFAULT_MODEL_PROVIDER_LIST.filter(
181
213
  (provider) => provider.enabled,
182
214
  ).map((item) => ({ id: item.id, name: item.name, source: 'builtin' }));
183
- const allModels = LOBE_DEFAULT_MODEL_LIST;
184
215
  return {
185
- enabledAiModels: allModels.filter((m) => m.enabled),
216
+ builtinAiModelList,
217
+ enabledAiModels: builtinAiModelList.filter((m) => m.enabled),
186
218
  enabledAiProviders: enabledAiProviders,
187
219
  enabledChatAiProviders: enabledAiProviders.filter((provider) => {
188
- return allModels.some(
220
+ return builtinAiModelList.some(
189
221
  (model) => model.providerId === provider.id && model.type === 'chat',
190
222
  );
191
223
  }),
192
224
  enabledImageAiProviders: enabledAiProviders
193
225
  .filter((provider) => {
194
- return allModels.some(
226
+ return builtinAiModelList.some(
195
227
  (model) => model.providerId === provider.id && model.type === 'image',
196
228
  );
197
229
  })
@@ -201,46 +233,25 @@ export const createAiProviderSlice: StateCreator<
201
233
  },
202
234
  {
203
235
  focusThrottleInterval: isDesktop || isUsePgliteDB ? 100 : undefined,
204
- onSuccess: async (data) => {
236
+ onSuccess: (data) => {
205
237
  if (!data) return;
206
238
 
207
- const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
208
-
209
- const getModelListByType = (providerId: string, type: string) => {
210
- const models = data.enabledAiModels
211
- .filter((model) => model.providerId === providerId && model.type === type)
212
- .map((model) => ({
213
- abilities: (model.abilities || {}) as ModelAbilities,
214
- contextWindowTokens: model.contextWindowTokens,
215
- displayName: model.displayName ?? '',
216
- id: model.id,
217
- ...(model.type === 'image' && {
218
- parameters:
219
- (model as AIImageModelCard).parameters ||
220
- getModelPropertyWithFallback(model.id, 'parameters'),
221
- }),
222
- }));
223
-
224
- return uniqBy(models, 'id');
225
- };
226
-
227
- // 3. 组装最终数据结构
228
239
  const enabledChatModelList = data.enabledChatAiProviders.map((provider) => ({
229
240
  ...provider,
230
- children: getModelListByType(provider.id, 'chat'),
241
+ children: getModelListByType(data.enabledAiModels, provider.id, 'chat'),
231
242
  name: provider.name || provider.id,
232
243
  }));
233
244
 
234
245
  const enabledImageModelList = data.enabledImageAiProviders.map((provider) => ({
235
246
  ...provider,
236
- children: getModelListByType(provider.id, 'image'),
247
+ children: getModelListByType(data.enabledAiModels, provider.id, 'image'),
237
248
  name: provider.name || provider.id,
238
249
  }));
239
250
 
240
251
  set(
241
252
  {
242
253
  aiProviderRuntimeConfig: data.runtimeConfig,
243
- builtinAiModelList: LOBE_DEFAULT_MODEL_LIST,
254
+ builtinAiModelList: data.builtinAiModelList,
244
255
  enabledAiModels: data.enabledAiModels,
245
256
  enabledAiProviders: data.enabledAiProviders,
246
257
  enabledChatModelList,
@@ -70,8 +70,8 @@ describe('LLMSettingsSliceAction', () => {
70
70
  });
71
71
  });
72
72
 
73
- act(() => {
74
- result.current.refreshDefaultModelProviderList();
73
+ await act(async () => {
74
+ await result.current.refreshDefaultModelProviderList();
75
75
  });
76
76
 
77
77
  // Assert that setModelProviderConfig was not called
@@ -79,7 +79,7 @@ describe('LLMSettingsSliceAction', () => {
79
79
  expect(azure?.chatModels).toEqual([{ id: 'abc', deploymentName: 'abc' }]);
80
80
  });
81
81
 
82
- it('openai', () => {
82
+ it('openai', async () => {
83
83
  const { result } = renderHook(() => useUserStore());
84
84
  act(() => {
85
85
  useUserStore.setState({
@@ -109,8 +109,8 @@ describe('LLMSettingsSliceAction', () => {
109
109
  });
110
110
  });
111
111
 
112
- act(() => {
113
- result.current.refreshDefaultModelProviderList();
112
+ await act(async () => {
113
+ await result.current.refreshDefaultModelProviderList();
114
114
  });
115
115
 
116
116
  // Assert that setModelProviderConfig was not called
@@ -2,7 +2,6 @@ import { produce } from 'immer';
2
2
  import useSWR, { SWRResponse } from 'swr';
3
3
  import type { StateCreator } from 'zustand/vanilla';
4
4
 
5
- import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
6
5
  import { ModelProvider } from '@/libs/model-runtime';
7
6
  import { UserStore } from '@/store/user';
8
7
  import type { ChatModelCard, ModelProviderCard } from '@/types/llm';
@@ -28,7 +27,7 @@ export interface ModelListAction {
28
27
  /**
29
28
  * make sure the default model provider list is sync to latest state
30
29
  */
31
- refreshDefaultModelProviderList: (params?: { trigger?: string }) => void;
30
+ refreshDefaultModelProviderList: (params?: { trigger?: string }) => Promise<void>;
32
31
  refreshModelProviderList: (params?: { trigger?: string }) => void;
33
32
  removeEnabledModels: (provider: GlobalLLMProviderKey, model: string) => Promise<void>;
34
33
  setModelProviderConfig: <T extends GlobalLLMProviderKey>(
@@ -69,7 +68,7 @@ export const createModelListSlice: StateCreator<
69
68
  remoteModelCards: [],
70
69
  });
71
70
 
72
- get().refreshDefaultModelProviderList();
71
+ await get().refreshDefaultModelProviderList();
73
72
  },
74
73
  dispatchCustomModelCards: async (provider, payload) => {
75
74
  const prevState = settingsSelectors.providerConfig(provider)(get());
@@ -80,7 +79,7 @@ export const createModelListSlice: StateCreator<
80
79
 
81
80
  await get().setModelProviderConfig(provider, { customModelCards: nextState });
82
81
  },
83
- refreshDefaultModelProviderList: (params) => {
82
+ refreshDefaultModelProviderList: async (params) => {
84
83
  /**
85
84
  * Because we have several model cards sources, we need to merge the model cards
86
85
  * the priority is below:
@@ -106,6 +105,7 @@ export const createModelListSlice: StateCreator<
106
105
  return providerCard.chatModels;
107
106
  };
108
107
 
108
+ const { DEFAULT_MODEL_PROVIDER_LIST } = await import('@/config/modelProviders');
109
109
  const defaultModelProviderList = produce(DEFAULT_MODEL_PROVIDER_LIST, (draft) => {
110
110
  Object.values(ModelProvider).forEach((id) => {
111
111
  const provider = draft.find((d) => d.id === id);
@@ -56,36 +56,36 @@ vi.mock('@/config/aiModels', () => ({
56
56
 
57
57
  describe('getModelPropertyWithFallback', () => {
58
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');
59
+ it('should return exact match value when model exists with specified provider', async () => {
60
+ const result = await getModelPropertyWithFallback('gpt-4', 'displayName', 'openai');
61
61
  expect(result).toBe('GPT-4');
62
62
  });
63
63
 
64
- it('should return exact match type when model exists with specified provider', () => {
65
- const result = getModelPropertyWithFallback('gpt-4', 'type', 'openai');
64
+ it('should return exact match type when model exists with specified provider', async () => {
65
+ const result = await getModelPropertyWithFallback('gpt-4', 'type', 'openai');
66
66
  expect(result).toBe('chat');
67
67
  });
68
68
 
69
- it('should return exact match contextWindowTokens when model exists with specified provider', () => {
70
- const result = getModelPropertyWithFallback('gpt-4', 'contextWindowTokens', 'azure');
69
+ it('should return exact match contextWindowTokens when model exists with specified provider', async () => {
70
+ const result = await getModelPropertyWithFallback('gpt-4', 'contextWindowTokens', 'azure');
71
71
  expect(result).toBe(8192);
72
72
  });
73
73
 
74
- it('should fall back to other provider when exact provider match not found', () => {
75
- const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
74
+ it('should fall back to other provider when exact provider match not found', async () => {
75
+ const result = await getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
76
76
  expect(result).toBe('GPT-4'); // Falls back to openai provider
77
77
  });
78
78
 
79
- it('should return nested property like abilities', () => {
80
- const result = getModelPropertyWithFallback('gpt-4', 'abilities', 'openai');
79
+ it('should return nested property like abilities', async () => {
80
+ const result = await getModelPropertyWithFallback('gpt-4', 'abilities', 'openai');
81
81
  expect(result).toEqual({
82
82
  functionCall: true,
83
83
  vision: true,
84
84
  });
85
85
  });
86
86
 
87
- it('should return parameters property correctly', () => {
88
- const result = getModelPropertyWithFallback('dall-e-3', 'parameters', 'openai');
87
+ it('should return parameters property correctly', async () => {
88
+ const result = await getModelPropertyWithFallback('dall-e-3', 'parameters', 'openai');
89
89
  expect(result).toEqual({
90
90
  size: '1024x1024',
91
91
  quality: 'standard',
@@ -94,99 +94,106 @@ describe('getModelPropertyWithFallback', () => {
94
94
  });
95
95
 
96
96
  describe('when providerId is not specified', () => {
97
- it('should return fallback match value when model exists', () => {
98
- const result = getModelPropertyWithFallback('claude-3', 'displayName');
97
+ it('should return fallback match value when model exists', async () => {
98
+ const result = await getModelPropertyWithFallback('claude-3', 'displayName');
99
99
  expect(result).toBe('Claude 3');
100
100
  });
101
101
 
102
- it('should return fallback match type when model exists', () => {
103
- const result = getModelPropertyWithFallback('claude-3', 'type');
102
+ it('should return fallback match type when model exists', async () => {
103
+ const result = await getModelPropertyWithFallback('claude-3', 'type');
104
104
  expect(result).toBe('chat');
105
105
  });
106
106
 
107
- it('should return fallback match enabled property', () => {
108
- const result = getModelPropertyWithFallback('claude-3', 'enabled');
107
+ it('should return fallback match enabled property', async () => {
108
+ const result = await getModelPropertyWithFallback('claude-3', 'enabled');
109
109
  expect(result).toBe(false);
110
110
  });
111
111
  });
112
112
 
113
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');
114
+ it('should return default value "chat" for type property', async () => {
115
+ const result = await getModelPropertyWithFallback('non-existent-model', 'type');
116
116
  expect(result).toBe('chat');
117
117
  });
118
118
 
119
- it('should return default value "chat" for type property even with providerId', () => {
120
- const result = getModelPropertyWithFallback('non-existent-model', 'type', 'fake-provider');
119
+ it('should return default value "chat" for type property even with providerId', async () => {
120
+ const result = await getModelPropertyWithFallback(
121
+ 'non-existent-model',
122
+ 'type',
123
+ 'fake-provider',
124
+ );
121
125
  expect(result).toBe('chat');
122
126
  });
123
127
 
124
- it('should return undefined for non-type properties when model not found', () => {
125
- const result = getModelPropertyWithFallback('non-existent-model', 'displayName');
128
+ it('should return undefined for non-type properties when model not found', async () => {
129
+ const result = await getModelPropertyWithFallback('non-existent-model', 'displayName');
126
130
  expect(result).toBeUndefined();
127
131
  });
128
132
 
129
- it('should return undefined for contextWindowTokens when model not found', () => {
130
- const result = getModelPropertyWithFallback('non-existent-model', 'contextWindowTokens');
133
+ it('should return undefined for contextWindowTokens when model not found', async () => {
134
+ const result = await getModelPropertyWithFallback(
135
+ 'non-existent-model',
136
+ 'contextWindowTokens',
137
+ );
131
138
  expect(result).toBeUndefined();
132
139
  });
133
140
 
134
- it('should return undefined for enabled property when model not found', () => {
135
- const result = getModelPropertyWithFallback('non-existent-model', 'enabled');
141
+ it('should return undefined for enabled property when model not found', async () => {
142
+ const result = await getModelPropertyWithFallback('non-existent-model', 'enabled');
136
143
  expect(result).toBeUndefined();
137
144
  });
138
145
  });
139
146
 
140
147
  describe('provider precedence logic', () => {
141
- it('should prioritize exact provider match over general match', () => {
148
+ it('should prioritize exact provider match over general match', async () => {
142
149
  // 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');
150
+ const openaiResult = await getModelPropertyWithFallback('gpt-4', 'displayName', 'openai');
151
+ const azureResult = await getModelPropertyWithFallback('gpt-4', 'displayName', 'azure');
145
152
 
146
153
  expect(openaiResult).toBe('GPT-4');
147
154
  expect(azureResult).toBe('GPT-4 Azure');
148
155
  });
149
156
 
150
- it('should fall back to first match when specified provider not found', () => {
157
+ it('should fall back to first match when specified provider not found', async () => {
151
158
  // When asking for 'fake-provider', should fall back to first match (openai)
152
- const result = getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
159
+ const result = await getModelPropertyWithFallback('gpt-4', 'displayName', 'fake-provider');
153
160
  expect(result).toBe('GPT-4');
154
161
  });
155
162
  });
156
163
 
157
164
  describe('property existence handling', () => {
158
- it('should handle undefined properties gracefully', () => {
165
+ it('should handle undefined properties gracefully', async () => {
159
166
  // claude-3 doesn't have abilities property defined
160
- const result = getModelPropertyWithFallback('claude-3', 'abilities');
167
+ const result = await getModelPropertyWithFallback('claude-3', 'abilities');
161
168
  expect(result).toBeUndefined();
162
169
  });
163
170
 
164
- it('should handle properties that exist but have falsy values', () => {
171
+ it('should handle properties that exist but have falsy values', async () => {
165
172
  // claude-3 has enabled: false
166
- const result = getModelPropertyWithFallback('claude-3', 'enabled');
173
+ const result = await getModelPropertyWithFallback('claude-3', 'enabled');
167
174
  expect(result).toBe(false);
168
175
  });
169
176
 
170
- it('should distinguish between undefined and null values', () => {
177
+ it('should distinguish between undefined and null values', async () => {
171
178
  // Testing that we check for undefined specifically, not just falsy values
172
- const result = getModelPropertyWithFallback('claude-3', 'contextWindowTokens');
179
+ const result = await getModelPropertyWithFallback('claude-3', 'contextWindowTokens');
173
180
  expect(result).toBe(200000); // Should find the defined value
174
181
  });
175
182
  });
176
183
 
177
184
  describe('edge cases', () => {
178
- it('should handle empty string modelId', () => {
179
- const result = getModelPropertyWithFallback('', 'type');
185
+ it('should handle empty string modelId', async () => {
186
+ const result = await getModelPropertyWithFallback('', 'type');
180
187
  expect(result).toBe('chat'); // Should fall back to default
181
188
  });
182
189
 
183
- it('should handle empty string providerId', () => {
184
- const result = getModelPropertyWithFallback('gpt-4', 'type', '');
190
+ it('should handle empty string providerId', async () => {
191
+ const result = await getModelPropertyWithFallback('gpt-4', 'type', '');
185
192
  expect(result).toBe('chat'); // Should still find the model via fallback
186
193
  });
187
194
 
188
- it('should handle case-sensitive modelId correctly', () => {
189
- const result = getModelPropertyWithFallback('GPT-4', 'type'); // Wrong case
195
+ it('should handle case-sensitive modelId correctly', async () => {
196
+ const result = await getModelPropertyWithFallback('GPT-4', 'type'); // Wrong case
190
197
  expect(result).toBe('chat'); // Should fall back to default since no match
191
198
  });
192
199
  });
@@ -1,4 +1,3 @@
1
- import { LOBE_DEFAULT_MODEL_LIST } from '@/config/aiModels';
2
1
  import { AiFullModelCard } from '@/types/aiModel';
3
2
 
4
3
  /**
@@ -8,11 +7,13 @@ import { AiFullModelCard } from '@/types/aiModel';
8
7
  * @param providerId Optional provider ID for an exact match.
9
8
  * @returns The property value or a default value.
10
9
  */
11
- export const getModelPropertyWithFallback = <T>(
10
+ export const getModelPropertyWithFallback = async <T>(
12
11
  modelId: string,
13
12
  propertyName: keyof AiFullModelCard,
14
13
  providerId?: string,
15
- ): T => {
14
+ ): Promise<T> => {
15
+ const { LOBE_DEFAULT_MODEL_LIST } = await import('@/config/aiModels');
16
+
16
17
  // Step 1: If providerId is provided, prioritize an exact match (same provider + same id)
17
18
  if (providerId) {
18
19
  const exactMatch = LOBE_DEFAULT_MODEL_LIST.find(