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
|
@@ -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
|
-
/**
|
|
283
|
-
const
|
|
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
|
|
287
|
-
*
|
|
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
|
-
|
|
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 >
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/server/usage.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/server/validation.ts
CHANGED
|
@@ -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.');
|