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.
- package/dist/client/123.e6fe04c856ce6417.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +5 -2
- package/dist/locale/en-US.json +12 -1
- package/dist/locale/vi-VN.json +12 -1
- package/dist/locale/zh-CN.json +12 -1
- package/dist/server/collections/ai-api-user-permissions.js +67 -0
- package/dist/server/plugin.js +32 -0
- package/dist/server/resource/ai-api-user-permissions.js +75 -0
- package/dist/server/routes/agent-completions.js +5 -0
- package/dist/server/routes/chat-completions.js +29 -16
- package/dist/server/routes/completions.js +33 -20
- package/dist/server/routes/embeddings.js +6 -14
- package/dist/server/routes/models.js +24 -0
- package/dist/server/utils/openai-format.js +17 -3
- package/dist/server/utils/user-permissions.js +160 -0
- package/dist/swagger.js +4 -3
- package/package.json +2 -2
- package/src/client/__tests__/settings-registration.test.tsx +69 -0
- package/src/client/plugin.tsx +14 -3
- package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
- package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
- package/src/client-v2/plugin.tsx +12 -3
- package/src/constants.ts +7 -0
- package/src/locale/en-US.json +12 -1
- package/src/locale/vi-VN.json +12 -1
- package/src/locale/zh-CN.json +12 -1
- package/src/server/__tests__/models.test.ts +44 -2
- package/src/server/__tests__/openai-format.test.ts +52 -1
- package/src/server/__tests__/permission-sync.test.ts +109 -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-user-permissions.ts +46 -0
- package/src/server/plugin.ts +42 -1
- package/src/server/resource/ai-api-user-permissions.ts +76 -0
- package/src/server/routes/agent-completions.ts +7 -0
- package/src/server/routes/chat-completions.ts +32 -16
- package/src/server/routes/completions.ts +40 -18
- package/src/server/routes/embeddings.ts +10 -15
- package/src/server/routes/models.ts +28 -0
- package/src/server/utils/openai-format.ts +26 -0
- package/src/server/utils/user-permissions.ts +218 -0
- package/src/swagger.ts +9 -3
package/src/server/plugin.ts
CHANGED
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Plugin } from '@nocobase/server';
|
|
11
|
+
import type { Transactionable } from '@nocobase/database';
|
|
11
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';
|
|
18
|
+
import { invalidateUserPermissionCache } from './utils/user-permissions';
|
|
16
19
|
import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
|
|
17
|
-
import { AI_API_ACL_SNIPPET } from '../constants';
|
|
20
|
+
import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
|
|
18
21
|
|
|
19
22
|
// Ensure dayjs timezone + utc plugins are loaded.
|
|
20
23
|
// Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
|
|
@@ -77,6 +80,7 @@ export class PluginAiApiServer extends Plugin {
|
|
|
77
80
|
// 2. Register admin config resource
|
|
78
81
|
this.app.resourceManager.define(aiApiConfigResource);
|
|
79
82
|
this.app.resourceManager.define(aiApiUsageMonitorResource);
|
|
83
|
+
this.app.resourceManager.define(aiApiUserPermissionsResource);
|
|
80
84
|
|
|
81
85
|
this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
|
|
82
86
|
invalidateRolePermissionCache(model.get('roleName'));
|
|
@@ -85,6 +89,13 @@ export class PluginAiApiServer extends Plugin {
|
|
|
85
89
|
invalidateRolePermissionCache(model.get('roleName'));
|
|
86
90
|
});
|
|
87
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
|
+
|
|
88
99
|
// 3. Set ACL permissions for admin config + role permissions management
|
|
89
100
|
this.app.acl.registerSnippet({
|
|
90
101
|
name: AI_API_ACL_SNIPPET,
|
|
@@ -102,12 +113,42 @@ export class PluginAiApiServer extends Plugin {
|
|
|
102
113
|
],
|
|
103
114
|
});
|
|
104
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
|
+
|
|
105
125
|
// 4. GC the rate limiter every 5 minutes to evict stale user entries.
|
|
106
126
|
// .unref() prevents this timer from keeping the process alive on shutdown.
|
|
107
127
|
this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
|
|
108
128
|
this.gcInterval.unref();
|
|
109
129
|
}
|
|
110
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
|
+
|
|
111
152
|
async install() {
|
|
112
153
|
// Create default config record on first install
|
|
113
154
|
const existing = await this.db.getRepository('aiApiConfig').findOne();
|
|
@@ -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;
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from '../utils/openai-format';
|
|
19
19
|
import { resolveModelString } from '../utils/resolve-service';
|
|
20
20
|
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
21
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
21
22
|
import { isStreamingRequested } from '../utils/streaming';
|
|
22
23
|
import {
|
|
23
24
|
AgentRuntimeContext,
|
|
@@ -121,6 +122,12 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
121
122
|
return;
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
// ─── Check whitelist (global config ∩ per-user grant) ──────────────────────
|
|
126
|
+
const globalEnabledServices = config ? config.get('enabledLlmServices') || config.enabledLlmServices : [];
|
|
127
|
+
if (!(await enforceModelAccess(ctx, globalEnabledServices, service, modelId))) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
124
131
|
const employeeUsername = defaultAiEmployee;
|
|
125
132
|
|
|
126
133
|
// ─── Check role is allowed to use this employee ────────────────────────────
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
generateCompletionId,
|
|
13
13
|
toOpenAIResponse,
|
|
14
14
|
toOpenAIStreamChunk,
|
|
15
|
+
toOpenAIUsageChunk,
|
|
15
16
|
toOpenAIError,
|
|
16
17
|
formatSSE,
|
|
17
18
|
formatSSEDone,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
writeResponse,
|
|
27
28
|
} from '../utils/streaming';
|
|
28
29
|
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
30
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
29
31
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
30
32
|
import type PluginAiApiServer from '../plugin';
|
|
31
33
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
@@ -126,22 +128,10 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
126
128
|
return;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
// ─── Check whitelist ───
|
|
131
|
+
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
130
132
|
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
131
|
-
if (config?.enabledLlmServices
|
|
132
|
-
|
|
133
|
-
const serviceTitle = service.title;
|
|
134
|
-
const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
|
|
135
|
-
if (!isAllowed) {
|
|
136
|
-
ctx.status = 403;
|
|
137
|
-
ctx.body = toOpenAIError(
|
|
138
|
-
403,
|
|
139
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
140
|
-
'invalid_request_error',
|
|
141
|
-
'model_not_available',
|
|
142
|
-
);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
133
|
+
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
134
|
+
return;
|
|
145
135
|
}
|
|
146
136
|
|
|
147
137
|
// ─── Create LLM provider instance ───
|
|
@@ -155,6 +145,13 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
155
145
|
await prepareLlmBilling(ctx, resolved);
|
|
156
146
|
|
|
157
147
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
148
|
+
if (stream) {
|
|
149
|
+
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
|
|
150
|
+
providerRequestParameters.stream_options = {
|
|
151
|
+
...streamOptions,
|
|
152
|
+
include_usage: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
158
155
|
const modelOptions: Record<string, unknown> = {
|
|
159
156
|
model: modelId,
|
|
160
157
|
llmService: service.name,
|
|
@@ -380,7 +377,13 @@ async function handleStreamingCompletion(
|
|
|
380
377
|
finishReason = 'tool_calls';
|
|
381
378
|
await writeResponse(
|
|
382
379
|
ctx,
|
|
383
|
-
formatSSE(
|
|
380
|
+
formatSSE(
|
|
381
|
+
toOpenAIStreamChunk({
|
|
382
|
+
id: completionId,
|
|
383
|
+
model: modelName,
|
|
384
|
+
delta: { tool_calls: toolCallChunks },
|
|
385
|
+
}),
|
|
386
|
+
),
|
|
384
387
|
);
|
|
385
388
|
}
|
|
386
389
|
if (chunk.usage_metadata) {
|
|
@@ -402,6 +405,19 @@ async function handleStreamingCompletion(
|
|
|
402
405
|
),
|
|
403
406
|
);
|
|
404
407
|
|
|
408
|
+
if (usage) {
|
|
409
|
+
await writeResponse(
|
|
410
|
+
ctx,
|
|
411
|
+
formatSSE(
|
|
412
|
+
toOpenAIUsageChunk({
|
|
413
|
+
id: completionId,
|
|
414
|
+
model: modelName,
|
|
415
|
+
usage,
|
|
416
|
+
}),
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
405
421
|
// Send [DONE]
|
|
406
422
|
await writeResponse(ctx, formatSSEDone());
|
|
407
423
|
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
generateCompletionId,
|
|
13
|
+
toOpenAIError,
|
|
14
|
+
formatSSE,
|
|
15
|
+
formatSSEDone,
|
|
16
|
+
toOpenAIUsageChunk,
|
|
17
|
+
} from '../utils/openai-format';
|
|
12
18
|
import { resolveModelString } from '../utils/resolve-service';
|
|
19
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
13
20
|
import {
|
|
14
21
|
createRequestAbortController,
|
|
15
22
|
isClientDisconnected,
|
|
@@ -93,22 +100,10 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
93
100
|
return;
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
// ─── Check whitelist ───
|
|
103
|
+
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
97
104
|
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
98
|
-
if (config?.enabledLlmServices
|
|
99
|
-
|
|
100
|
-
const serviceTitle = service.title;
|
|
101
|
-
const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
|
|
102
|
-
if (!isAllowed) {
|
|
103
|
-
ctx.status = 403;
|
|
104
|
-
ctx.body = toOpenAIError(
|
|
105
|
-
403,
|
|
106
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
107
|
-
'invalid_request_error',
|
|
108
|
-
'model_not_available',
|
|
109
|
-
);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
105
|
+
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
106
|
+
return;
|
|
112
107
|
}
|
|
113
108
|
|
|
114
109
|
// ─── Create LLM provider instance ───
|
|
@@ -166,7 +161,14 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
166
161
|
markLlmProviderAttempted(ctx);
|
|
167
162
|
|
|
168
163
|
if (stream) {
|
|
169
|
-
await handleStreamingTextCompletion(
|
|
164
|
+
await handleStreamingTextCompletion(
|
|
165
|
+
ctx,
|
|
166
|
+
chatModel,
|
|
167
|
+
langchainMessages,
|
|
168
|
+
completionId,
|
|
169
|
+
body.model,
|
|
170
|
+
body.stream_options,
|
|
171
|
+
);
|
|
170
172
|
} else {
|
|
171
173
|
await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
172
174
|
}
|
|
@@ -237,6 +239,7 @@ async function handleStreamingTextCompletion(
|
|
|
237
239
|
messages: [string, string][],
|
|
238
240
|
completionId: string,
|
|
239
241
|
modelName: string,
|
|
242
|
+
streamOptions: Record<string, unknown> | undefined,
|
|
240
243
|
) {
|
|
241
244
|
ctx.set({
|
|
242
245
|
'Content-Type': 'text/event-stream',
|
|
@@ -250,7 +253,10 @@ async function handleStreamingTextCompletion(
|
|
|
250
253
|
let usage: Usage | undefined;
|
|
251
254
|
let providerRequestId: string | undefined;
|
|
252
255
|
try {
|
|
253
|
-
const stream = await chatModel.stream(messages, {
|
|
256
|
+
const stream = await chatModel.stream(messages, {
|
|
257
|
+
stream_options: { ...streamOptions, include_usage: true },
|
|
258
|
+
signal: requestAbort.signal,
|
|
259
|
+
});
|
|
254
260
|
|
|
255
261
|
for await (const chunk of stream) {
|
|
256
262
|
if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
|
|
@@ -280,6 +286,7 @@ async function handleStreamingTextCompletion(
|
|
|
280
286
|
finish_reason: null,
|
|
281
287
|
},
|
|
282
288
|
],
|
|
289
|
+
usage: null,
|
|
283
290
|
}),
|
|
284
291
|
);
|
|
285
292
|
}
|
|
@@ -306,9 +313,24 @@ async function handleStreamingTextCompletion(
|
|
|
306
313
|
finish_reason: 'stop',
|
|
307
314
|
},
|
|
308
315
|
],
|
|
316
|
+
usage: null,
|
|
309
317
|
}),
|
|
310
318
|
);
|
|
311
319
|
|
|
320
|
+
if (usage) {
|
|
321
|
+
await writeResponse(
|
|
322
|
+
ctx,
|
|
323
|
+
formatSSE(
|
|
324
|
+
toOpenAIUsageChunk({
|
|
325
|
+
id: completionId,
|
|
326
|
+
model: modelName,
|
|
327
|
+
object: 'text_completion',
|
|
328
|
+
usage,
|
|
329
|
+
}),
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
312
334
|
await writeResponse(ctx, formatSSEDone());
|
|
313
335
|
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
314
336
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
11
|
import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
|
|
12
12
|
import { resolveModelString } from '../utils/resolve-service';
|
|
13
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
13
14
|
import { setAiApiUsageUnavailable } from '../usage';
|
|
14
15
|
import type PluginAiApiServer from '../plugin';
|
|
15
16
|
|
|
@@ -112,24 +113,18 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
// ─── Check service whitelist
|
|
116
|
+
// ─── Check service whitelist (global config ∩ per-user grant) ─────────────
|
|
117
|
+
// A config read failure falls open on the global list, but the per-user grant is
|
|
118
|
+
// still enforced: an explicit deny must never be bypassed by an unreadable config.
|
|
119
|
+
let globalEnabledServices: unknown = [];
|
|
116
120
|
try {
|
|
117
121
|
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
118
|
-
|
|
119
|
-
const allowed = config.enabledLlmServices.some((s: string) => s === service.name || s === service.title);
|
|
120
|
-
if (!allowed) {
|
|
121
|
-
ctx.status = 403;
|
|
122
|
-
ctx.body = toOpenAIError(
|
|
123
|
-
403,
|
|
124
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
125
|
-
'invalid_request_error',
|
|
126
|
-
'model_not_available',
|
|
127
|
-
);
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
122
|
+
globalEnabledServices = config?.enabledLlmServices ?? [];
|
|
131
123
|
} catch {
|
|
132
|
-
// Config read failure: fail open
|
|
124
|
+
// Config read failure: fail open on the global whitelist only.
|
|
125
|
+
}
|
|
126
|
+
if (!(await enforceModelAccess(ctx, globalEnabledServices, service, modelId))) {
|
|
127
|
+
return;
|
|
133
128
|
}
|
|
134
129
|
|
|
135
130
|
// ─── Get embedding provider ───────────────────────────────────────────────
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
11
|
import { toOpenAIError } from '../utils/openai-format';
|
|
12
|
+
import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
|
|
12
13
|
import type PluginAiApiServer from '../plugin';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -19,6 +20,9 @@ import type PluginAiApiServer from '../plugin';
|
|
|
19
20
|
* so clients can copy-paste the ID directly into POST /v1/chat/completions
|
|
20
21
|
* without needing to configure a defaultLlmService.
|
|
21
22
|
*
|
|
23
|
+
* The catalog is scoped to the caller: a user with an aiApiUserPermissions row only
|
|
24
|
+
* sees the intersection of the global whitelist and their own grant.
|
|
25
|
+
*
|
|
22
26
|
* Backward compatibility: resolveModelString() in resolve-service.ts still
|
|
23
27
|
* accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
|
|
24
28
|
*/
|
|
@@ -44,18 +48,25 @@ export async function handleListModels(ctx: Context, plugin: PluginAiApiServer)
|
|
|
44
48
|
sort: 'sort',
|
|
45
49
|
});
|
|
46
50
|
|
|
51
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
52
|
+
if (scope.lookupFailed) {
|
|
53
|
+
respondPermissionCheckFailed(ctx);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
47
56
|
const metadataMap = await loadModelMetadata(ctx);
|
|
48
57
|
const now = Math.floor(Date.now() / 1000);
|
|
49
58
|
const models: any[] = [];
|
|
50
59
|
|
|
51
60
|
for (const service of services) {
|
|
52
61
|
if (service.enabled === false) continue;
|
|
62
|
+
if (!isServiceAllowed(scope, config?.enabledLlmServices, service)) continue;
|
|
53
63
|
|
|
54
64
|
const enabledModels = resolveEnabledModels(service);
|
|
55
65
|
const serviceLabel = service.title || service.name;
|
|
56
66
|
|
|
57
67
|
for (const model of enabledModels) {
|
|
58
68
|
const fullId = `${service.name}/${model.value}`;
|
|
69
|
+
if (!isModelAllowed(scope, fullId)) continue;
|
|
59
70
|
const meta = metadataMap.get(fullId);
|
|
60
71
|
// An override row with enabled=false hides the model from the catalog.
|
|
61
72
|
if (meta && meta.enabled === false) continue;
|
|
@@ -95,12 +106,18 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
|
|
|
95
106
|
sort: 'sort',
|
|
96
107
|
});
|
|
97
108
|
|
|
109
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
110
|
+
if (scope.lookupFailed) {
|
|
111
|
+
respondPermissionCheckFailed(ctx);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
98
114
|
const metadataMap = await loadModelMetadata(ctx);
|
|
99
115
|
const now = Math.floor(Date.now() / 1000);
|
|
100
116
|
let found: any = null;
|
|
101
117
|
|
|
102
118
|
for (const service of services) {
|
|
103
119
|
if (service.enabled === false) continue;
|
|
120
|
+
if (!isServiceAllowed(scope, config?.enabledLlmServices, service)) continue;
|
|
104
121
|
const enabledModels = resolveEnabledModels(service);
|
|
105
122
|
const serviceLabel = service.title || service.name;
|
|
106
123
|
|
|
@@ -108,6 +125,7 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
|
|
|
108
125
|
const fullId = `${service.name}/${model.value}`;
|
|
109
126
|
// Accept both new "serviceName/modelId" format AND bare model ID (backward compat)
|
|
110
127
|
if (fullId === modelId || model.value === modelId) {
|
|
128
|
+
if (!isModelAllowed(scope, fullId)) continue;
|
|
111
129
|
const meta = metadataMap.get(fullId);
|
|
112
130
|
// A disabled override hides the model — treat as not found.
|
|
113
131
|
if (meta && meta.enabled === false) continue;
|
|
@@ -135,6 +153,16 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
|
|
|
135
153
|
|
|
136
154
|
// ─── Helpers ───
|
|
137
155
|
|
|
156
|
+
function respondPermissionCheckFailed(ctx: Context): void {
|
|
157
|
+
ctx.status = 503;
|
|
158
|
+
ctx.body = toOpenAIError(
|
|
159
|
+
503,
|
|
160
|
+
'Unable to verify LLM permissions for this user. Please retry shortly.',
|
|
161
|
+
'service_unavailable',
|
|
162
|
+
'permission_check_failed',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
138
166
|
export interface ModelMetadataOverride {
|
|
139
167
|
contextWindow?: number | null;
|
|
140
168
|
maxCompletionTokens?: number | null;
|
|
@@ -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
|
|