plugin-ai-api 1.0.21 → 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.
Files changed (46) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/index.js +1 -1
  3. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  4. package/dist/client-v2/index.js +1 -1
  5. package/dist/constants.js +5 -2
  6. package/dist/locale/en-US.json +12 -1
  7. package/dist/locale/vi-VN.json +12 -1
  8. package/dist/locale/zh-CN.json +12 -1
  9. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  10. package/dist/server/plugin.js +32 -0
  11. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  12. package/dist/server/routes/agent-completions.js +5 -0
  13. package/dist/server/routes/chat-completions.js +29 -16
  14. package/dist/server/routes/completions.js +33 -20
  15. package/dist/server/routes/embeddings.js +6 -14
  16. package/dist/server/routes/models.js +24 -0
  17. package/dist/server/utils/openai-format.js +17 -3
  18. package/dist/server/utils/user-permissions.js +160 -0
  19. package/dist/swagger.js +4 -3
  20. package/package.json +2 -2
  21. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  22. package/src/client/plugin.tsx +14 -3
  23. package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
  24. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  25. package/src/client-v2/plugin.tsx +12 -3
  26. package/src/constants.ts +7 -0
  27. package/src/locale/en-US.json +12 -1
  28. package/src/locale/vi-VN.json +12 -1
  29. package/src/locale/zh-CN.json +12 -1
  30. package/src/server/__tests__/models.test.ts +44 -2
  31. package/src/server/__tests__/openai-format.test.ts +52 -1
  32. package/src/server/__tests__/permission-sync.test.ts +109 -0
  33. package/src/server/__tests__/usage-route.test.ts +213 -0
  34. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  35. package/src/server/__tests__/user-permissions.test.ts +284 -0
  36. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  37. package/src/server/plugin.ts +42 -1
  38. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  39. package/src/server/routes/agent-completions.ts +7 -0
  40. package/src/server/routes/chat-completions.ts +32 -16
  41. package/src/server/routes/completions.ts +40 -18
  42. package/src/server/routes/embeddings.ts +10 -15
  43. package/src/server/routes/models.ts +28 -0
  44. package/src/server/utils/openai-format.ts +26 -0
  45. package/src/server/utils/user-permissions.ts +218 -0
  46. package/src/swagger.ts +9 -3
