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.
Files changed (91) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  3. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  4. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  5. package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  6. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  7. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  10. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  11. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  12. package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  13. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  14. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  15. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  16. package/dist/client-v2/index.js +1 -1
  17. package/dist/constants.js +39 -0
  18. package/dist/externalVersion.js +9 -10
  19. package/dist/locale/en-US.json +39 -9
  20. package/dist/locale/vi-VN.json +31 -1
  21. package/dist/locale/zh-CN.json +31 -1
  22. package/dist/server/collections/ai-api-config.js +6 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  24. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  25. package/dist/server/plugin.js +45 -1
  26. package/dist/server/resource/ai-api-config.js +17 -0
  27. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  28. package/dist/server/routes/agent-completions.js +67 -51
  29. package/dist/server/routes/auth.js +11 -1
  30. package/dist/server/routes/chat-completions.js +174 -20
  31. package/dist/server/routes/completions.js +41 -21
  32. package/dist/server/routes/embeddings.js +6 -14
  33. package/dist/server/routes/models.js +102 -20
  34. package/dist/server/routes/router.js +94 -22
  35. package/dist/server/usage.js +2 -0
  36. package/dist/server/utils/app-observability.js +110 -0
  37. package/dist/server/utils/openai-format.js +17 -3
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/utils/user-permissions.js +160 -0
  40. package/dist/server/validation.js +18 -0
  41. package/dist/swagger.js +36 -4
  42. package/package.json +2 -2
  43. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  44. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  45. package/src/client/locale.ts +11 -21
  46. package/src/client/plugin.tsx +28 -8
  47. package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
  48. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  49. package/src/client-v2/locale.ts +21 -1
  50. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  51. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  52. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  53. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  54. package/src/client-v2/plugin.tsx +50 -1
  55. package/src/constants.ts +28 -0
  56. package/src/locale/en-US.json +39 -9
  57. package/src/locale/vi-VN.json +31 -1
  58. package/src/locale/zh-CN.json +31 -1
  59. package/src/server/__tests__/app-observability.test.ts +98 -0
  60. package/src/server/__tests__/models.test.ts +116 -0
  61. package/src/server/__tests__/openai-format.test.ts +52 -1
  62. package/src/server/__tests__/permission-sync.test.ts +109 -0
  63. package/src/server/__tests__/request-body.test.ts +310 -0
  64. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  65. package/src/server/__tests__/usage-route.test.ts +213 -0
  66. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  67. package/src/server/__tests__/user-permissions.test.ts +284 -0
  68. package/src/server/collections/ai-api-config.ts +6 -0
  69. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  70. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  71. package/src/server/plugin.ts +65 -4
  72. package/src/server/resource/ai-api-config.ts +23 -0
  73. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  74. package/src/server/routes/agent-completions.ts +84 -62
  75. package/src/server/routes/auth.ts +14 -1
  76. package/src/server/routes/chat-completions.ts +294 -20
  77. package/src/server/routes/completions.ts +54 -20
  78. package/src/server/routes/embeddings.ts +10 -15
  79. package/src/server/routes/models.ts +318 -195
  80. package/src/server/routes/router.ts +136 -26
  81. package/src/server/usage.ts +2 -0
  82. package/src/server/utils/app-observability.ts +105 -0
  83. package/src/server/utils/openai-format.ts +26 -0
  84. package/src/server/utils/streaming.ts +13 -1
  85. package/src/server/utils/user-permissions.ts +218 -0
  86. package/src/server/validation.ts +27 -0
  87. package/src/swagger.ts +47 -4
  88. package/dist/client/302.25edd5d75460acbf.js +0 -10
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  91. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -22,9 +22,13 @@ import { startUsageRecord, finishUsageRecord } from '../usage';
22
22
  import { isStreamingRequested } from '../utils/streaming';
23
23
  import type PluginAiApiServer from '../plugin';
24
24
  import { finalizeLlmBilling } from '../billing';
