plugin-ai-api 1.0.20 → 1.0.23
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/dist/client/123.e6fe04c856ce6417.js +10 -0
- package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
- package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
- package/dist/client/562.17a0a299d2e5152c.js +10 -0
- package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
- package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
- package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
- package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +39 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +39 -9
- package/dist/locale/vi-VN.json +31 -1
- package/dist/locale/zh-CN.json +31 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-user-permissions.js +67 -0
- package/dist/server/plugin.js +45 -1
- package/dist/server/resource/ai-api-config.js +17 -0
- package/dist/server/resource/ai-api-user-permissions.js +75 -0
- package/dist/server/routes/agent-completions.js +67 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +174 -20
- package/dist/server/routes/completions.js +41 -21
- package/dist/server/routes/embeddings.js +6 -14
- package/dist/server/routes/models.js +102 -20
- package/dist/server/routes/router.js +94 -22
- package/dist/server/usage.js +2 -0
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/openai-format.js +17 -3
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/utils/user-permissions.js +160 -0
- package/dist/server/validation.js +18 -0
- package/dist/swagger.js +36 -4
- package/package.json +2 -2
- package/src/client/__tests__/settings-registration.test.tsx +69 -0
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +28 -8
- package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -1
- package/src/client-v2/pages/GeneralPage.tsx +13 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
- package/src/client-v2/plugin.tsx +50 -1
- package/src/constants.ts +28 -0
- package/src/locale/en-US.json +39 -9
- package/src/locale/vi-VN.json +31 -1
- package/src/locale/zh-CN.json +31 -1
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/models.test.ts +116 -0
- package/src/server/__tests__/openai-format.test.ts +52 -1
- package/src/server/__tests__/permission-sync.test.ts +109 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/__tests__/usage-route.test.ts +213 -0
- package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
- package/src/server/__tests__/user-permissions.test.ts +284 -0
- package/src/server/collections/ai-api-config.ts +6 -0
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-user-permissions.ts +46 -0
- package/src/server/plugin.ts +65 -4
- package/src/server/resource/ai-api-config.ts +23 -0
- package/src/server/resource/ai-api-user-permissions.ts +76 -0
- package/src/server/routes/agent-completions.ts +84 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +294 -20
- package/src/server/routes/completions.ts +54 -20
- package/src/server/routes/embeddings.ts +10 -15
- package/src/server/routes/models.ts +318 -195
- package/src/server/routes/router.ts +136 -26
- package/src/server/usage.ts +2 -0
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/openai-format.ts +26 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/utils/user-permissions.ts +218 -0
- package/src/server/validation.ts +27 -0
- package/src/swagger.ts +47 -4
- package/dist/client/302.25edd5d75460acbf.js +0 -10
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context } from '@nocobase/actions';
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
buildAccessScope,
|
|
14
|
+
enforceModelAccess,
|
|
15
|
+
invalidateUserPermissionCache,
|
|
16
|
+
isModelAllowed,
|
|
17
|
+
isServiceAllowed,
|
|
18
|
+
resolveUserAccessScope,
|
|
19
|
+
} from '../utils/user-permissions';
|
|
20
|
+
|
|
21
|
+
const OPENAI = { name: 'openai', title: 'OpenAI' };
|
|
22
|
+
const ANTHROPIC = { name: 'anthropic', title: 'Anthropic' };
|
|
23
|
+
|
|
24
|
+
/** Sequelize instances only expose columns via .get(); rows must be shaped that way. */
|
|
25
|
+
function row(values: Record<string, unknown>) {
|
|
26
|
+
return { get: (key: string) => values[key] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockContext(options: { userId?: number | null; row?: unknown; throws?: boolean; appName?: string } = {}) {
|
|
30
|
+
const findOne = vi.fn(async () => {
|
|
31
|
+
if (options.throws) throw new Error('collection unavailable');
|
|
32
|
+
return options.row ?? null;
|
|
33
|
+
});
|
|
34
|
+
const ctx = {
|
|
35
|
+
state: { currentUser: options.userId === null ? undefined : { id: options.userId ?? 1 } },
|
|
36
|
+
db: { getRepository: () => ({ findOne }) },
|
|
37
|
+
app: { name: options.appName ?? 'main' },
|
|
38
|
+
log: { warn: vi.fn(), error: vi.fn() },
|
|
39
|
+
status: 200,
|
|
40
|
+
body: undefined,
|
|
41
|
+
} as unknown as Context;
|
|
42
|
+
return { ctx, findOne };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
invalidateUserPermissionCache();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('buildAccessScope', () => {
|
|
50
|
+
it('treats a missing row as "no user-level narrowing"', () => {
|
|
51
|
+
const scope = buildAccessScope(null);
|
|
52
|
+
expect(scope.hasUserRecord).toBe(false);
|
|
53
|
+
expect(scope.denyAll).toBe(false);
|
|
54
|
+
expect(scope.allowedServices).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('marks a disabled row as deny-all', () => {
|
|
58
|
+
const scope = buildAccessScope(row({ enabled: false, allowedLlmServices: ['openai'] }));
|
|
59
|
+
expect(scope.denyAll).toBe(true);
|
|
60
|
+
expect(scope.allowedServices).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('reads columns through .get() rather than plain property access', () => {
|
|
64
|
+
const scope = buildAccessScope(
|
|
65
|
+
row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
66
|
+
);
|
|
67
|
+
expect(scope.allowedServices).toEqual(['openai']);
|
|
68
|
+
expect(scope.allowAllModels).toBe(false);
|
|
69
|
+
expect(scope.allowedModels.has('openai/gpt-4o')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('defaults allowAllModels to true when the column is unset', () => {
|
|
73
|
+
expect(buildAccessScope(row({ allowedLlmServices: [] })).allowAllModels).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('discards non-string entries in the json arrays', () => {
|
|
77
|
+
const scope = buildAccessScope(
|
|
78
|
+
row({ allowedLlmServices: ['openai', null, 42, ''], allowedModels: [{}, 'openai/gpt-4o'] }),
|
|
79
|
+
);
|
|
80
|
+
expect(scope.allowedServices).toEqual(['openai']);
|
|
81
|
+
expect([...scope.allowedModels]).toEqual(['openai/gpt-4o']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('isServiceAllowed', () => {
|
|
86
|
+
const noRecord = buildAccessScope(null);
|
|
87
|
+
|
|
88
|
+
it('leaves behaviour unchanged for a user with no record', () => {
|
|
89
|
+
expect(isServiceAllowed(noRecord, ['openai'], OPENAI)).toBe(true);
|
|
90
|
+
expect(isServiceAllowed(noRecord, ['openai'], ANTHROPIC)).toBe(false);
|
|
91
|
+
expect(isServiceAllowed(noRecord, [], ANTHROPIC)).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('denies everything when the record is disabled', () => {
|
|
95
|
+
const scope = buildAccessScope(row({ enabled: false, allowedLlmServices: ['openai'] }));
|
|
96
|
+
expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('denies everything when the service list is empty', () => {
|
|
100
|
+
const scope = buildAccessScope(row({ allowedLlmServices: [] }));
|
|
101
|
+
expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(false);
|
|
102
|
+
expect(isServiceAllowed(scope, [], OPENAI)).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('never widens the global whitelist (strict subset)', () => {
|
|
106
|
+
const scope = buildAccessScope(row({ allowedLlmServices: ['openai', 'anthropic'] }));
|
|
107
|
+
expect(isServiceAllowed(scope, ['openai'], OPENAI)).toBe(true);
|
|
108
|
+
// Granted to the user, but absent from the global whitelist → still denied.
|
|
109
|
+
expect(isServiceAllowed(scope, ['openai'], ANTHROPIC)).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('matches a service by title as well as by name', () => {
|
|
113
|
+
const byTitle = buildAccessScope(row({ allowedLlmServices: ['OpenAI'] }));
|
|
114
|
+
expect(isServiceAllowed(byTitle, ['OpenAI'], OPENAI)).toBe(true);
|
|
115
|
+
expect(isServiceAllowed(byTitle, [], OPENAI)).toBe(true);
|
|
116
|
+
expect(isServiceAllowed(byTitle, [], ANTHROPIC)).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('narrows within an empty global whitelist', () => {
|
|
120
|
+
const scope = buildAccessScope(row({ allowedLlmServices: ['openai'] }));
|
|
121
|
+
expect(isServiceAllowed(scope, [], OPENAI)).toBe(true);
|
|
122
|
+
expect(isServiceAllowed(scope, [], ANTHROPIC)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('tolerates a null or malformed global whitelist', () => {
|
|
126
|
+
expect(isServiceAllowed(noRecord, null, OPENAI)).toBe(true);
|
|
127
|
+
expect(isServiceAllowed(noRecord, 'openai', OPENAI)).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('isModelAllowed', () => {
|
|
132
|
+
it('allows every model when the user has no record', () => {
|
|
133
|
+
expect(isModelAllowed(buildAccessScope(null), 'openai/gpt-4o')).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('allows every model of the granted services when allowAllModels is true', () => {
|
|
137
|
+
const scope = buildAccessScope(row({ allowedLlmServices: ['openai'], allowAllModels: true }));
|
|
138
|
+
expect(isModelAllowed(scope, 'openai/anything')).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('restricts to the listed models when allowAllModels is false', () => {
|
|
142
|
+
const scope = buildAccessScope(
|
|
143
|
+
row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
144
|
+
);
|
|
145
|
+
expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(true);
|
|
146
|
+
expect(isModelAllowed(scope, 'openai/gpt-4o-mini')).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('denies every model when the record is disabled', () => {
|
|
150
|
+
const scope = buildAccessScope(row({ enabled: false, allowAllModels: true }));
|
|
151
|
+
expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('resolveUserAccessScope', () => {
|
|
156
|
+
it('returns the no-record scope for an unauthenticated context', async () => {
|
|
157
|
+
const { ctx, findOne } = mockContext({ userId: null });
|
|
158
|
+
expect((await resolveUserAccessScope(ctx)).hasUserRecord).toBe(false);
|
|
159
|
+
expect(findOne).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('caches the scope per user instead of querying on every request', async () => {
|
|
163
|
+
const { ctx, findOne } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
|
|
164
|
+
await resolveUserAccessScope(ctx);
|
|
165
|
+
await resolveUserAccessScope(ctx);
|
|
166
|
+
expect(findOne).toHaveBeenCalledTimes(1);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('re-queries after the cache is invalidated for that user', async () => {
|
|
170
|
+
const { ctx, findOne } = mockContext({ userId: 7, row: row({ allowedLlmServices: ['openai'] }) });
|
|
171
|
+
await resolveUserAccessScope(ctx);
|
|
172
|
+
invalidateUserPermissionCache(7);
|
|
173
|
+
await resolveUserAccessScope(ctx);
|
|
174
|
+
expect(findOne).toHaveBeenCalledTimes(2);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('keeps other users cached when one user is invalidated', async () => {
|
|
178
|
+
const first = mockContext({ userId: 1, row: row({ allowedLlmServices: ['openai'] }) });
|
|
179
|
+
const second = mockContext({ userId: 2, row: row({ allowedLlmServices: ['openai'] }) });
|
|
180
|
+
await resolveUserAccessScope(first.ctx);
|
|
181
|
+
await resolveUserAccessScope(second.ctx);
|
|
182
|
+
invalidateUserPermissionCache(2);
|
|
183
|
+
await resolveUserAccessScope(first.ctx);
|
|
184
|
+
expect(first.findOne).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('fails closed when the collection is unavailable', async () => {
|
|
188
|
+
const { ctx } = mockContext({ throws: true });
|
|
189
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
190
|
+
// Treating a failed lookup as "no record" would silently lift every user's restrictions
|
|
191
|
+
// during a rolling upgrade where the table does not exist yet.
|
|
192
|
+
expect(scope.lookupFailed).toBe(true);
|
|
193
|
+
expect(scope.denyAll).toBe(true);
|
|
194
|
+
expect(ctx.log.error).toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('denies every service and model when the lookup failed', async () => {
|
|
198
|
+
const { ctx } = mockContext({ throws: true });
|
|
199
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
200
|
+
expect(isServiceAllowed(scope, [], OPENAI)).toBe(false);
|
|
201
|
+
expect(isModelAllowed(scope, 'openai/gpt-4o')).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('does not cache a failed lookup', async () => {
|
|
205
|
+
const { ctx, findOne } = mockContext({ throws: true });
|
|
206
|
+
await resolveUserAccessScope(ctx);
|
|
207
|
+
await resolveUserAccessScope(ctx);
|
|
208
|
+
expect(findOne).toHaveBeenCalledTimes(2);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not share a cache entry between apps with the same user id', async () => {
|
|
212
|
+
const main = mockContext({ userId: 1, appName: 'main', row: row({ allowedLlmServices: ['openai'] }) });
|
|
213
|
+
const sub = mockContext({ userId: 1, appName: 'sub', row: row({ allowedLlmServices: ['anthropic'] }) });
|
|
214
|
+
await resolveUserAccessScope(main.ctx);
|
|
215
|
+
const subScope = await resolveUserAccessScope(sub.ctx);
|
|
216
|
+
// Sub-apps share this process but have separate databases, so user 1 in "sub" is a
|
|
217
|
+
// different person than user 1 in "main" and must not inherit their grant.
|
|
218
|
+
expect(sub.findOne).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(subScope.allowedServices).toEqual(['anthropic']);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('invalidates a user across every app', async () => {
|
|
223
|
+
const main = mockContext({ userId: 1, appName: 'main', row: row({ allowedLlmServices: ['openai'] }) });
|
|
224
|
+
const sub = mockContext({ userId: 1, appName: 'sub', row: row({ allowedLlmServices: ['openai'] }) });
|
|
225
|
+
await resolveUserAccessScope(main.ctx);
|
|
226
|
+
await resolveUserAccessScope(sub.ctx);
|
|
227
|
+
invalidateUserPermissionCache(1);
|
|
228
|
+
await resolveUserAccessScope(main.ctx);
|
|
229
|
+
await resolveUserAccessScope(sub.ctx);
|
|
230
|
+
expect(main.findOne).toHaveBeenCalledTimes(2);
|
|
231
|
+
expect(sub.findOne).toHaveBeenCalledTimes(2);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does not invalidate a user whose id is a suffix of another', async () => {
|
|
235
|
+
const first = mockContext({ userId: 1, row: row({ allowedLlmServices: ['openai'] }) });
|
|
236
|
+
const second = mockContext({ userId: 21, row: row({ allowedLlmServices: ['openai'] }) });
|
|
237
|
+
await resolveUserAccessScope(first.ctx);
|
|
238
|
+
await resolveUserAccessScope(second.ctx);
|
|
239
|
+
invalidateUserPermissionCache(1);
|
|
240
|
+
await resolveUserAccessScope(second.ctx);
|
|
241
|
+
expect(second.findOne).toHaveBeenCalledTimes(1);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('enforceModelAccess', () => {
|
|
246
|
+
it('passes a permitted service and model through untouched', async () => {
|
|
247
|
+
const { ctx } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
|
|
248
|
+
expect(await enforceModelAccess(ctx, ['openai'], OPENAI, 'gpt-4o')).toBe(true);
|
|
249
|
+
expect(ctx.status).toBe(200);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('returns 403 model_not_available for a denied service', async () => {
|
|
253
|
+
const { ctx } = mockContext({ row: row({ allowedLlmServices: ['openai'] }) });
|
|
254
|
+
expect(await enforceModelAccess(ctx, ['openai', 'anthropic'], ANTHROPIC, 'claude')).toBe(false);
|
|
255
|
+
expect(ctx.status).toBe(403);
|
|
256
|
+
// `permission_denied` is what every other 403 in this plugin reports; keeping the type
|
|
257
|
+
// consistent means OpenAI clients can branch on it uniformly.
|
|
258
|
+
expect(ctx.body).toMatchObject({ error: { code: 'model_not_available', type: 'permission_denied' } });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('returns 403 model_not_available for a denied model of a granted service', async () => {
|
|
262
|
+
const { ctx } = mockContext({
|
|
263
|
+
row: row({ allowedLlmServices: ['openai'], allowAllModels: false, allowedModels: ['openai/gpt-4o'] }),
|
|
264
|
+
});
|
|
265
|
+
expect(await enforceModelAccess(ctx, ['openai'], OPENAI, 'gpt-4o-mini')).toBe(false);
|
|
266
|
+
expect(ctx.status).toBe(403);
|
|
267
|
+
expect(ctx.body).toMatchObject({ error: { code: 'model_not_available', type: 'permission_denied' } });
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('denies a user-granted service that the global whitelist excludes', async () => {
|
|
271
|
+
const { ctx } = mockContext({ row: row({ allowedLlmServices: ['anthropic'] }) });
|
|
272
|
+
expect(await enforceModelAccess(ctx, ['openai'], ANTHROPIC, 'claude')).toBe(false);
|
|
273
|
+
expect(ctx.status).toBe(403);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('returns a retryable 503 rather than allowing access when the lookup fails', async () => {
|
|
277
|
+
const { ctx } = mockContext({ throws: true });
|
|
278
|
+
expect(await enforceModelAccess(ctx, [], OPENAI, 'gpt-4o')).toBe(false);
|
|
279
|
+
// 503 not 403: the failure is ours, so clients should back off rather than treat the
|
|
280
|
+
// grant as permanently revoked.
|
|
281
|
+
expect(ctx.status).toBe(503);
|
|
282
|
+
expect(ctx.body).toMatchObject({ error: { code: 'permission_check_failed' } });
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -41,6 +41,12 @@ export default defineCollection({
|
|
|
41
41
|
defaultValue: 60,
|
|
42
42
|
comment: 'Max requests per user per minute',
|
|
43
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: 'maxRequestBodyMb',
|
|
46
|
+
type: 'integer',
|
|
47
|
+
defaultValue: 10,
|
|
48
|
+
comment: 'Max request body size in MB. Raise this to accept inline base64 images in vision requests.',
|
|
49
|
+
},
|
|
44
50
|
{
|
|
45
51
|
name: 'quotaEnabled',
|
|
46
52
|
type: 'boolean',
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineCollection } from '@nocobase/database';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-model metadata overrides surfaced through the OpenAI-compatible GET /v1/models
|
|
14
|
+
* endpoint. Lets an admin correct or supply values that the upstream provider either
|
|
15
|
+
* reports incorrectly (e.g. an inflated context window) or omits entirely.
|
|
16
|
+
*
|
|
17
|
+
* A row is keyed by (llmService, model). All override fields are nullable — a null
|
|
18
|
+
* value means "do not override this attribute", so an admin can override just the
|
|
19
|
+
* context window without touching anything else.
|
|
20
|
+
*/
|
|
21
|
+
export default defineCollection({
|
|
22
|
+
name: 'aiApiModelMetadata',
|
|
23
|
+
autoGenId: true,
|
|
24
|
+
fields: [
|
|
25
|
+
{ name: 'llmService', type: 'string', allowNull: false, index: true },
|
|
26
|
+
{ name: 'model', type: 'string', allowNull: false, index: true },
|
|
27
|
+
{
|
|
28
|
+
name: 'contextWindow',
|
|
29
|
+
type: 'integer',
|
|
30
|
+
allowNull: true,
|
|
31
|
+
comment:
|
|
32
|
+
'Override for the model context window (input+output token capacity). Returned as context_window / context_length.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'maxCompletionTokens',
|
|
36
|
+
type: 'integer',
|
|
37
|
+
allowNull: true,
|
|
38
|
+
comment: 'Override for the maximum output tokens. Returned as max_completion_tokens.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'ownedByOverride',
|
|
42
|
+
type: 'string',
|
|
43
|
+
allowNull: true,
|
|
44
|
+
comment: 'Override for the owned_by field in the OpenAI model object.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'displayName',
|
|
48
|
+
type: 'string',
|
|
49
|
+
allowNull: true,
|
|
50
|
+
comment: 'Friendly display name returned as display_name / name in the model object.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'description',
|
|
54
|
+
type: 'text',
|
|
55
|
+
allowNull: true,
|
|
56
|
+
comment: 'Human-readable description returned as description in the model object.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'enabled',
|
|
60
|
+
type: 'boolean',
|
|
61
|
+
defaultValue: true,
|
|
62
|
+
index: true,
|
|
63
|
+
comment: 'When false, the model is hidden from /v1/models and reported as active:false.',
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
indexes: [
|
|
67
|
+
{
|
|
68
|
+
fields: ['llmService', 'model'],
|
|
69
|
+
unique: true,
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defineCollection } from '@nocobase/database';
|
|
11
|
+
|
|
12
|
+
export default defineCollection({
|
|
13
|
+
name: 'aiApiUserPermissions',
|
|
14
|
+
autoGenId: true,
|
|
15
|
+
fields: [
|
|
16
|
+
{ name: 'userId', type: 'bigInt', allowNull: false, index: true },
|
|
17
|
+
{
|
|
18
|
+
name: 'user',
|
|
19
|
+
type: 'belongsTo',
|
|
20
|
+
target: 'users',
|
|
21
|
+
targetKey: 'id',
|
|
22
|
+
foreignKey: 'userId',
|
|
23
|
+
constraints: false,
|
|
24
|
+
},
|
|
25
|
+
{ name: 'enabled', type: 'boolean', defaultValue: true, index: true },
|
|
26
|
+
{
|
|
27
|
+
name: 'allowedLlmServices',
|
|
28
|
+
type: 'json',
|
|
29
|
+
defaultValue: [],
|
|
30
|
+
comment: 'LLM service names/titles this user may use. Empty means the user is denied every service.',
|
|
31
|
+
},
|
|
32
|
+
{ name: 'allowAllModels', type: 'boolean', defaultValue: true },
|
|
33
|
+
{
|
|
34
|
+
name: 'allowedModels',
|
|
35
|
+
type: 'json',
|
|
36
|
+
defaultValue: [],
|
|
37
|
+
comment: 'Array of "serviceName/modelId" this user may use (when allowAllModels=false)',
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
indexes: [
|
|
41
|
+
{
|
|
42
|
+
fields: ['userId'],
|
|
43
|
+
unique: true,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
package/src/server/plugin.ts
CHANGED
|
@@ -8,12 +8,16 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Plugin } from '@nocobase/server';
|
|
11
|
-
import {
|
|
11
|
+
import type { Transactionable } from '@nocobase/database';
|
|
12
|
+
import { createAiLlmRouter, AI_LLM_PREFIX } from './routes/router';
|
|
12
13
|
import aiApiConfigResource from './resource/ai-api-config';
|
|
13
14
|
import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
|
|
15
|
+
import aiApiUserPermissionsResource from './resource/ai-api-user-permissions';
|
|
14
16
|
import { RateLimiter } from './utils/rate-limiter';
|
|
15
17
|
import { invalidateRolePermissionCache } from './middleware/role-permission';
|
|
16
|
-
import {
|
|
18
|
+
import { invalidateUserPermissionCache } from './utils/user-permissions';
|
|
19
|
+
import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
|
|
20
|
+
import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
|
|
17
21
|
|
|
18
22
|
// Ensure dayjs timezone + utc plugins are loaded.
|
|
19
23
|
// Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
|
|
@@ -43,13 +47,31 @@ export class PluginAiApiServer extends Plugin {
|
|
|
43
47
|
this.app.db.on('aiApiModelPrices.beforeSave', async (model) => {
|
|
44
48
|
await validateModelPrice(this.db, model);
|
|
45
49
|
});
|
|
50
|
+
this.app.db.on('aiApiModelMetadata.beforeSave', (model) => {
|
|
51
|
+
validateModelMetadata(model);
|
|
52
|
+
});
|
|
46
53
|
this.app.db.on('aiApiUserQuotaPolicies.beforeSave', (model) => {
|
|
47
54
|
validateQuotaPolicy(model);
|
|
48
55
|
});
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
async load() {
|
|
52
|
-
// 1.
|
|
59
|
+
// 1. Claim body parsing for our own routes before the core bodyParser runs.
|
|
60
|
+
// Core registers koa-bodyparser with a global REQUEST_BODY_LIMIT (10mb by
|
|
61
|
+
// default) much earlier in the stack, so without this the gateway's own
|
|
62
|
+
// configurable limit is unreachable: an oversized vision request would be
|
|
63
|
+
// rejected by core with a non-OpenAI error shape. `disableBodyParser` makes
|
|
64
|
+
// koa-bodyparser skip the request, leaving ctx.request.body undefined so
|
|
65
|
+
// createAiLlmRouter reads and caps the raw stream itself.
|
|
66
|
+
this.app.use(
|
|
67
|
+
async (ctx, next) => {
|
|
68
|
+
if (ctx.path.startsWith(AI_LLM_PREFIX)) ctx.disableBodyParser = true;
|
|
69
|
+
await next();
|
|
70
|
+
},
|
|
71
|
+
{ tag: 'aiApiDisableBodyParser', before: 'bodyParser' },
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// 2. Register raw Koa middleware for OpenAI-compatible endpoints
|
|
53
75
|
// Must run before 'resourcer' so URL paths match OpenAI convention
|
|
54
76
|
// OIDC access tokens must first pass through plugin-idp-oauth, which validates
|
|
55
77
|
// issuer/audience/scope and rewrites them to a NocoBase internal token.
|
|
@@ -58,6 +80,7 @@ export class PluginAiApiServer extends Plugin {
|
|
|
58
80
|
// 2. Register admin config resource
|
|
59
81
|
this.app.resourceManager.define(aiApiConfigResource);
|
|
60
82
|
this.app.resourceManager.define(aiApiUsageMonitorResource);
|
|
83
|
+
this.app.resourceManager.define(aiApiUserPermissionsResource);
|
|
61
84
|
|
|
62
85
|
this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
|
|
63
86
|
invalidateRolePermissionCache(model.get('roleName'));
|
|
@@ -66,13 +89,21 @@ export class PluginAiApiServer extends Plugin {
|
|
|
66
89
|
invalidateRolePermissionCache(model.get('roleName'));
|
|
67
90
|
});
|
|
68
91
|
|
|
92
|
+
this.app.db.on('aiApiUserPermissions.afterSave', (model, options) => {
|
|
93
|
+
this.revokeUserPermissions(model.get('userId'), options?.transaction);
|
|
94
|
+
});
|
|
95
|
+
this.app.db.on('aiApiUserPermissions.afterDestroy', (model, options) => {
|
|
96
|
+
this.revokeUserPermissions(model.get('userId'), options?.transaction);
|
|
97
|
+
});
|
|
98
|
+
|
|
69
99
|
// 3. Set ACL permissions for admin config + role permissions management
|
|
70
100
|
this.app.acl.registerSnippet({
|
|
71
|
-
name:
|
|
101
|
+
name: AI_API_ACL_SNIPPET,
|
|
72
102
|
actions: [
|
|
73
103
|
'aiApiConfig:*',
|
|
74
104
|
'aiApiRolePermissions:*',
|
|
75
105
|
'aiApiModelPrices:*',
|
|
106
|
+
'aiApiModelMetadata:*',
|
|
76
107
|
'aiApiUserQuotaPolicies:*',
|
|
77
108
|
'aiApiUserQuotaBuckets:list',
|
|
78
109
|
'aiApiUserQuotaBuckets:get',
|
|
@@ -82,12 +113,42 @@ export class PluginAiApiServer extends Plugin {
|
|
|
82
113
|
],
|
|
83
114
|
});
|
|
84
115
|
|
|
116
|
+
// Per-user LLM grants are a separate child permission: handing out model access is a
|
|
117
|
+
// stronger capability than editing gateway settings, so it ticks independently.
|
|
118
|
+
// The wildcard also covers `listUsers`, which backs the page's user picker — without it
|
|
119
|
+
// the page would depend on `pm.plugin-users` and break for a role holding only this snippet.
|
|
120
|
+
this.app.acl.registerSnippet({
|
|
121
|
+
name: AI_API_USER_PERMISSIONS_SNIPPET,
|
|
122
|
+
actions: ['aiApiUserPermissions:*'],
|
|
123
|
+
});
|
|
124
|
+
|
|
85
125
|
// 4. GC the rate limiter every 5 minutes to evict stale user entries.
|
|
86
126
|
// .unref() prevents this timer from keeping the process alive on shutdown.
|
|
87
127
|
this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
|
|
88
128
|
this.gcInterval.unref();
|
|
89
129
|
}
|
|
90
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Drop a user's cached LLM scope on every node.
|
|
133
|
+
*
|
|
134
|
+
* The local call is not redundant: syncMessageManager hardcodes skipSelf, so the publishing
|
|
135
|
+
* node never receives its own message. Passing the transaction defers the broadcast until
|
|
136
|
+
* the write commits, so other nodes cannot re-read the old row and re-cache it.
|
|
137
|
+
*/
|
|
138
|
+
private revokeUserPermissions(userId: unknown, transaction?: Transactionable['transaction']) {
|
|
139
|
+
invalidateUserPermissionCache(userId as string | number | bigint);
|
|
140
|
+
this.sendSyncMessage({ type: 'invalidateUserPermissions', userId }, { transaction });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Received only on the *other* nodes (skipSelf), so this must not re-broadcast.
|
|
145
|
+
*/
|
|
146
|
+
async handleSyncMessage(message: { type?: string; userId?: unknown }) {
|
|
147
|
+
if (message?.type === 'invalidateUserPermissions') {
|
|
148
|
+
invalidateUserPermissionCache(message.userId as string | number | bigint);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
91
152
|
async install() {
|
|
92
153
|
// Create default config record on first install
|
|
93
154
|
const existing = await this.db.getRepository('aiApiConfig').findOne();
|
|
@@ -8,6 +8,24 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { ResourceOptions } from '@nocobase/resourcer';
|
|
11
|
+
import { MAX_REQUEST_BODY_MB_LIMIT } from '../routes/router';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_REQUEST_BODY_MB = 10;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The gateway buffers each request body in memory, so an out-of-range value
|
|
17
|
+
* here is a denial-of-service footgun rather than a harmless setting.
|
|
18
|
+
*/
|
|
19
|
+
function coerceMaxRequestBodyMb(value: unknown): number {
|
|
20
|
+
const mb = Number(value);
|
|
21
|
+
if (!Number.isSafeInteger(mb) || mb <= 0) {
|
|
22
|
+
throw new Error(`maxRequestBodyMb must be a positive integer (1-${MAX_REQUEST_BODY_MB_LIMIT}).`);
|
|
23
|
+
}
|
|
24
|
+
if (mb > MAX_REQUEST_BODY_MB_LIMIT) {
|
|
25
|
+
throw new Error(`maxRequestBodyMb cannot exceed ${MAX_REQUEST_BODY_MB_LIMIT}.`);
|
|
26
|
+
}
|
|
27
|
+
return mb;
|
|
28
|
+
}
|
|
11
29
|
|
|
12
30
|
/**
|
|
13
31
|
* Resource for managing AI API configuration via NocoBase admin UI.
|
|
@@ -29,6 +47,7 @@ const aiApiConfigResource: ResourceOptions = {
|
|
|
29
47
|
defaultLlmService: '',
|
|
30
48
|
enabledLlmServices: [],
|
|
31
49
|
rateLimitPerMinute: 60,
|
|
50
|
+
maxRequestBodyMb: 10,
|
|
32
51
|
quotaEnabled: false,
|
|
33
52
|
defaultReservationOutputTokens: 4096,
|
|
34
53
|
options: {},
|
|
@@ -52,6 +71,7 @@ const aiApiConfigResource: ResourceOptions = {
|
|
|
52
71
|
defaultLlmService: values.defaultLlmService ?? '',
|
|
53
72
|
enabledLlmServices: values.enabledLlmServices ?? [],
|
|
54
73
|
rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
|
|
74
|
+
maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
|
|
55
75
|
quotaEnabled: values.quotaEnabled ?? false,
|
|
56
76
|
defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
|
|
57
77
|
options: values.options ?? {},
|
|
@@ -64,6 +84,9 @@ const aiApiConfigResource: ResourceOptions = {
|
|
|
64
84
|
if (values.defaultLlmService !== undefined) updateData.defaultLlmService = values.defaultLlmService;
|
|
65
85
|
if (values.enabledLlmServices !== undefined) updateData.enabledLlmServices = values.enabledLlmServices;
|
|
66
86
|
if (values.rateLimitPerMinute !== undefined) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
|
|
87
|
+
if (values.maxRequestBodyMb !== undefined) {
|
|
88
|
+
updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
|
|
89
|
+
}
|
|
67
90
|
if (values.quotaEnabled !== undefined) updateData.quotaEnabled = values.quotaEnabled;
|
|
68
91
|
if (values.defaultReservationOutputTokens !== undefined) {
|
|
69
92
|
updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ResourceOptions } from '@nocobase/resourcer';
|
|
11
|
+
|
|
12
|
+
const MAX_PAGE_SIZE = 100;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal user directory for the permission page's user picker.
|
|
16
|
+
*
|
|
17
|
+
* The page is gated by AI_API_USER_PERMISSIONS_SNIPPET, but `users:list` belongs to
|
|
18
|
+
* `pm.plugin-users`. Calling it would force every permission admin to also be a user admin,
|
|
19
|
+
* so this action exposes only the identity fields the picker renders — never password hashes,
|
|
20
|
+
* roles or any other user column.
|
|
21
|
+
*/
|
|
22
|
+
const aiApiUserPermissionsResource: ResourceOptions = {
|
|
23
|
+
name: 'aiApiUserPermissions',
|
|
24
|
+
actions: {
|
|
25
|
+
async listUsers(ctx, next) {
|
|
26
|
+
const params = ctx.action.params || {};
|
|
27
|
+
const keyword = typeof params.keyword === 'string' ? params.keyword.trim() : '';
|
|
28
|
+
const page = Math.max(1, Number(params.page) || 1);
|
|
29
|
+
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(params.pageSize) || 50));
|
|
30
|
+
|
|
31
|
+
const filter: Record<string, unknown> = keyword
|
|
32
|
+
? {
|
|
33
|
+
$or: [
|
|
34
|
+
{ nickname: { $includes: keyword } },
|
|
35
|
+
{ username: { $includes: keyword } },
|
|
36
|
+
{ email: { $includes: keyword } },
|
|
37
|
+
],
|
|
38
|
+
}
|
|
39
|
+
: {};
|
|
40
|
+
|
|
41
|
+
// A user already holding a grant is excluded so the picker cannot produce a duplicate
|
|
42
|
+
// that the unique index on userId would reject at save time.
|
|
43
|
+
if (params.excludeGranted) {
|
|
44
|
+
const granted = await ctx.db.getRepository('aiApiUserPermissions').find({ fields: ['userId'] });
|
|
45
|
+
const ids = granted.map((row) => row.get('userId')).filter((id) => id !== null && id !== undefined);
|
|
46
|
+
if (ids.length) filter.id = { $notIn: ids };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const [rows, count] = await ctx.db.getRepository('users').findAndCount({
|
|
50
|
+
filter,
|
|
51
|
+
fields: ['id', 'nickname', 'username', 'email'],
|
|
52
|
+
sort: ['nickname', 'id'],
|
|
53
|
+
offset: (page - 1) * pageSize,
|
|
54
|
+
limit: pageSize,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Use the canonical { rows, ...meta } action shape. NocoBase's dataWrapping
|
|
58
|
+
// middleware turns this into { data, meta } on the wire; returning that wire
|
|
59
|
+
// shape here would make it wrap a second time.
|
|
60
|
+
ctx.body = {
|
|
61
|
+
rows: rows.map((row) => ({
|
|
62
|
+
id: row.get('id'),
|
|
63
|
+
nickname: row.get('nickname'),
|
|
64
|
+
username: row.get('username'),
|
|
65
|
+
email: row.get('email'),
|
|
66
|
+
})),
|
|
67
|
+
count,
|
|
68
|
+
page,
|
|
69
|
+
pageSize,
|
|
70
|
+
};
|
|
71
|
+
await next();
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export default aiApiUserPermissionsResource;
|