@lobehub/chat 1.53.5 → 1.53.6
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 +25 -0
- package/changelog/v1.json +9 -0
- package/docs/self-hosting/environment-variables/auth.mdx +10 -0
- package/docs/self-hosting/environment-variables/auth.zh-CN.mdx +7 -1
- package/package.json +3 -3
- package/src/database/repositories/aiInfra/index.test.ts +415 -0
- package/src/database/repositories/aiInfra/index.ts +11 -3
- package/src/libs/agent-runtime/providerTestUtils.test.ts +123 -0
- package/src/server/routers/lambda/aiProvider.test.ts +200 -0
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,31 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.53.6](https://github.com/lobehub/lobe-chat/compare/v1.53.5...v1.53.6)
|
6
|
+
|
7
|
+
<sup>Released on **2025-02-13**</sup>
|
8
|
+
|
9
|
+
#### 🐛 Bug Fixes
|
10
|
+
|
11
|
+
- **misc**: Fix not enable models correctly.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### What's fixed
|
19
|
+
|
20
|
+
- **misc**: Fix not enable models correctly, closes [#6071](https://github.com/lobehub/lobe-chat/issues/6071) ([b78328e](https://github.com/lobehub/lobe-chat/commit/b78328e))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
5
30
|
### [Version 1.53.5](https://github.com/lobehub/lobe-chat/compare/v1.53.4...v1.53.5)
|
6
31
|
|
7
32
|
<sup>Released on **2025-02-13**</sup>
|
package/changelog/v1.json
CHANGED
@@ -19,6 +19,16 @@ LobeChat provides a complete authentication service capability when deployed. Th
|
|
19
19
|
|
20
20
|
### General Settings
|
21
21
|
|
22
|
+
#### `NEXT_PUBLIC_ENABLE_NEXT_AUTH`
|
23
|
+
|
24
|
+
- Changes after v1.52.0.
|
25
|
+
|
26
|
+
- For users who deploy with Vercel using Next Auth, it is necessary to add the environment variable NEXT_PUBLIC_ENABLE_NEXT_AUTH=1 to ensure that Next Auth is enabled.
|
27
|
+
- For users who use Clerk in their self-built image, it is necessary to configure the environment variable NEXT_PUBLIC_ENABLE_NEXT_AUTH=0 to disable Next Auth.\n
|
28
|
+
- Other standard deployment scenarios (using Clerk on Vercel and next-auth in Docker) are not affected
|
29
|
+
|
30
|
+
|
31
|
+
|
22
32
|
#### `NEXT_AUTH_SECRET`
|
23
33
|
|
24
34
|
- Type: Required
|
@@ -17,6 +17,12 @@ LobeChat 在部署时提供了完善的身份验证服务能力,以下是相
|
|
17
17
|
|
18
18
|
### 通用设置
|
19
19
|
|
20
|
+
#### `NEXT_PUBLIC_ENABLE_NEXT_AUTH`
|
21
|
+
- v1.52.0 之后有变更
|
22
|
+
- 针对使用 Vercel 部署中使用 next-auth 的用户,需要额外添加 NEXT_PUBLIC_ENABLE_NEXT_AUTH=1 环境变量来确保开启 Next Auth
|
23
|
+
- 针对使用自构建镜像中使用 clerk 的用户,需要额外配置 NEXT_PUBLIC_ENABLE_NEXT_AUTH=0 环境变量来关闭 Next Auth
|
24
|
+
- 其他标准部署场景(Vercel 中使用 Clerk 与 Docker 中使用 next-auth )不受影响
|
25
|
+
|
20
26
|
#### `NEXT_AUTH_SECRET`
|
21
27
|
|
22
28
|
- 类型:必选
|
@@ -280,4 +286,4 @@ LobeChat 在部署时提供了完善的身份验证服务能力,以下是相
|
|
280
286
|
- 类型:必选
|
281
287
|
- 描述: Clerk 应用程序的 Secret key。您可以在[这里](https://dashboard.clerk.com)访问,并导航到 API Keys 以查看。
|
282
288
|
- 默认值:`-`
|
283
|
-
- 示例: `sk_test_513Ma0P7IAWM1XMv4waxZjRYRajWTaCfJLjpEO3SD2` (测试环境) / `sk_live_eMMlHjwJvZFUfczFljSKqZdwQtLvmczmsJSNmdrpeZ`(生产环境)
|
289
|
+
- 示例: `sk_test_513Ma0P7IAWM1XMv4waxZjRYRajWTaCfJLjpEO3SD2` (测试环境) / `sk_live_eMMlHjwJvZFUfczFljSKqZdwQtLvmczmsJSNmdrpeZ`(生产环境)
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.53.
|
3
|
+
"version": "1.53.6",
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
5
5
|
"keywords": [
|
6
6
|
"framework",
|
@@ -173,7 +173,7 @@
|
|
173
173
|
"langfuse": "3.29.1",
|
174
174
|
"langfuse-core": "3.29.1",
|
175
175
|
"lodash-es": "^4.17.21",
|
176
|
-
"lucide-react": "^0.
|
176
|
+
"lucide-react": "^0.475.0",
|
177
177
|
"mammoth": "^1.9.0",
|
178
178
|
"mdast-util-to-markdown": "^2.1.2",
|
179
179
|
"modern-screenshot": "^4.5.5",
|
@@ -245,7 +245,7 @@
|
|
245
245
|
"devDependencies": {
|
246
246
|
"@commitlint/cli": "^19.6.1",
|
247
247
|
"@edge-runtime/vm": "^5.0.0",
|
248
|
-
"@huggingface/tasks": "^0.
|
248
|
+
"@huggingface/tasks": "^0.15.0",
|
249
249
|
"@lobehub/i18n-cli": "^1.20.3",
|
250
250
|
"@lobehub/lint": "^1.25.5",
|
251
251
|
"@lobehub/seo-cli": "^1.4.3",
|
@@ -0,0 +1,415 @@
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders';
|
4
|
+
import { clientDB, initializeDB } from '@/database/client/db';
|
5
|
+
import { AiProviderModel } from '@/database/server/models/aiProvider';
|
6
|
+
import { LobeChatDatabase } from '@/database/type';
|
7
|
+
import { AiProviderModelListItem } from '@/types/aiModel';
|
8
|
+
import {
|
9
|
+
AiProviderDetailItem,
|
10
|
+
AiProviderListItem,
|
11
|
+
AiProviderRuntimeConfig,
|
12
|
+
EnabledAiModel,
|
13
|
+
EnabledProvider,
|
14
|
+
} from '@/types/aiProvider';
|
15
|
+
|
16
|
+
import { AiInfraRepos } from './index';
|
17
|
+
|
18
|
+
const userId = 'test-user-id';
|
19
|
+
const mockProviderConfigs = {
|
20
|
+
openai: { enabled: true },
|
21
|
+
anthropic: { enabled: false },
|
22
|
+
};
|
23
|
+
|
24
|
+
let repo: AiInfraRepos;
|
25
|
+
|
26
|
+
beforeEach(async () => {
|
27
|
+
await initializeDB();
|
28
|
+
vi.clearAllMocks();
|
29
|
+
|
30
|
+
repo = new AiInfraRepos(clientDB as any, userId, mockProviderConfigs);
|
31
|
+
});
|
32
|
+
|
33
|
+
describe('AiInfraRepos', () => {
|
34
|
+
describe('getAiProviderList', () => {
|
35
|
+
it('should merge builtin and user providers correctly', async () => {
|
36
|
+
const mockUserProviders = [
|
37
|
+
{ id: 'openai', enabled: true, name: 'Custom OpenAI' },
|
38
|
+
{ id: 'custom', enabled: true, name: 'Custom Provider' },
|
39
|
+
] as AiProviderListItem[];
|
40
|
+
|
41
|
+
vi.spyOn(repo.aiProviderModel, 'getAiProviderList').mockResolvedValueOnce(mockUserProviders);
|
42
|
+
|
43
|
+
const result = await repo.getAiProviderList();
|
44
|
+
|
45
|
+
expect(result).toBeDefined();
|
46
|
+
expect(result.length).toBeGreaterThan(0);
|
47
|
+
// Verify the merge logic
|
48
|
+
const openaiProvider = result.find((p) => p.id === 'openai');
|
49
|
+
expect(openaiProvider).toMatchObject({ enabled: true, name: 'Custom OpenAI' });
|
50
|
+
});
|
51
|
+
|
52
|
+
it('should sort providers according to DEFAULT_MODEL_PROVIDER_LIST order', async () => {
|
53
|
+
vi.spyOn(repo.aiProviderModel, 'getAiProviderList').mockResolvedValue([]);
|
54
|
+
|
55
|
+
const result = await repo.getAiProviderList();
|
56
|
+
|
57
|
+
expect(result).toEqual(
|
58
|
+
expect.arrayContaining(
|
59
|
+
DEFAULT_MODEL_PROVIDER_LIST.map((item) =>
|
60
|
+
expect.objectContaining({
|
61
|
+
id: item.id,
|
62
|
+
source: 'builtin',
|
63
|
+
}),
|
64
|
+
),
|
65
|
+
),
|
66
|
+
);
|
67
|
+
});
|
68
|
+
});
|
69
|
+
|
70
|
+
describe('getUserEnabledProviderList', () => {
|
71
|
+
it('should return only enabled providers', async () => {
|
72
|
+
const mockProviders = [
|
73
|
+
{ id: 'openai', enabled: true, name: 'OpenAI', sort: 1 },
|
74
|
+
{ id: 'anthropic', enabled: false, name: 'Anthropic', sort: 2 },
|
75
|
+
] as AiProviderListItem[];
|
76
|
+
|
77
|
+
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
78
|
+
|
79
|
+
const result = await repo.getUserEnabledProviderList();
|
80
|
+
|
81
|
+
expect(result).toHaveLength(1);
|
82
|
+
expect(result[0]).toMatchObject({
|
83
|
+
id: 'openai',
|
84
|
+
name: 'OpenAI',
|
85
|
+
});
|
86
|
+
});
|
87
|
+
|
88
|
+
it('should return only enabled provider', async () => {
|
89
|
+
const mockProviders = [
|
90
|
+
{
|
91
|
+
enabled: true,
|
92
|
+
id: 'openai',
|
93
|
+
logo: 'logo1',
|
94
|
+
name: 'OpenAI',
|
95
|
+
sort: 1,
|
96
|
+
source: 'builtin' as const,
|
97
|
+
},
|
98
|
+
{
|
99
|
+
enabled: false,
|
100
|
+
id: 'anthropic',
|
101
|
+
logo: 'logo2',
|
102
|
+
name: 'Anthropic',
|
103
|
+
sort: 2,
|
104
|
+
source: 'builtin' as const,
|
105
|
+
},
|
106
|
+
];
|
107
|
+
|
108
|
+
vi.spyOn(repo.aiProviderModel, 'getAiProviderList').mockResolvedValue(mockProviders);
|
109
|
+
|
110
|
+
const result = await repo.getUserEnabledProviderList();
|
111
|
+
|
112
|
+
expect(result).toEqual([
|
113
|
+
{
|
114
|
+
id: 'openai',
|
115
|
+
logo: 'logo1',
|
116
|
+
name: 'OpenAI',
|
117
|
+
source: 'builtin',
|
118
|
+
},
|
119
|
+
]);
|
120
|
+
});
|
121
|
+
});
|
122
|
+
|
123
|
+
describe('getEnabledModels', () => {
|
124
|
+
it('should merge and filter enabled models', async () => {
|
125
|
+
const mockProviders = [{ id: 'openai', enabled: true }] as AiProviderListItem[];
|
126
|
+
const mockAllModels = [
|
127
|
+
{ id: 'gpt-4', providerId: 'openai', enabled: true },
|
128
|
+
] as EnabledAiModel[];
|
129
|
+
|
130
|
+
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
131
|
+
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue(mockAllModels);
|
132
|
+
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
|
133
|
+
{ id: 'gpt-4', enabled: true, type: 'chat' },
|
134
|
+
]);
|
135
|
+
|
136
|
+
const result = await repo.getEnabledModels();
|
137
|
+
|
138
|
+
expect(result).toBeDefined();
|
139
|
+
expect(result.length).toBeGreaterThan(0);
|
140
|
+
expect(result[0]).toMatchObject({
|
141
|
+
id: 'gpt-4',
|
142
|
+
providerId: 'openai',
|
143
|
+
});
|
144
|
+
});
|
145
|
+
|
146
|
+
it('should merge builtin and user models correctly', async () => {
|
147
|
+
const mockProviders = [
|
148
|
+
{ enabled: true, id: 'openai', name: 'OpenAI', sort: 1, source: 'builtin' as const },
|
149
|
+
];
|
150
|
+
|
151
|
+
const mockAllModels = [
|
152
|
+
{
|
153
|
+
abilities: { vision: true },
|
154
|
+
displayName: 'Custom GPT-4',
|
155
|
+
enabled: true,
|
156
|
+
id: 'gpt-4',
|
157
|
+
providerId: 'openai',
|
158
|
+
sort: 1,
|
159
|
+
type: 'chat' as const,
|
160
|
+
contextWindowTokens: 10,
|
161
|
+
},
|
162
|
+
];
|
163
|
+
|
164
|
+
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
165
|
+
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue(mockAllModels);
|
166
|
+
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
|
167
|
+
{
|
168
|
+
abilities: {},
|
169
|
+
displayName: 'GPT-4',
|
170
|
+
enabled: true,
|
171
|
+
id: 'gpt-4',
|
172
|
+
type: 'chat' as const,
|
173
|
+
},
|
174
|
+
]);
|
175
|
+
|
176
|
+
const result = await repo.getEnabledModels();
|
177
|
+
|
178
|
+
expect(result).toContainEqual(
|
179
|
+
expect.objectContaining({
|
180
|
+
abilities: { vision: true },
|
181
|
+
displayName: 'Custom GPT-4',
|
182
|
+
enabled: true,
|
183
|
+
contextWindowTokens: 10,
|
184
|
+
id: 'gpt-4',
|
185
|
+
providerId: 'openai',
|
186
|
+
sort: 1,
|
187
|
+
type: 'chat',
|
188
|
+
}),
|
189
|
+
);
|
190
|
+
});
|
191
|
+
|
192
|
+
it('should handle case when user model not found', async () => {
|
193
|
+
const mockProviders = [
|
194
|
+
{ enabled: true, id: 'openai', name: 'OpenAI', sort: 1, source: 'builtin' as const },
|
195
|
+
];
|
196
|
+
|
197
|
+
const mockAllModels: any[] = [];
|
198
|
+
|
199
|
+
vi.spyOn(repo, 'getAiProviderList').mockResolvedValue(mockProviders);
|
200
|
+
vi.spyOn(repo.aiModelModel, 'getAllModels').mockResolvedValue(mockAllModels);
|
201
|
+
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue([
|
202
|
+
{
|
203
|
+
abilities: { reasoning: true },
|
204
|
+
displayName: 'GPT-4',
|
205
|
+
enabled: true,
|
206
|
+
id: 'gpt-4',
|
207
|
+
type: 'chat' as const,
|
208
|
+
},
|
209
|
+
]);
|
210
|
+
|
211
|
+
const result = await repo.getEnabledModels();
|
212
|
+
|
213
|
+
expect(result[0]).toEqual(
|
214
|
+
expect.objectContaining({
|
215
|
+
abilities: { reasoning: true },
|
216
|
+
enabled: true,
|
217
|
+
id: 'gpt-4',
|
218
|
+
providerId: 'openai',
|
219
|
+
}),
|
220
|
+
);
|
221
|
+
});
|
222
|
+
});
|
223
|
+
|
224
|
+
describe('getAiProviderModelList', () => {
|
225
|
+
it('should merge builtin and user models', async () => {
|
226
|
+
const providerId = 'openai';
|
227
|
+
const mockUserModels = [
|
228
|
+
{ id: 'custom-gpt4', enabled: true, type: 'chat' },
|
229
|
+
] as AiProviderModelListItem[];
|
230
|
+
const mockBuiltinModels = [{ id: 'gpt-4', enabled: true }];
|
231
|
+
|
232
|
+
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(mockUserModels);
|
233
|
+
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(mockBuiltinModels);
|
234
|
+
|
235
|
+
const result = await repo.getAiProviderModelList(providerId);
|
236
|
+
|
237
|
+
expect(result).toHaveLength(2);
|
238
|
+
expect(result).toEqual(
|
239
|
+
expect.arrayContaining([
|
240
|
+
expect.objectContaining({ id: 'custom-gpt4' }),
|
241
|
+
expect.objectContaining({ id: 'gpt-4' }),
|
242
|
+
]),
|
243
|
+
);
|
244
|
+
});
|
245
|
+
it('should merge default and custom models', async () => {
|
246
|
+
const mockCustomModels = [
|
247
|
+
{
|
248
|
+
displayName: 'Custom GPT-4',
|
249
|
+
enabled: false,
|
250
|
+
id: 'gpt-4',
|
251
|
+
type: 'chat' as const,
|
252
|
+
},
|
253
|
+
];
|
254
|
+
|
255
|
+
const mockDefaultModels = [
|
256
|
+
{
|
257
|
+
displayName: 'GPT-4',
|
258
|
+
enabled: true,
|
259
|
+
id: 'gpt-4',
|
260
|
+
type: 'chat' as const,
|
261
|
+
},
|
262
|
+
];
|
263
|
+
|
264
|
+
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue(mockCustomModels);
|
265
|
+
vi.spyOn(repo as any, 'fetchBuiltinModels').mockResolvedValue(mockDefaultModels);
|
266
|
+
|
267
|
+
const result = await repo.getAiProviderModelList('openai');
|
268
|
+
|
269
|
+
expect(result).toContainEqual(
|
270
|
+
expect.objectContaining({
|
271
|
+
displayName: 'Custom GPT-4',
|
272
|
+
enabled: false,
|
273
|
+
id: 'gpt-4',
|
274
|
+
}),
|
275
|
+
);
|
276
|
+
});
|
277
|
+
|
278
|
+
it('should use builtin models', async () => {
|
279
|
+
const providerId = 'taichu';
|
280
|
+
|
281
|
+
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue([]);
|
282
|
+
|
283
|
+
const result = await repo.getAiProviderModelList(providerId);
|
284
|
+
|
285
|
+
expect(result).toHaveLength(2);
|
286
|
+
expect(result).toEqual(
|
287
|
+
expect.arrayContaining([
|
288
|
+
expect.objectContaining({ id: 'taichu_llm' }),
|
289
|
+
expect.objectContaining({ id: 'taichu2_mm' }),
|
290
|
+
]),
|
291
|
+
);
|
292
|
+
});
|
293
|
+
|
294
|
+
it('should return empty if not exist provider', async () => {
|
295
|
+
const providerId = 'abc';
|
296
|
+
|
297
|
+
vi.spyOn(repo.aiModelModel, 'getModelListByProviderId').mockResolvedValue([]);
|
298
|
+
|
299
|
+
const result = await repo.getAiProviderModelList(providerId);
|
300
|
+
|
301
|
+
expect(result).toHaveLength(0);
|
302
|
+
});
|
303
|
+
});
|
304
|
+
|
305
|
+
describe('getAiProviderRuntimeState', () => {
|
306
|
+
it('should return complete runtime state', async () => {
|
307
|
+
const mockRuntimeConfig = {
|
308
|
+
openai: { apiKey: 'test-key' },
|
309
|
+
} as unknown as Record<string, AiProviderRuntimeConfig>;
|
310
|
+
const mockEnabledProviders = [{ id: 'openai', name: 'OpenAI' }] as EnabledProvider[];
|
311
|
+
const mockEnabledModels = [{ id: 'gpt-4', providerId: 'openai' }] as EnabledAiModel[];
|
312
|
+
|
313
|
+
vi.spyOn(repo.aiProviderModel, 'getAiProviderRuntimeConfig').mockResolvedValue(
|
314
|
+
mockRuntimeConfig,
|
315
|
+
);
|
316
|
+
vi.spyOn(repo, 'getUserEnabledProviderList').mockResolvedValue(mockEnabledProviders);
|
317
|
+
vi.spyOn(repo, 'getEnabledModels').mockResolvedValue(mockEnabledModels);
|
318
|
+
|
319
|
+
const result = await repo.getAiProviderRuntimeState();
|
320
|
+
|
321
|
+
expect(result).toMatchObject({
|
322
|
+
enabledAiProviders: mockEnabledProviders,
|
323
|
+
enabledAiModels: mockEnabledModels,
|
324
|
+
runtimeConfig: expect.any(Object),
|
325
|
+
});
|
326
|
+
});
|
327
|
+
it('should return provider runtime state', async () => {
|
328
|
+
const mockRuntimeConfig = {
|
329
|
+
openai: {
|
330
|
+
apiKey: 'test-key',
|
331
|
+
},
|
332
|
+
} as unknown as Record<string, AiProviderRuntimeConfig>;
|
333
|
+
|
334
|
+
vi.spyOn(repo.aiProviderModel, 'getAiProviderRuntimeConfig').mockResolvedValue(
|
335
|
+
mockRuntimeConfig,
|
336
|
+
);
|
337
|
+
|
338
|
+
vi.spyOn(repo, 'getUserEnabledProviderList').mockResolvedValue([
|
339
|
+
{ id: 'openai', logo: 'logo1', name: 'OpenAI', source: 'builtin' },
|
340
|
+
]);
|
341
|
+
|
342
|
+
vi.spyOn(repo, 'getEnabledModels').mockResolvedValue([
|
343
|
+
{
|
344
|
+
abilities: {},
|
345
|
+
enabled: true,
|
346
|
+
id: 'gpt-4',
|
347
|
+
providerId: 'openai',
|
348
|
+
type: 'chat',
|
349
|
+
},
|
350
|
+
]);
|
351
|
+
|
352
|
+
const result = await repo.getAiProviderRuntimeState();
|
353
|
+
|
354
|
+
expect(result).toEqual({
|
355
|
+
enabledAiModels: [
|
356
|
+
expect.objectContaining({
|
357
|
+
enabled: true,
|
358
|
+
id: 'gpt-4',
|
359
|
+
providerId: 'openai',
|
360
|
+
}),
|
361
|
+
],
|
362
|
+
enabledAiProviders: [{ id: 'openai', logo: 'logo1', name: 'OpenAI', source: 'builtin' }],
|
363
|
+
runtimeConfig: {
|
364
|
+
openai: {
|
365
|
+
apiKey: 'test-key',
|
366
|
+
enabled: true,
|
367
|
+
},
|
368
|
+
},
|
369
|
+
});
|
370
|
+
});
|
371
|
+
});
|
372
|
+
|
373
|
+
describe('getAiProviderDetail', () => {
|
374
|
+
it('should merge provider config with user settings', async () => {
|
375
|
+
const providerId = 'openai';
|
376
|
+
const mockProviderDetail = {
|
377
|
+
id: providerId,
|
378
|
+
customSetting: 'test',
|
379
|
+
} as unknown as AiProviderDetailItem;
|
380
|
+
|
381
|
+
vi.spyOn(repo.aiProviderModel, 'getAiProviderById').mockResolvedValue(mockProviderDetail);
|
382
|
+
|
383
|
+
const result = await repo.getAiProviderDetail(providerId);
|
384
|
+
|
385
|
+
expect(result).toMatchObject({
|
386
|
+
id: providerId,
|
387
|
+
customSetting: 'test',
|
388
|
+
enabled: true, // from mockProviderConfigs
|
389
|
+
});
|
390
|
+
});
|
391
|
+
it('should merge provider configs correctly', async () => {
|
392
|
+
const mockProviderDetail = {
|
393
|
+
enabled: true,
|
394
|
+
id: 'openai',
|
395
|
+
keyVaults: { apiKey: 'test-key' },
|
396
|
+
name: 'Custom OpenAI',
|
397
|
+
settings: {},
|
398
|
+
source: 'builtin' as const,
|
399
|
+
};
|
400
|
+
|
401
|
+
vi.spyOn(repo.aiProviderModel, 'getAiProviderById').mockResolvedValue(mockProviderDetail);
|
402
|
+
|
403
|
+
const result = await repo.getAiProviderDetail('openai');
|
404
|
+
|
405
|
+
expect(result).toEqual({
|
406
|
+
enabled: true,
|
407
|
+
id: 'openai',
|
408
|
+
keyVaults: { apiKey: 'test-key' },
|
409
|
+
name: 'Custom OpenAI',
|
410
|
+
settings: {},
|
411
|
+
source: 'builtin',
|
412
|
+
});
|
413
|
+
});
|
414
|
+
});
|
415
|
+
});
|
@@ -11,6 +11,7 @@ import {
|
|
11
11
|
AiProviderListItem,
|
12
12
|
AiProviderRuntimeState,
|
13
13
|
EnabledAiModel,
|
14
|
+
EnabledProvider,
|
14
15
|
} from '@/types/aiProvider';
|
15
16
|
import { ProviderConfig } from '@/types/user/settings';
|
16
17
|
import { merge, mergeArrayById } from '@/utils/merge';
|
@@ -22,7 +23,7 @@ export class AiInfraRepos {
|
|
22
23
|
private db: LobeChatDatabase;
|
23
24
|
aiProviderModel: AiProviderModel;
|
24
25
|
private providerConfigs: Record<string, ProviderConfig>;
|
25
|
-
|
26
|
+
aiModelModel: AiModelModel;
|
26
27
|
|
27
28
|
constructor(
|
28
29
|
db: LobeChatDatabase,
|
@@ -70,7 +71,14 @@ export class AiInfraRepos {
|
|
70
71
|
return list
|
71
72
|
.filter((item) => item.enabled)
|
72
73
|
.sort((a, b) => a.sort! - b.sort!)
|
73
|
-
.map(
|
74
|
+
.map(
|
75
|
+
(item): EnabledProvider => ({
|
76
|
+
id: item.id,
|
77
|
+
logo: item.logo,
|
78
|
+
name: item.name,
|
79
|
+
source: item.source,
|
80
|
+
}),
|
81
|
+
);
|
74
82
|
};
|
75
83
|
|
76
84
|
getEnabledModels = async () => {
|
@@ -104,7 +112,7 @@ export class AiInfraRepos {
|
|
104
112
|
? user.contextWindowTokens
|
105
113
|
: item.contextWindowTokens,
|
106
114
|
displayName: user?.displayName || item.displayName,
|
107
|
-
enabled: user.enabled
|
115
|
+
enabled: typeof user.enabled === 'boolean' ? user.enabled : item.enabled,
|
108
116
|
id: item.id,
|
109
117
|
providerId: provider.id,
|
110
118
|
sort: user.sort || undefined,
|
@@ -0,0 +1,123 @@
|
|
1
|
+
import OpenAI from 'openai';
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { testProvider } from './providerTestUtils';
|
5
|
+
|
6
|
+
describe('testProvider', () => {
|
7
|
+
it('should run provider tests correctly', () => {
|
8
|
+
class MockRuntime {
|
9
|
+
baseURL: string;
|
10
|
+
client: any;
|
11
|
+
|
12
|
+
constructor({
|
13
|
+
apiKey,
|
14
|
+
baseURL = 'https://default.test',
|
15
|
+
}: {
|
16
|
+
apiKey?: string;
|
17
|
+
baseURL?: string;
|
18
|
+
}) {
|
19
|
+
if (!apiKey) throw { errorType: 'InvalidAPIKey' };
|
20
|
+
this.baseURL = baseURL;
|
21
|
+
this.client = {
|
22
|
+
chat: {
|
23
|
+
completions: {
|
24
|
+
create: vi.fn().mockResolvedValue(new ReadableStream()),
|
25
|
+
},
|
26
|
+
},
|
27
|
+
};
|
28
|
+
}
|
29
|
+
|
30
|
+
async chat(params: any) {
|
31
|
+
return this.client.chat.completions.create(params);
|
32
|
+
}
|
33
|
+
}
|
34
|
+
|
35
|
+
testProvider({
|
36
|
+
Runtime: MockRuntime,
|
37
|
+
bizErrorType: 'TestBizError',
|
38
|
+
chatDebugEnv: 'TEST_DEBUG',
|
39
|
+
chatModel: 'test-model',
|
40
|
+
defaultBaseURL: 'https://default.test',
|
41
|
+
invalidErrorType: 'InvalidAPIKey',
|
42
|
+
provider: 'TestProvider',
|
43
|
+
});
|
44
|
+
});
|
45
|
+
|
46
|
+
it('should handle OpenAI API errors correctly', async () => {
|
47
|
+
class MockRuntime {
|
48
|
+
baseURL: string;
|
49
|
+
client: any;
|
50
|
+
|
51
|
+
constructor({ apiKey }: { apiKey?: string }) {
|
52
|
+
if (!apiKey) throw { errorType: 'InvalidAPIKey' };
|
53
|
+
this.baseURL = 'test';
|
54
|
+
this.client = {
|
55
|
+
chat: {
|
56
|
+
completions: {
|
57
|
+
create: vi.fn().mockRejectedValue(
|
58
|
+
new OpenAI.APIError(
|
59
|
+
400,
|
60
|
+
{
|
61
|
+
error: { message: 'Test Error' },
|
62
|
+
status: 400,
|
63
|
+
},
|
64
|
+
'Test Error',
|
65
|
+
{},
|
66
|
+
),
|
67
|
+
),
|
68
|
+
},
|
69
|
+
},
|
70
|
+
};
|
71
|
+
}
|
72
|
+
|
73
|
+
async chat(params: any) {
|
74
|
+
return this.client.chat.completions.create(params);
|
75
|
+
}
|
76
|
+
}
|
77
|
+
|
78
|
+
testProvider({
|
79
|
+
Runtime: MockRuntime,
|
80
|
+
bizErrorType: 'TestBizError',
|
81
|
+
chatDebugEnv: 'TEST_DEBUG',
|
82
|
+
chatModel: 'test-model',
|
83
|
+
defaultBaseURL: 'test',
|
84
|
+
invalidErrorType: 'InvalidAPIKey',
|
85
|
+
provider: 'TestProvider',
|
86
|
+
});
|
87
|
+
});
|
88
|
+
|
89
|
+
it('should handle debug stream correctly', () => {
|
90
|
+
class MockRuntime {
|
91
|
+
baseURL: string;
|
92
|
+
client: any;
|
93
|
+
|
94
|
+
constructor({ apiKey }: { apiKey?: string }) {
|
95
|
+
if (!apiKey) throw { errorType: 'InvalidAPIKey' };
|
96
|
+
this.baseURL = 'test';
|
97
|
+
this.client = {
|
98
|
+
chat: {
|
99
|
+
completions: {
|
100
|
+
create: vi.fn().mockResolvedValue({
|
101
|
+
tee: () => [new ReadableStream(), { toReadableStream: () => new ReadableStream() }],
|
102
|
+
}),
|
103
|
+
},
|
104
|
+
},
|
105
|
+
};
|
106
|
+
}
|
107
|
+
|
108
|
+
async chat(params: any) {
|
109
|
+
return this.client.chat.completions.create(params);
|
110
|
+
}
|
111
|
+
}
|
112
|
+
|
113
|
+
testProvider({
|
114
|
+
Runtime: MockRuntime,
|
115
|
+
bizErrorType: 'TestBizError',
|
116
|
+
chatDebugEnv: 'TEST_DEBUG',
|
117
|
+
chatModel: 'test-model',
|
118
|
+
defaultBaseURL: 'test',
|
119
|
+
invalidErrorType: 'InvalidAPIKey',
|
120
|
+
provider: 'TestProvider',
|
121
|
+
});
|
122
|
+
});
|
123
|
+
});
|
@@ -0,0 +1,200 @@
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { AiInfraRepos } from '@/database/repositories/aiInfra';
|
4
|
+
import { serverDB } from '@/database/server';
|
5
|
+
import { AiProviderModel } from '@/database/server/models/aiProvider';
|
6
|
+
import { UserModel } from '@/database/server/models/user';
|
7
|
+
import { getServerGlobalConfig } from '@/server/globalConfig';
|
8
|
+
import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
|
9
|
+
import { AiProviderDetailItem, AiProviderRuntimeState } from '@/types/aiProvider';
|
10
|
+
|
11
|
+
import { aiProviderRouter } from './aiProvider';
|
12
|
+
|
13
|
+
vi.mock('@/server/globalConfig');
|
14
|
+
vi.mock('@/server/modules/KeyVaultsEncrypt');
|
15
|
+
vi.mock('@/database/repositories/aiInfra');
|
16
|
+
vi.mock('@/database/server/models/aiProvider');
|
17
|
+
vi.mock('@/database/server/models/user');
|
18
|
+
|
19
|
+
describe('aiProviderRouter', () => {
|
20
|
+
const mockUserId = 'test-user-id';
|
21
|
+
const mockProviderId = 'test-provider-id';
|
22
|
+
const mockEncrypt = vi.fn();
|
23
|
+
const mockDecrypt = vi.fn();
|
24
|
+
|
25
|
+
const mockGateKeeper = {
|
26
|
+
encrypt: mockEncrypt,
|
27
|
+
decrypt: mockDecrypt,
|
28
|
+
};
|
29
|
+
|
30
|
+
const mockProviderDetail: AiProviderDetailItem = {
|
31
|
+
id: mockProviderId,
|
32
|
+
name: 'Test Provider',
|
33
|
+
enabled: true,
|
34
|
+
description: 'Test Description',
|
35
|
+
source: 'custom',
|
36
|
+
settings: {},
|
37
|
+
};
|
38
|
+
|
39
|
+
const mockRuntimeState: AiProviderRuntimeState = {
|
40
|
+
enabledAiModels: [],
|
41
|
+
enabledAiProviders: [],
|
42
|
+
runtimeConfig: {},
|
43
|
+
};
|
44
|
+
|
45
|
+
beforeEach(() => {
|
46
|
+
vi.clearAllMocks();
|
47
|
+
|
48
|
+
vi.mocked(getServerGlobalConfig).mockReturnValue({
|
49
|
+
aiProvider: {},
|
50
|
+
} as any);
|
51
|
+
|
52
|
+
vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValue(mockGateKeeper as any);
|
53
|
+
});
|
54
|
+
|
55
|
+
const createMockContext = () => ({
|
56
|
+
userId: mockUserId,
|
57
|
+
});
|
58
|
+
|
59
|
+
describe('createAiProvider', () => {
|
60
|
+
it('should create a new AI provider', async () => {
|
61
|
+
const mockCreate = vi.fn().mockResolvedValue({ id: mockProviderId });
|
62
|
+
vi.mocked(AiProviderModel).prototype.create = mockCreate;
|
63
|
+
|
64
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
65
|
+
const result = await caller.createAiProvider({
|
66
|
+
id: mockProviderId,
|
67
|
+
name: 'Test Provider',
|
68
|
+
source: 'custom',
|
69
|
+
});
|
70
|
+
|
71
|
+
expect(result).toBe(mockProviderId);
|
72
|
+
expect(mockCreate).toHaveBeenCalledWith(
|
73
|
+
expect.objectContaining({
|
74
|
+
id: mockProviderId,
|
75
|
+
name: 'Test Provider',
|
76
|
+
}),
|
77
|
+
mockGateKeeper.encrypt,
|
78
|
+
);
|
79
|
+
});
|
80
|
+
});
|
81
|
+
|
82
|
+
describe('getAiProviderById', () => {
|
83
|
+
it('should get AI provider by id', async () => {
|
84
|
+
const mockGetDetail = vi.fn().mockResolvedValue(mockProviderDetail);
|
85
|
+
vi.mocked(AiInfraRepos).prototype.getAiProviderDetail = mockGetDetail;
|
86
|
+
|
87
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
88
|
+
const result = await caller.getAiProviderById({ id: mockProviderId });
|
89
|
+
|
90
|
+
expect(result).toEqual(mockProviderDetail);
|
91
|
+
expect(mockGetDetail).toHaveBeenCalledWith(
|
92
|
+
mockProviderId,
|
93
|
+
KeyVaultsGateKeeper.getUserKeyVaults,
|
94
|
+
);
|
95
|
+
});
|
96
|
+
});
|
97
|
+
|
98
|
+
describe('getAiProviderList', () => {
|
99
|
+
it('should get AI provider list', async () => {
|
100
|
+
const mockList = [mockProviderDetail];
|
101
|
+
const mockGetList = vi.fn().mockResolvedValue(mockList);
|
102
|
+
vi.mocked(AiInfraRepos).prototype.getAiProviderList = mockGetList;
|
103
|
+
|
104
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
105
|
+
const result = await caller.getAiProviderList();
|
106
|
+
|
107
|
+
expect(result).toEqual(mockList);
|
108
|
+
expect(mockGetList).toHaveBeenCalled();
|
109
|
+
});
|
110
|
+
});
|
111
|
+
|
112
|
+
describe('getAiProviderRuntimeState', () => {
|
113
|
+
it('should get AI provider runtime state', async () => {
|
114
|
+
const mockGetState = vi.fn().mockResolvedValue(mockRuntimeState);
|
115
|
+
vi.mocked(AiInfraRepos).prototype.getAiProviderRuntimeState = mockGetState;
|
116
|
+
|
117
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
118
|
+
const result = await caller.getAiProviderRuntimeState({});
|
119
|
+
|
120
|
+
expect(result).toEqual(mockRuntimeState);
|
121
|
+
expect(mockGetState).toHaveBeenCalledWith(KeyVaultsGateKeeper.getUserKeyVaults);
|
122
|
+
});
|
123
|
+
});
|
124
|
+
|
125
|
+
describe('removeAiProvider', () => {
|
126
|
+
it('should remove AI provider', async () => {
|
127
|
+
const mockDelete = vi.fn();
|
128
|
+
vi.mocked(AiProviderModel).prototype.delete = mockDelete;
|
129
|
+
|
130
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
131
|
+
await caller.removeAiProvider({ id: mockProviderId });
|
132
|
+
|
133
|
+
expect(mockDelete).toHaveBeenCalledWith(mockProviderId);
|
134
|
+
});
|
135
|
+
});
|
136
|
+
|
137
|
+
describe('toggleProviderEnabled', () => {
|
138
|
+
it('should toggle provider enabled state', async () => {
|
139
|
+
const mockToggle = vi.fn();
|
140
|
+
vi.mocked(AiProviderModel).prototype.toggleProviderEnabled = mockToggle;
|
141
|
+
|
142
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
143
|
+
await caller.toggleProviderEnabled({
|
144
|
+
id: mockProviderId,
|
145
|
+
enabled: true,
|
146
|
+
});
|
147
|
+
|
148
|
+
expect(mockToggle).toHaveBeenCalledWith(mockProviderId, true);
|
149
|
+
});
|
150
|
+
});
|
151
|
+
|
152
|
+
describe('updateAiProvider', () => {
|
153
|
+
it('should update AI provider', async () => {
|
154
|
+
const mockUpdate = vi.fn();
|
155
|
+
vi.mocked(AiProviderModel).prototype.update = mockUpdate;
|
156
|
+
|
157
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
158
|
+
await caller.updateAiProvider({
|
159
|
+
id: mockProviderId,
|
160
|
+
value: { name: 'Updated Provider' },
|
161
|
+
});
|
162
|
+
|
163
|
+
expect(mockUpdate).toHaveBeenCalledWith(mockProviderId, {
|
164
|
+
name: 'Updated Provider',
|
165
|
+
});
|
166
|
+
});
|
167
|
+
});
|
168
|
+
|
169
|
+
describe('updateAiProviderConfig', () => {
|
170
|
+
it('should update AI provider config', async () => {
|
171
|
+
const mockUpdateConfig = vi.fn();
|
172
|
+
vi.mocked(AiProviderModel).prototype.updateConfig = mockUpdateConfig;
|
173
|
+
|
174
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
175
|
+
await caller.updateAiProviderConfig({
|
176
|
+
id: mockProviderId,
|
177
|
+
value: { checkModel: 'gpt-4' },
|
178
|
+
});
|
179
|
+
|
180
|
+
expect(mockUpdateConfig).toHaveBeenCalledWith(
|
181
|
+
mockProviderId,
|
182
|
+
{ checkModel: 'gpt-4' },
|
183
|
+
mockGateKeeper.encrypt,
|
184
|
+
);
|
185
|
+
});
|
186
|
+
});
|
187
|
+
|
188
|
+
describe('updateAiProviderOrder', () => {
|
189
|
+
it('should update AI provider order', async () => {
|
190
|
+
const mockUpdateOrder = vi.fn();
|
191
|
+
vi.mocked(AiProviderModel).prototype.updateOrder = mockUpdateOrder;
|
192
|
+
|
193
|
+
const sortMap = [{ id: mockProviderId, sort: 1 }];
|
194
|
+
const caller = aiProviderRouter.createCaller(createMockContext());
|
195
|
+
await caller.updateAiProviderOrder({ sortMap });
|
196
|
+
|
197
|
+
expect(mockUpdateOrder).toHaveBeenCalledWith(sortMap);
|
198
|
+
});
|
199
|
+
});
|
200
|
+
});
|