25
+ import { finishAiApiObservation, startAiApiObservation } from '../utils/app-observability';
25
26
 
26
27
  const API_PREFIX = '/api/ai-llm/v1';
27
28
 
29
+ /** Shared so the plugin can disable the core body parser for exactly these routes. */
30
+ export const AI_LLM_PREFIX = API_PREFIX;
31
+
28
32
  type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
29
33
 
30
34
  /**
@@ -90,22 +94,9 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
90
94
  const requestId = `req-${crypto.randomBytes(12).toString('hex')}`;
91
95
  ctx.set('X-Request-Id', requestId);
92
96
 
93
- // ─── Parse body for POST requests if not already parsed ───────────────
94
- if (method === 'POST' && !ctx.request.body) {
95
- try {
96
- const rawBody = await getRawBody(ctx);
97
- ctx.request.body = JSON.parse(rawBody);
98
- } catch (bodyErr: unknown) {
99
- const status =
100
- bodyErr && typeof bodyErr === 'object' && 'statusCode' in bodyErr && bodyErr.statusCode === 413 ? 413 : 400;
101
- const message = status === 413 ? 'Request body too large (max 10 MB)' : 'Invalid JSON in request body';
102
- ctx.status = status;
103
- ctx.body = toOpenAIError(status, message, 'invalid_request_error');
104
- return;
105
- }
106
- }
107
-
108
97
  // ─── Authenticate ─────────────────────────────────────────────────────
98
+ // Runs before the body is read: buffering a multi-megabyte payload for an
99
+ // anonymous caller would let unauthenticated traffic pin memory.
109
100
  const isAuth = await authenticateBearer(ctx);
110
101
  if (!isAuth) {
111
102
  logRequest(ctx, requestId, '-', 'auth_failed', 0);
@@ -126,6 +117,32 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
126
117
  return;
127
118
  }
128
119
 
120
+ // ─── Parse body for POST requests if not already parsed ───────────────
121
+ if (method === 'POST' && !ctx.request.body) {
122
+ const maxBodyBytes = await resolveMaxBodyBytes(ctx);
123
+ const declaredLength = Number(ctx.get('Content-Length'));
124
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
125
+ respondBodyTooLarge(ctx, maxBodyBytes);
126
+ logRequest(ctx, requestId, '-', 'body_too_large', 0);
127
+ return;
128
+ }
129
+ try {
130
+ const rawBody = await getRawBody(ctx, maxBodyBytes);
131
+ ctx.request.body = JSON.parse(rawBody);
132
+ } catch (bodyErr: unknown) {
133
+ const tooLarge =
134
+ bodyErr && typeof bodyErr === 'object' && 'statusCode' in bodyErr && bodyErr.statusCode === 413;
135
+ if (tooLarge) {
136
+ respondBodyTooLarge(ctx, maxBodyBytes);
137
+ logRequest(ctx, requestId, '-', 'body_too_large', 0);
138
+ return;
139
+ }
140
+ ctx.status = 400;
141
+ ctx.body = toOpenAIError(400, 'Invalid JSON in request body', 'invalid_request_error');
142
+ return;
143
+ }
144
+ }
145
+
129
146
  // ─── Route matching ───────────────────────────────────────────────────
130
147
  const requestBody = (ctx.request.body || {}) as Record<string, unknown>;
131
148
  const model = requestBody.model === undefined || requestBody.model === null ? '-' : String(requestBody.model);
@@ -145,6 +162,23 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
145
162
  };
146
163
  }
147
164
  const t0 = Date.now();
165
+ if (isUsageEndpoint) {
166
+ const service =
167
+ subPath === '/embeddings'
168
+ ? 'llm.embedding'
169
+ : resolvedMode === 'agent'
170
+ ? 'llm.agent'
171
+ : subPath === '/completions'
172
+ ? 'llm.completion'
173
+ : 'llm.chat';
174
+ startAiApiObservation(ctx, {
175
+ service,
176
+ operation: subPath,
177
+ streaming,
178
+ model: model === '-' ? undefined : model,
179
+ mode: resolvedMode,
180
+ });
181
+ }
148
182
  let usageId: unknown;
149
183
  try {
150
184
  usageId = isUsageEndpoint
@@ -275,32 +309,108 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
275
309
  ctx.log.error('AI API quota reservation could not be finalized:', billingError);
276
310
  }
277
311
  }
312
+ if (isUsageEndpoint) {
313
+ const streamResult = ctx.state.aiApiStreamResult;
314
+ finishAiApiObservation(ctx, {
315
+ status:
316
+ streamResult?.errorCode === 'client_disconnected'
317
+ ? 'cancelled'
318
+ : streamResult
319
+ ? streamResult.succeeded
320
+ ? 'succeeded'
321
+ : 'failed'
322
+ : ctx.status >= 200 && ctx.status < 400
323
+ ? 'succeeded'
324
+ : ctx.status >= 500
325
+ ? 'failed'
326
+ : 'rejected',
327
+ errorCode: streamResult?.errorCode,
328
+ });
329
+ }
278
330
  }
279
331
  };
280
332
  }
281
333
 
282
- /** Maximum allowed request body size (10 MB) to prevent OOM DoS attacks. */
283
- const MAX_BODY_BYTES = 10 * 1024 * 1024;
334
+ /** Body size cap used when aiApiConfig has no usable maxRequestBodyMb. */
335
+ const DEFAULT_MAX_BODY_MB = 10;
336
+
337
+ /**
338
+ * Upper bound an admin can configure.
339
+ *
340
+ * The gateway buffers the whole payload in memory (raw chunks, the decoded
341
+ * string, and the parsed object all coexist briefly), so an unbounded value
342
+ * would turn a config typo into an out-of-memory risk.
343
+ */
344
+ export const MAX_REQUEST_BODY_MB_LIMIT = 100;
345
+
346
+ /** Clamp a configured megabyte value into the supported range. */
347
+ export function normalizeMaxRequestBodyMb(value: unknown): number {
348
+ const mb = Number(value);
349
+ if (!Number.isSafeInteger(mb) || mb <= 0) return DEFAULT_MAX_BODY_MB;
350
+ return Math.min(mb, MAX_REQUEST_BODY_MB_LIMIT);
351
+ }
352
+
353
+ async function resolveMaxBodyBytes(ctx: Context): Promise<number> {
354
+ let configuredMb: unknown;
355
+ try {
356
+ const config = await ctx.db.getRepository('aiApiConfig').findOne();
357
+ configuredMb = config?.get('maxRequestBodyMb');
358
+ } catch (err) {
359
+ ctx.log?.warn?.('AI API: could not read maxRequestBodyMb, using default:', err);
360
+ }
361
+ return normalizeMaxRequestBodyMb(configuredMb) * 1024 * 1024;
362
+ }
363
+
364
+ function formatMb(bytes: number): string {
365
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
366
+ }
367
+
368
+ function respondBodyTooLarge(ctx: Context, maxBodyBytes: number): void {
369
+ ctx.status = 413;
370
+ ctx.body = toOpenAIError(
371
+ 413,
372
+ `Request body too large (max ${formatMb(maxBodyBytes)}). ` +
373
+ `Inline base64 images inflate payloads by ~33%; raise "Max request body size" in Settings → AI API Gateway if needed.`,
374
+ 'invalid_request_error',
375
+ );
376
+ }
284
377
 