@@ -0,0 +1,218 @@
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 { toOpenAIError } from './openai-format';
12
+
13
+ const SCOPE_TTL_MS = 15_000;
14
+
15
+ interface CachedScope {
16
+ scope: AiApiAccessScope;
17
+ expiresAt: number;
18
+ }
19
+
20
+ const scopeCache = new Map<string, CachedScope>();
21
+
22
+ /**
23
+ * Resolved per-user LLM access, layered *under* the global `aiApiConfig.enabledLlmServices`
24
+ * whitelist. A user grant can only ever narrow global access, never widen it.
25
+ */
26
+ export interface AiApiAccessScope {
27
+ /** False when the user has no aiApiUserPermissions row — global config alone decides. */
28
+ hasUserRecord: boolean;
29
+ /** True when a row exists but is switched off, denying every service. */
30
+ denyAll: boolean;
31
+ /** null means "no user-level narrowing"; an empty array denies every service. */
32
+ allowedServices: string[] | null;
33
+ allowAllModels: boolean;
34
+ allowedModels: Set<string>;
35
+ /**
36
+ * True when the lookup itself failed. Distinct from hasUserRecord:false — "this user has no
37
+ * restrictions" and "we cannot tell whether this user has restrictions" must not be conflated,
38
+ * or a mid-rolling-upgrade missing table silently lifts every user's restrictions.
39
+ */
40
+ lookupFailed: boolean;
41
+ }
42
+
43
+ const NO_RECORD_SCOPE: AiApiAccessScope = {
44
+ hasUserRecord: false,
45
+ denyAll: false,
46
+ allowedServices: null,
47
+ allowAllModels: true,
48
+ allowedModels: new Set(),
49
+ lookupFailed: false,
50
+ };
51
+
52
+ const LOOKUP_FAILED_SCOPE: AiApiAccessScope = { ...NO_RECORD_SCOPE, denyAll: true, lookupFailed: true };
53
+
54
+ /**
55
+ * Invalidate cached scopes for one user across every app in this process, or all users when
56
+ * called with no argument. Keys are `${appName}:${userId}`, and the afterSave hook only knows
57
+ * the user id, so the match is on the suffix.
58
+ *
59
+ * This is per-process only: in a multi-node deployment other nodes keep serving their cached
60
+ * scope until the 15s TTL expires.
61
+ */
62
+ export function invalidateUserPermissionCache(userId?: string | number | bigint): void {
63
+ if (userId === undefined || userId === null) {
64
+ scopeCache.clear();
65
+ return;
66
+ }
67
+ const suffix = `:${userId}`;
68
+ for (const key of scopeCache.keys()) {
69
+ if (key.endsWith(suffix)) scopeCache.delete(key);
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Sequelize instances expose columns through .get() only — a plain property read returns
75
+ * undefined for most fields. Mirrors valueOf() in billing.ts so plain-object test fixtures
76
+ * work too.
77
+ */
78
+ function valueOf<T>(row: unknown, name: string): T {
79
+ if (!row) return undefined as T;
80
+ const candidate = row as { get?: (key: string) => unknown };
81
+ if (typeof candidate.get === 'function') return candidate.get(name) as T;
82
+ return (row as Record<string, unknown>)[name] as T;
83
+ }
84
+
85
+ function toStringArray(value: unknown): string[] {
86
+ if (!Array.isArray(value)) return [];
87
+ return value.filter((item): item is string => typeof item === 'string' && item.length > 0);
88
+ }
89
+
90
+ /** Build a scope from an aiApiUserPermissions row (or absence of one). */
91
+ export function buildAccessScope(row: unknown): AiApiAccessScope {
92
+ if (!row) return NO_RECORD_SCOPE;
93
+ if (valueOf<boolean>(row, 'enabled') === false) {
94
+ return { ...NO_RECORD_SCOPE, hasUserRecord: true, denyAll: true, allowedServices: [] };
95
+ }
96
+ return {
97
+ hasUserRecord: true,
98
+ denyAll: false,
99
+ allowedServices: toStringArray(valueOf(row, 'allowedLlmServices')),
100
+ allowAllModels: valueOf<boolean>(row, 'allowAllModels') !== false,
101
+ allowedModels: new Set(toStringArray(valueOf(row, 'allowedModels'))),
102
+ lookupFailed: false,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Load the current user's access scope, cached for 15s per user id — the same TTL the role
108
+ * permission cache uses. Invalidated by afterSave/afterDestroy hooks in plugin.ts.
109
+ */
110
+ export async function resolveUserAccessScope(ctx: Context): Promise<AiApiAccessScope> {
111
+ const userId = ctx.state.currentUser?.id;
112
+ if (userId === undefined || userId === null) return NO_RECORD_SCOPE;
113
+
114
+ // Sub-apps share this process but have separate databases, so user id 1 in one app is a
115
+ // different person than user id 1 in another. Without the app prefix they collide here.
116
+ const key = `${ctx.app?.name ?? 'main'}:${userId}`;
117
+ const cached = scopeCache.get(key);
118
+ if (cached && cached.expiresAt > Date.now()) return cached.scope;
119
+
120
+ let scope: AiApiAccessScope;
121
+ try {
122
+ const row = await ctx.db.getRepository('aiApiUserPermissions').findOne({ filter: { userId } });
123
+ scope = buildAccessScope(row);
124
+ } catch (err) {
125
+ // Fail closed. A failed lookup cannot be treated as "no restrictions": during a rolling
126
+ // upgrade the table may not exist yet, and that must not lift every user's restrictions.
127
+ ctx.log?.error?.('AI API user permissions lookup failed, denying access:', err);
128
+ return LOOKUP_FAILED_SCOPE;
129
+ }
130
+
131
+ scopeCache.set(key, { scope, expiresAt: Date.now() + SCOPE_TTL_MS });
132
+ return scope;
133
+ }
134
+
135
+ function matchesService(list: string[], serviceName?: string, serviceTitle?: string): boolean {
136
+ return list.some((entry) => entry === serviceName || entry === serviceTitle);
137
+ }
138
+
139
+ /**
140
+ * Effective service check: the global whitelist AND the user grant must both allow it.
141
+ *
142
+ * An empty global whitelist means "expose all services", preserving existing behaviour.
143
+ * An empty user grant means "deny everything" — the record itself is the opt-in.
144
+ */
145
+ export function isServiceAllowed(
146
+ scope: AiApiAccessScope,
147
+ globalEnabledServices: unknown,
148
+ service: { name?: string; title?: string },
149
+ ): boolean {
150
+ // denyAll is checked before hasUserRecord: a failed lookup denies without having a record.
151
+ if (scope.denyAll) return false;
152
+ const globalList = toStringArray(globalEnabledServices);
153
+ if (globalList.length && !matchesService(globalList, service.name, service.title)) return false;
154
+ if (!scope.hasUserRecord) return true;
155
+ return matchesService(scope.allowedServices ?? [], service.name, service.title);
156
+ }
157
+
158
+ /** Model-level narrowing on top of isServiceAllowed, keyed by "serviceName/modelId". */
159
+ export function isModelAllowed(scope: AiApiAccessScope, fullModelId: string): boolean {
160
+ if (scope.denyAll) return false;
161
+ if (!scope.hasUserRecord) return true;
162
+ if (scope.allowAllModels) return true;
163
+ return scope.allowedModels.has(fullModelId);
164
+ }
165
+
166
+ /**
167
+ * Gate a completion/embedding request on the caller's effective service+model access.
168
+ *
169
+ * Writes a 403 in OpenAI error shape and returns false when access is denied, so callers
170
+ * can `if (!(await enforceModelAccess(...))) return;`.
171
+ */
172
+ export async function enforceModelAccess(
173
+ ctx: Context,
174
+ globalEnabledServices: unknown,
175
+ service: { name?: string; title?: string },
176
+ modelId: string,
177
+ ): Promise<boolean> {
178
+ const scope = await resolveUserAccessScope(ctx);
179
+ const serviceLabel = service.title || service.name;
180
+
181
+ // The denial is ours, not the caller's — report it as retryable rather than as a permission
182
+ // decision, so clients back off instead of treating the grant as permanently revoked.
183
+ if (scope.lookupFailed) {
184
+ ctx.status = 503;
185
+ ctx.body = toOpenAIError(
186
+ 503,
187
+ 'Unable to verify LLM permissions for this user. Please retry shortly.',
188
+ 'service_unavailable',
189
+ 'permission_check_failed',
190
+ );
191
+ return false;
192
+ }
193
+
194
+ if (!isServiceAllowed(scope, globalEnabledServices, service)) {
195
+ ctx.status = 403;
196
+ ctx.body = toOpenAIError(
197
+ 403,
198
+ `LLM service '${serviceLabel}' is not enabled for API access`,
199
+ 'permission_denied',
200
+ 'model_not_available',
201
+ );
202
+ return false;
203
+ }
204
+
205
+ if (!isModelAllowed(scope, `${service.name}/${modelId}`)) {
206
+ ctx.status = 403;
207
+ ctx.body = toOpenAIError(
208
+ 403,
209
+ `Model '${service.name}/${modelId}' is not permitted for this user. ` +
210
+ `Use GET /v1/models to see available models.`,
211
+ 'permission_denied',
212
+ 'model_not_available',
213
+ );
214
+ return false;
215
+ }
216
+
217
+ return true;
218
+ }
package/src/swagger.ts CHANGED
@@ -57,7 +57,10 @@ export default {
57
57
  tags: ['ai-llm'],
58
58
  summary: 'List available models',
59
59
  description:
60
- 'Returns all LLM models available across registered services. Model IDs are formatted as `serviceName/modelId`.',
60
+ 'Returns the LLM models available to the authenticated caller across registered services. Model IDs are formatted as `serviceName/modelId`.\n\n' +
61
+ "The catalog is user-scoped: it starts from `enabledLlmServices` in the AI API configuration, then narrows to the caller's " +
62
+ '`aiApiUserPermissions` record when one exists. A user grant can only narrow the global whitelist, never widen it, so two ' +
63
+ 'users may receive different lists from the same request.',
61
64
  security: [{ BearerAuth: [] }],
62
65
  responses: {
63
66
  200: {
@@ -84,6 +87,8 @@ export default {
84
87
  get: {
85
88
  tags: ['ai-llm'],
86
89
  summary: 'Get model details',
90
+ description:
91
+ 'User-scoped in the same way as `GET /v1/models`: a model the caller is not granted is reported as not found rather than disclosed.',
87
92
  security: [{ BearerAuth: [] }],
88
93
  parameters: [
89
94
  {
@@ -103,7 +108,7 @@ export default {
103
108
  },
104
109
  },
105
110
  },
106
- 404: { description: 'Model not found' },
111
+ 404: { description: 'Model not found, or not available to this user' },
107
112
  },
108
113
  },
109
114
  },
@@ -252,7 +257,8 @@ export default {
252
257
  enabledLlmServices: {
253
258
  type: 'array',
254
259
  items: { type: 'string' },
255
- description: 'List of enabled LLM service names',
260
+ description:
261
+ 'List of enabled LLM service names. This is the outer bound for every caller; per-user `aiApiUserPermissions` records can only narrow it further.',
256
262
  },
257
263
  rateLimitPerMinute: {
258
264
  type: 'integer',