285
378
  /**
286
- * Read raw body from request stream (fallback if bodyparser didn't handle it).
287
- * Rejects with a 413-style error if the body exceeds MAX_BODY_BYTES.
379
+ * Read the raw request body.
380
+ *
381
+ * The plugin disables the core koa-bodyparser for these routes (see plugin.ts),
382
+ * so this cap is the one that actually governs gateway payload size.
383
+ * Rejects with a 413-style error once maxBodyBytes is exceeded.
384
+ *
385
+ * Exported so tests can drive the real implementation over a live socket rather
386
+ * than a copy that cannot catch a regression here.
288
387
  */
289
- function getRawBody(ctx: Context): Promise<string> {
388
+ export function getRawBody(ctx: Pick<Context, 'req'>, maxBodyBytes: number): Promise<string> {
290
389
  return new Promise((resolve, reject) => {
291
- let body = '';
390
+ const chunks: Buffer[] = [];
292
391
  let byteCount = 0;
392
+ let aborted = false;
293
393
 
294
394
  ctx.req.on('data', (chunk: Buffer) => {
395
+ if (aborted) return;
295
396
  byteCount += chunk.length;
296
- if (byteCount > MAX_BODY_BYTES) {
297
- ctx.req.destroy();
298
- reject(Object.assign(new Error('Request body too large (max 10 MB)'), { statusCode: 413 }));
397
+ if (byteCount > maxBodyBytes) {
398
+ // Drop what we buffered and drain the rest instead of destroying the
399
+ // socket: ctx.req and the response share one connection, so destroying
400
+ // it replaces our 413 JSON with an ECONNRESET on the client.
401
+ aborted = true;
402
+ chunks.length = 0;
403
+ ctx.req.resume();
404
+ reject(Object.assign(new Error(`Request body too large (max ${formatMb(maxBodyBytes)})`), { statusCode: 413 }));
299
405
  return;
300
406
  }
301
- body += chunk.toString();
407
+ chunks.push(chunk);
408
+ });
409
+ // Decode once at the end: a multi-byte UTF-8 character can straddle a chunk
410
+ // boundary, and per-chunk toString() would corrupt it.
411
+ ctx.req.on('end', () => {
412
+ if (!aborted) resolve(Buffer.concat(chunks).toString('utf8'));
302
413
  });
303
- ctx.req.on('end', () => resolve(body));
304
414
  ctx.req.on('error', reject);
305
415
  });
306
416
  }
@@ -1,5 +1,6 @@
1
1
  import { Context } from '@nocobase/actions';
2
2
  import { finalizeLlmBilling, type LlmBillingState } from './billing';
3
+ import { addAiApiUsage } from './utils/app-observability';
3
4
 
4
5
  export type Usage = {
5
6
  prompt_tokens: number | null;
@@ -76,6 +77,7 @@ export function setAiApiUsageResult(
76
77
  getAiApiState(ctx).aiApiUsageResult = usage
77
78
  ? { source: 'provider', usage, ...metadata }
78
79
  : { source: 'unavailable', ...metadata };
80
+ addAiApiUsage(ctx, usage);
79
81
  return usage;
80
82
  }
81
83
 
@@ -0,0 +1,105 @@
1
+ import type { Context } from '@nocobase/actions';
2
+
3
+ interface ObservationFinish {
4
+ status: 'succeeded' | 'failed' | 'cancelled' | 'rejected';
5
+ errorCode?: string;
6
+ inputTokens?: number;
7
+ outputTokens?: number;
8
+ }
9
+ interface ObservationHandle {
10
+ markFirstByte(): void;
11
+ addInputTokens(value: number): void;
12
+ addOutputTokens(value: number): void;
13
+ finish(result: ObservationFinish): void;
14
+ }
15
+ interface AppObservabilityContract {
16
+ start(input: {
17
+ service: string;
18
+ operation: string;
19
+ streaming?: boolean;
20
+ attributes?: Record<string, string | number | boolean | null>;
21
+ }): ObservationHandle;
22
+ }
23
+ interface AiApiObservabilityState {
24
+ aiApiObservabilityHandle?: ObservationHandle;
25
+ aiApiObservabilityOutcome?: ObservationFinish;
26
+ }
27
+
28
+ const CONTRACT_SYMBOL = Symbol.for('nocobase.app-observability.contract');
29
+ const NOOP_HANDLE: ObservationHandle = {
30
+ markFirstByte() {},
31
+ addInputTokens() {},
32
+ addOutputTokens() {},
33
+ finish() {},
34
+ };
35
+
36
+ function state(ctx: Context): AiApiObservabilityState {
37
+ return ctx.state as AiApiObservabilityState;
38
+ }
39
+
40
+ function safely(ctx: Context, callback: () => void): void {
41
+ try {
42
+ callback();
43
+ } catch (error) {
44
+ ctx.app?.logger?.warn?.('[ai-api] App observability callback failed', { error });
45
+ }
46
+ }
47
+
48
+ export function startAiApiObservation(
49
+ ctx: Context,
50
+ input: {
51
+ service: 'llm.chat' | 'llm.agent' | 'llm.completion' | 'llm.embedding';
52
+ operation: string;
53
+ streaming: boolean;
54
+ model?: string;
55
+ mode: 'llm' | 'agent';
56
+ },
57
+ ): void {
58
+ let handle = NOOP_HANDLE;
59
+ safely(ctx, () => {
60
+ const contract = (ctx.app as object & { [CONTRACT_SYMBOL]?: AppObservabilityContract })[CONTRACT_SYMBOL];
61
+ if (!contract || typeof contract.start !== 'function') return;
62
+ const candidate = contract.start({
63
+ service: input.service,
64
+ operation: input.operation,
65
+ streaming: input.streaming,
66
+ attributes: {
67
+ mode: input.mode,
68
+ endpoint: input.operation,
69
+ ...(input.model ? { model: input.model } : {}),
70
+ },
71
+ });
72
+ if (candidate && typeof candidate.finish === 'function') handle = candidate;
73
+ });
74
+ state(ctx).aiApiObservabilityHandle = handle;
75
+ }
76
+
77
+ export function markAiApiFirstProviderOutput(ctx: Context): void {
78
+ safely(ctx, () => state(ctx).aiApiObservabilityHandle?.markFirstByte());
79
+ }
80
+
81
+ export function addAiApiUsage(
82
+ ctx: Context,
83
+ usage?: { prompt_tokens: number | null; completion_tokens: number | null },
84
+ ): void {
85
+ if (!usage) return;
86
+ safely(ctx, () => {
87
+ const handle = state(ctx).aiApiObservabilityHandle;
88
+ if (usage.prompt_tokens !== null) handle?.addInputTokens(usage.prompt_tokens);
89
+ if (usage.completion_tokens !== null) handle?.addOutputTokens(usage.completion_tokens);
90
+ });
91
+ }
92
+
93
+ export function setAiApiObservationOutcome(ctx: Context, outcome: ObservationFinish): void {
94
+ state(ctx).aiApiObservabilityOutcome = outcome;
95
+ }
96
+
97
+ export function finishAiApiObservation(ctx: Context, fallback: ObservationFinish): void {
98
+ const current = state(ctx);
99
+ const handle = current.aiApiObservabilityHandle;
100
+ if (!handle) return;
101
+ current.aiApiObservabilityHandle = undefined;
102
+ const outcome = current.aiApiObservabilityOutcome ?? fallback;
103
+ current.aiApiObservabilityOutcome = undefined;
104
+ safely(ctx, () => handle.finish(outcome));
105
+ }
@@ -97,6 +97,14 @@ export function toOpenAIResponse(options: {
97
97
 
98
98
  // ─── OpenAI Streaming chunk format ───
99
99
 
100
+ export type OpenAIUsage = {
101
+ prompt_tokens: number | null;
102
+ completion_tokens: number | null;
103
+ total_tokens: number | null;
104
+ };
105
+
106
+ export type OpenAIStreamObject = 'chat.completion.chunk' | 'text_completion';
107
+
100
108
  export function toOpenAIStreamChunk(options: {
101
109
  id: string;
102
110
  model: string;
@@ -118,6 +126,24 @@ export function toOpenAIStreamChunk(options: {
118
126
  finish_reason: finishReason,
119
127
  },
120
128
  ],
129
+ usage: null,
130
+ };
131
+ }
132
+
133
+ export function toOpenAIUsageChunk(options: {
134
+ id: string;
135
+ model: string;
136
+ usage: OpenAIUsage;
137
+ object?: OpenAIStreamObject;
138
+ }) {
139
+ const { id, model, usage, object = 'chat.completion.chunk' } = options;
140
+ return {
141
+ id,
142
+ object,
143
+ created: Math.floor(Date.now() / 1000),
144
+ model,
145
+ choices: [],
146
+ usage,
121
147
  };
122
148
  }
123
149
 
@@ -1,5 +1,17 @@
1
1
  import type { Context } from '@nocobase/actions';
2
2
 
3
+ export class AiApiClientDisconnectedError extends Error {
4
+ readonly code = 'client_disconnected';
5
+ constructor() {
6
+ super('Client disconnected');
7
+ this.name = 'AiApiClientDisconnectedError';
8
+ }
9
+ }
10
+
11
+ export function isClientDisconnected(ctx: Context, error: unknown): boolean {
12
+ return ctx.req.aborted === true || error instanceof AiApiClientDisconnectedError;
13
+ }
14
+
3
15
  export function isStreamingRequested(value: unknown) {
4
16
  return value !== false;
5
17
  }
@@ -7,7 +19,7 @@ export function isStreamingRequested(value: unknown) {
7
19
  export function createRequestAbortController(ctx: Context) {
8
20
  const controller = new AbortController();
9
21
  const abort = () => {
10
- if (!ctx.res.writableEnded) controller.abort(new Error('Client disconnected'));
22
+ if (!ctx.res.writableEnded) controller.abort(new AiApiClientDisconnectedError());
11
23
  };
12
24
  ctx.req.once('aborted', abort);
13
25
  ctx.res.once('close', abort);
@@ -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
+ }
@@ -42,6 +42,33 @@ export async function validateModelPrice(db: Database, model: Model): Promise<vo
42
42
  if (overlap) throw new Error('An enabled price already overlaps this effective period.');
43
43
  }
44
44
 
45
+ function requirePositiveIntegerOrNull(value: unknown, field: string): void {
46
+ if (value === null || value === undefined || value === '') return;
47
+ const parsed = Number(value);
48
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${field} must be a positive integer.`);
49
+ }
50
+
51
+ export function validateModelMetadata(model: Model): void {
52
+ if (!String(model.get('llmService') ?? '').trim()) throw new Error('llmService is required.');
53
+ if (!String(model.get('model') ?? '').trim()) throw new Error('model is required.');
54
+ requirePositiveIntegerOrNull(model.get('contextWindow'), 'contextWindow');
55
+ requirePositiveIntegerOrNull(model.get('maxCompletionTokens'), 'maxCompletionTokens');
56
+
57
+ const contextWindow = model.get('contextWindow');
58
+ const maxCompletionTokens = model.get('maxCompletionTokens');
59
+ if (
60
+ contextWindow !== null &&
61
+ contextWindow !== undefined &&
62
+ contextWindow !== '' &&
63
+ maxCompletionTokens !== null &&
64
+ maxCompletionTokens !== undefined &&
65
+ maxCompletionTokens !== '' &&
66
+ Number(maxCompletionTokens) > Number(contextWindow)
67
+ ) {
68
+ throw new Error('maxCompletionTokens cannot exceed contextWindow.');
69
+ }
70
+ }
71
+
45
72
  export function validateQuotaPolicy(model: Model): void {
46
73
  if (!['daily', 'monthly'].includes(String(model.get('periodType')))) {
47
74
  throw new Error('periodType must be daily or monthly.');