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
|
@@ -31,12 +31,12 @@ __export(completions_exports, {
|
|
|
31
31
|
module.exports = __toCommonJS(completions_exports);
|
|
32
32
|
var import_openai_format = require("../utils/openai-format");
|
|
33
33
|
var import_resolve_service = require("../utils/resolve-service");
|
|
34
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
34
35
|
var import_streaming = require("../utils/streaming");
|
|
35
36
|
var import_usage = require("../usage");
|
|
36
37
|
var import_billing = require("../billing");
|
|
37
38
|
var import_app_observability = require("../utils/app-observability");
|
|
38
39
|
async function handleCompletions(ctx, plugin) {
|
|
39
|
-
var _a;
|
|
40
40
|
const body = ctx.request.body;
|
|
41
41
|
if (!(body == null ? void 0 : body.model)) {
|
|
42
42
|
ctx.status = 400;
|
|
@@ -89,20 +89,8 @@ async function handleCompletions(ctx, plugin) {
|
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
const config = await ctx.db.getRepository("aiApiConfig").findOne();
|
|
92
|
-
if ((
|
|
93
|
-
|
|
94
|
-
const serviceTitle = service.title;
|
|
95
|
-
const isAllowed = config.enabledLlmServices.some((s) => s === serviceName || s === serviceTitle);
|
|
96
|
-
if (!isAllowed) {
|
|
97
|
-
ctx.status = 403;
|
|
98
|
-
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
99
|
-
403,
|
|
100
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
101
|
-
"invalid_request_error",
|
|
102
|
-
"model_not_available"
|
|
103
|
-
);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
92
|
+
if (!await (0, import_user_permissions.enforceModelAccess)(ctx, config == null ? void 0 : config.enabledLlmServices, service, modelId)) {
|
|
93
|
+
return;
|
|
106
94
|
}
|
|
107
95
|
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
108
96
|
if (!providerMeta) {
|
|
@@ -143,7 +131,14 @@ async function handleCompletions(ctx, plugin) {
|
|
|
143
131
|
const chatModel = provider.createModel();
|
|
144
132
|
(0, import_billing.markLlmProviderAttempted)(ctx);
|
|
145
133
|
if (stream) {
|
|
146
|
-
await handleStreamingTextCompletion(
|
|
134
|
+
await handleStreamingTextCompletion(
|
|
135
|
+
ctx,
|
|
136
|
+
chatModel,
|
|
137
|
+
langchainMessages,
|
|
138
|
+
completionId,
|
|
139
|
+
body.model,
|
|
140
|
+
body.stream_options
|
|
141
|
+
);
|
|
147
142
|
} else {
|
|
148
143
|
await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
149
144
|
}
|
|
@@ -193,7 +188,7 @@ async function handleNonStreamingTextCompletion(ctx, chatModel, messages, comple
|
|
|
193
188
|
usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
194
189
|
};
|
|
195
190
|
}
|
|
196
|
-
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName) {
|
|
191
|
+
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, streamOptions) {
|
|
197
192
|
ctx.set({
|
|
198
193
|
"Content-Type": "text/event-stream",
|
|
199
194
|
"Cache-Control": "no-cache",
|
|
@@ -205,7 +200,10 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
|
|
|
205
200
|
let usage;
|
|
206
201
|
let providerRequestId;
|
|
207
202
|
try {
|
|
208
|
-
const stream = await chatModel.stream(messages, {
|
|
203
|
+
const stream = await chatModel.stream(messages, {
|
|
204
|
+
stream_options: { ...streamOptions, include_usage: true },
|
|
205
|
+
signal: requestAbort.signal
|
|
206
|
+
});
|
|
209
207
|
for await (const chunk of stream) {
|
|
210
208
|
if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
|
|
211
209
|
let text = "";
|
|
@@ -232,7 +230,8 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
|
|
|
232
230
|
logprobs: null,
|
|
233
231
|
finish_reason: null
|
|
234
232
|
}
|
|
235
|
-
]
|
|
233
|
+
],
|
|
234
|
+
usage: null
|
|
236
235
|
})
|
|
237
236
|
);
|
|
238
237
|
}
|
|
@@ -256,9 +255,23 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
|
|
|
256
255
|
logprobs: null,
|
|
257
256
|
finish_reason: "stop"
|
|
258
257
|
}
|
|
259
|
-
]
|
|
258
|
+
],
|
|
259
|
+
usage: null
|
|
260
260
|
})
|
|
261
261
|
);
|
|
262
|
+
if (usage) {
|
|
263
|
+
await (0, import_streaming.writeResponse)(
|
|
264
|
+
ctx,
|
|
265
|
+
(0, import_openai_format.formatSSE)(
|
|
266
|
+
(0, import_openai_format.toOpenAIUsageChunk)({
|
|
267
|
+
id: completionId,
|
|
268
|
+
model: modelName,
|
|
269
|
+
object: "text_completion",
|
|
270
|
+
usage
|
|
271
|
+
})
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
262
275
|
await (0, import_streaming.writeResponse)(ctx, (0, import_openai_format.formatSSEDone)());
|
|
263
276
|
(0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
264
277
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
@@ -31,9 +31,9 @@ __export(embeddings_exports, {
|
|
|
31
31
|
module.exports = __toCommonJS(embeddings_exports);
|
|
32
32
|
var import_openai_format = require("../utils/openai-format");
|
|
33
33
|
var import_resolve_service = require("../utils/resolve-service");
|
|
34
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
34
35
|
var import_usage = require("../usage");
|
|
35
36
|
async function handleEmbeddings(ctx, plugin) {
|
|
36
|
-
var _a;
|
|
37
37
|
const body = ctx.request.body;
|
|
38
38
|
if (!(body == null ? void 0 : body.model)) {
|
|
39
39
|
ctx.status = 400;
|
|
@@ -102,23 +102,15 @@ async function handleEmbeddings(ctx, plugin) {
|
|
|
102
102
|
);
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
|
+
let globalEnabledServices = [];
|
|
105
106
|
try {
|
|
106
107
|
const config = await ctx.db.getRepository("aiApiConfig").findOne();
|
|
107
|
-
|
|
108
|
-
const allowed = config.enabledLlmServices.some((s) => s === service.name || s === service.title);
|
|
109
|
-
if (!allowed) {
|
|
110
|
-
ctx.status = 403;
|
|
111
|
-
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
112
|
-
403,
|
|
113
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
114
|
-
"invalid_request_error",
|
|
115
|
-
"model_not_available"
|
|
116
|
-
);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
108
|
+
globalEnabledServices = (config == null ? void 0 : config.enabledLlmServices) ?? [];
|
|
120
109
|
} catch {
|
|
121
110
|
}
|
|
111
|
+
if (!await (0, import_user_permissions.enforceModelAccess)(ctx, globalEnabledServices, service, modelId)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
122
114
|
const aiPlugin = ctx.app.pm.get("ai");
|
|
123
115
|
if (!aiPlugin) {
|
|
124
116
|
ctx.status = 500;
|
|
@@ -32,6 +32,7 @@ __export(models_exports, {
|
|
|
32
32
|
});
|
|
33
33
|
module.exports = __toCommonJS(models_exports);
|
|
34
34
|
var import_openai_format = require("../utils/openai-format");
|
|
35
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
35
36
|
async function handleListModels(ctx, plugin) {
|
|
36
37
|
var _a;
|
|
37
38
|
try {
|
|
@@ -50,15 +51,22 @@ async function handleListModels(ctx, plugin) {
|
|
|
50
51
|
filter,
|
|
51
52
|
sort: "sort"
|
|
52
53
|
});
|
|
54
|
+
const scope = await (0, import_user_permissions.resolveUserAccessScope)(ctx);
|
|
55
|
+
if (scope.lookupFailed) {
|
|
56
|
+
respondPermissionCheckFailed(ctx);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
53
59
|
const metadataMap = await loadModelMetadata(ctx);
|
|
54
60
|
const now = Math.floor(Date.now() / 1e3);
|
|
55
61
|
const models = [];
|
|
56
62
|
for (const service of services) {
|
|
57
63
|
if (service.enabled === false) continue;
|
|
64
|
+
if (!(0, import_user_permissions.isServiceAllowed)(scope, config == null ? void 0 : config.enabledLlmServices, service)) continue;
|
|
58
65
|
const enabledModels = resolveEnabledModels(service);
|
|
59
66
|
const serviceLabel = service.title || service.name;
|
|
60
67
|
for (const model of enabledModels) {
|
|
61
68
|
const fullId = `${service.name}/${model.value}`;
|
|
69
|
+
if (!(0, import_user_permissions.isModelAllowed)(scope, fullId)) continue;
|
|
62
70
|
const meta = metadataMap.get(fullId);
|
|
63
71
|
if (meta && meta.enabled === false) continue;
|
|
64
72
|
models.push(buildModelObject(fullId, now, serviceLabel, meta));
|
|
@@ -87,16 +95,23 @@ async function handleGetModel(ctx, modelId, plugin) {
|
|
|
87
95
|
filter,
|
|
88
96
|
sort: "sort"
|
|
89
97
|
});
|
|
98
|
+
const scope = await (0, import_user_permissions.resolveUserAccessScope)(ctx);
|
|
99
|
+
if (scope.lookupFailed) {
|
|
100
|
+
respondPermissionCheckFailed(ctx);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
90
103
|
const metadataMap = await loadModelMetadata(ctx);
|
|
91
104
|
const now = Math.floor(Date.now() / 1e3);
|
|
92
105
|
let found = null;
|
|
93
106
|
for (const service of services) {
|
|
94
107
|
if (service.enabled === false) continue;
|
|
108
|
+
if (!(0, import_user_permissions.isServiceAllowed)(scope, config == null ? void 0 : config.enabledLlmServices, service)) continue;
|
|
95
109
|
const enabledModels = resolveEnabledModels(service);
|
|
96
110
|
const serviceLabel = service.title || service.name;
|
|
97
111
|
for (const model of enabledModels) {
|
|
98
112
|
const fullId = `${service.name}/${model.value}`;
|
|
99
113
|
if (fullId === modelId || model.value === modelId) {
|
|
114
|
+
if (!(0, import_user_permissions.isModelAllowed)(scope, fullId)) continue;
|
|
100
115
|
const meta = metadataMap.get(fullId);
|
|
101
116
|
if (meta && meta.enabled === false) continue;
|
|
102
117
|
found = buildModelObject(fullId, now, serviceLabel, meta);
|
|
@@ -118,6 +133,15 @@ async function handleGetModel(ctx, modelId, plugin) {
|
|
|
118
133
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, "Failed to retrieve model", "server_error");
|
|
119
134
|
}
|
|
120
135
|
}
|
|
136
|
+
function respondPermissionCheckFailed(ctx) {
|
|
137
|
+
ctx.status = 503;
|
|
138
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
139
|
+
503,
|
|
140
|
+
"Unable to verify LLM permissions for this user. Please retry shortly.",
|
|
141
|
+
"service_unavailable",
|
|
142
|
+
"permission_check_failed"
|
|
143
|
+
);
|
|
144
|
+
}
|
|
121
145
|
async function loadModelMetadata(ctx) {
|
|
122
146
|
var _a, _b;
|
|
123
147
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -43,7 +43,8 @@ __export(openai_format_exports, {
|
|
|
43
43
|
toOpenAIEmbeddingsResponse: () => toOpenAIEmbeddingsResponse,
|
|
44
44
|
toOpenAIError: () => toOpenAIError,
|
|
45
45
|
toOpenAIResponse: () => toOpenAIResponse,
|
|
46
|
-
toOpenAIStreamChunk: () => toOpenAIStreamChunk
|
|
46
|
+
toOpenAIStreamChunk: () => toOpenAIStreamChunk,
|
|
47
|
+
toOpenAIUsageChunk: () => toOpenAIUsageChunk
|
|
47
48
|
});
|
|
48
49
|
module.exports = __toCommonJS(openai_format_exports);
|
|
49
50
|
var import_crypto = __toESM(require("crypto"));
|
|
@@ -116,7 +117,19 @@ function toOpenAIStreamChunk(options) {
|
|
|
116
117
|
logprobs: null,
|
|
117
118
|
finish_reason: finishReason
|
|
118
119
|
}
|
|
119
|
-
]
|
|
120
|
+
],
|
|
121
|
+
usage: null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function toOpenAIUsageChunk(options) {
|
|
125
|
+
const { id, model, usage, object = "chat.completion.chunk" } = options;
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
object,
|
|
129
|
+
created: Math.floor(Date.now() / 1e3),
|
|
130
|
+
model,
|
|
131
|
+
choices: [],
|
|
132
|
+
usage
|
|
120
133
|
};
|
|
121
134
|
}
|
|
122
135
|
function toOpenAIEmbeddingsResponse(options) {
|
|
@@ -154,5 +167,6 @@ function formatSSEDone() {
|
|
|
154
167
|
toOpenAIEmbeddingsResponse,
|
|
155
168
|
toOpenAIError,
|
|
156
169
|
toOpenAIResponse,
|
|
157
|
-
toOpenAIStreamChunk
|
|
170
|
+
toOpenAIStreamChunk,
|
|
171
|
+
toOpenAIUsageChunk
|
|
158
172
|
});
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
12
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
13
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
14
|
+
var __export = (target, all) => {
|
|
15
|
+
for (var name in all)
|
|
16
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
17
|
+
};
|
|
18
|
+
var __copyProps = (to, from, except, desc) => {
|
|
19
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
20
|
+
for (let key of __getOwnPropNames(from))
|
|
21
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
22
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
23
|
+
}
|
|
24
|
+
return to;
|
|
25
|
+
};
|
|
26
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
27
|
+
var user_permissions_exports = {};
|
|
28
|
+
__export(user_permissions_exports, {
|
|
29
|
+
buildAccessScope: () => buildAccessScope,
|
|
30
|
+
enforceModelAccess: () => enforceModelAccess,
|
|
31
|
+
invalidateUserPermissionCache: () => invalidateUserPermissionCache,
|
|
32
|
+
isModelAllowed: () => isModelAllowed,
|
|
33
|
+
isServiceAllowed: () => isServiceAllowed,
|
|
34
|
+
resolveUserAccessScope: () => resolveUserAccessScope
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(user_permissions_exports);
|
|
37
|
+
var import_openai_format = require("./openai-format");
|
|
38
|
+
const SCOPE_TTL_MS = 15e3;
|
|
39
|
+
const scopeCache = /* @__PURE__ */ new Map();
|
|
40
|
+
const NO_RECORD_SCOPE = {
|
|
41
|
+
hasUserRecord: false,
|
|
42
|
+
denyAll: false,
|
|
43
|
+
allowedServices: null,
|
|
44
|
+
allowAllModels: true,
|
|
45
|
+
allowedModels: /* @__PURE__ */ new Set(),
|
|
46
|
+
lookupFailed: false
|
|
47
|
+
};
|
|
48
|
+
const LOOKUP_FAILED_SCOPE = { ...NO_RECORD_SCOPE, denyAll: true, lookupFailed: true };
|
|
49
|
+
function invalidateUserPermissionCache(userId) {
|
|
50
|
+
if (userId === void 0 || userId === null) {
|
|
51
|
+
scopeCache.clear();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const suffix = `:${userId}`;
|
|
55
|
+
for (const key of scopeCache.keys()) {
|
|
56
|
+
if (key.endsWith(suffix)) scopeCache.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function valueOf(row, name) {
|
|
60
|
+
if (!row) return void 0;
|
|
61
|
+
const candidate = row;
|
|
62
|
+
if (typeof candidate.get === "function") return candidate.get(name);
|
|
63
|
+
return row[name];
|
|
64
|
+
}
|
|
65
|
+
function toStringArray(value) {
|
|
66
|
+
if (!Array.isArray(value)) return [];
|
|
67
|
+
return value.filter((item) => typeof item === "string" && item.length > 0);
|
|
68
|
+
}
|
|
69
|
+
function buildAccessScope(row) {
|
|
70
|
+
if (!row) return NO_RECORD_SCOPE;
|
|
71
|
+
if (valueOf(row, "enabled") === false) {
|
|
72
|
+
return { ...NO_RECORD_SCOPE, hasUserRecord: true, denyAll: true, allowedServices: [] };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
hasUserRecord: true,
|
|
76
|
+
denyAll: false,
|
|
77
|
+
allowedServices: toStringArray(valueOf(row, "allowedLlmServices")),
|
|
78
|
+
allowAllModels: valueOf(row, "allowAllModels") !== false,
|
|
79
|
+
allowedModels: new Set(toStringArray(valueOf(row, "allowedModels"))),
|
|
80
|
+
lookupFailed: false
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function resolveUserAccessScope(ctx) {
|
|
84
|
+
var _a, _b, _c, _d;
|
|
85
|
+
const userId = (_a = ctx.state.currentUser) == null ? void 0 : _a.id;
|
|
86
|
+
if (userId === void 0 || userId === null) return NO_RECORD_SCOPE;
|
|
87
|
+
const key = `${((_b = ctx.app) == null ? void 0 : _b.name) ?? "main"}:${userId}`;
|
|
88
|
+
const cached = scopeCache.get(key);
|
|
89
|
+
if (cached && cached.expiresAt > Date.now()) return cached.scope;
|
|
90
|
+
let scope;
|
|
91
|
+
try {
|
|
92
|
+
const row = await ctx.db.getRepository("aiApiUserPermissions").findOne({ filter: { userId } });
|
|
93
|
+
scope = buildAccessScope(row);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
(_d = (_c = ctx.log) == null ? void 0 : _c.error) == null ? void 0 : _d.call(_c, "AI API user permissions lookup failed, denying access:", err);
|
|
96
|
+
return LOOKUP_FAILED_SCOPE;
|
|
97
|
+
}
|
|
98
|
+
scopeCache.set(key, { scope, expiresAt: Date.now() + SCOPE_TTL_MS });
|
|
99
|
+
return scope;
|
|
100
|
+
}
|
|
101
|
+
function matchesService(list, serviceName, serviceTitle) {
|
|
102
|
+
return list.some((entry) => entry === serviceName || entry === serviceTitle);
|
|
103
|
+
}
|
|
104
|
+
function isServiceAllowed(scope, globalEnabledServices, service) {
|
|
105
|
+
if (scope.denyAll) return false;
|
|
106
|
+
const globalList = toStringArray(globalEnabledServices);
|
|
107
|
+
if (globalList.length && !matchesService(globalList, service.name, service.title)) return false;
|
|
108
|
+
if (!scope.hasUserRecord) return true;
|
|
109
|
+
return matchesService(scope.allowedServices ?? [], service.name, service.title);
|
|
110
|
+
}
|
|
111
|
+
function isModelAllowed(scope, fullModelId) {
|
|
112
|
+
if (scope.denyAll) return false;
|
|
113
|
+
if (!scope.hasUserRecord) return true;
|
|
114
|
+
if (scope.allowAllModels) return true;
|
|
115
|
+
return scope.allowedModels.has(fullModelId);
|
|
116
|
+
}
|
|
117
|
+
async function enforceModelAccess(ctx, globalEnabledServices, service, modelId) {
|
|
118
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
119
|
+
const serviceLabel = service.title || service.name;
|
|
120
|
+
if (scope.lookupFailed) {
|
|
121
|
+
ctx.status = 503;
|
|
122
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
123
|
+
503,
|
|
124
|
+
"Unable to verify LLM permissions for this user. Please retry shortly.",
|
|
125
|
+
"service_unavailable",
|
|
126
|
+
"permission_check_failed"
|
|
127
|
+
);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
if (!isServiceAllowed(scope, globalEnabledServices, service)) {
|
|
131
|
+
ctx.status = 403;
|
|
132
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
133
|
+
403,
|
|
134
|
+
`LLM service '${serviceLabel}' is not enabled for API access`,
|
|
135
|
+
"permission_denied",
|
|
136
|
+
"model_not_available"
|
|
137
|
+
);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (!isModelAllowed(scope, `${service.name}/${modelId}`)) {
|
|
141
|
+
ctx.status = 403;
|
|
142
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
143
|
+
403,
|
|
144
|
+
`Model '${service.name}/${modelId}' is not permitted for this user. Use GET /v1/models to see available models.`,
|
|
145
|
+
"permission_denied",
|
|
146
|
+
"model_not_available"
|
|
147
|
+
);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
153
|
+
0 && (module.exports = {
|
|
154
|
+
buildAccessScope,
|
|
155
|
+
enforceModelAccess,
|
|
156
|
+
invalidateUserPermissionCache,
|
|
157
|
+
isModelAllowed,
|
|
158
|
+
isServiceAllowed,
|
|
159
|
+
resolveUserAccessScope
|
|
160
|
+
});
|
package/dist/swagger.js
CHANGED
|
@@ -78,7 +78,7 @@ var swagger_default = {
|
|
|
78
78
|
get: {
|
|
79
79
|
tags: ["ai-llm"],
|
|
80
80
|
summary: "List available models",
|
|
81
|
-
description: "Returns
|
|
81
|
+
description: "Returns the LLM models available to the authenticated caller across registered services. Model IDs are formatted as `serviceName/modelId`.\n\nThe catalog is user-scoped: it starts from `enabledLlmServices` in the AI API configuration, then narrows to the caller's `aiApiUserPermissions` record when one exists. A user grant can only narrow the global whitelist, never widen it, so two users may receive different lists from the same request.",
|
|
82
82
|
security: [{ BearerAuth: [] }],
|
|
83
83
|
responses: {
|
|
84
84
|
200: {
|
|
@@ -105,6 +105,7 @@ var swagger_default = {
|
|
|
105
105
|
get: {
|
|
106
106
|
tags: ["ai-llm"],
|
|
107
107
|
summary: "Get model details",
|
|
108
|
+
description: "User-scoped in the same way as `GET /v1/models`: a model the caller is not granted is reported as not found rather than disclosed.",
|
|
108
109
|
security: [{ BearerAuth: [] }],
|
|
109
110
|
parameters: [
|
|
110
111
|
{
|
|
@@ -124,7 +125,7 @@ var swagger_default = {
|
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
127
|
},
|
|
127
|
-
404: { description: "Model not found" }
|
|
128
|
+
404: { description: "Model not found, or not available to this user" }
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
},
|
|
@@ -272,7 +273,7 @@ var swagger_default = {
|
|
|
272
273
|
enabledLlmServices: {
|
|
273
274
|
type: "array",
|
|
274
275
|
items: { type: "string" },
|
|
275
|
-
description: "List of enabled LLM service names"
|
|
276
|
+
description: "List of enabled LLM service names. This is the outer bound for every caller; per-user `aiApiUserPermissions` records can only narrow it further."
|
|
276
277
|
},
|
|
277
278
|
rateLimitPerMinute: {
|
|
278
279
|
type: "integer",
|
package/package.json
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
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 { Application } from '@nocobase/client';
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../../constants';
|
|
13
|
+
import { PluginAiApiClient } from '../plugin';
|
|
14
|
+
|
|
15
|
+
describe('AI API v1 settings registration', () => {
|
|
16
|
+
async function loadPlugin() {
|
|
17
|
+
const app = new Application({});
|
|
18
|
+
await new PluginAiApiClient({}, app).load();
|
|
19
|
+
return app;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it('registers the same ordered settings tabs and ACL boundaries as v2', async () => {
|
|
23
|
+
const app = await loadPlugin();
|
|
24
|
+
app.pluginSettingsManager.setAclSnippets([]);
|
|
25
|
+
|
|
26
|
+
const menu = app.pluginSettingsManager.get('ai-api');
|
|
27
|
+
|
|
28
|
+
expect(menu?.children?.map((item) => item.name)).toEqual([
|
|
29
|
+
'ai-api.config',
|
|
30
|
+
'ai-api.model-pricing',
|
|
31
|
+
'ai-api.model-metadata',
|
|
32
|
+
'ai-api.user-permissions',
|
|
33
|
+
'ai-api.user-quotas',
|
|
34
|
+
'ai-api.usage',
|
|
35
|
+
]);
|
|
36
|
+
expect(app.pluginSettingsManager.getAclSnippet('ai-api.user-permissions')).toBe(AI_API_USER_PERMISSIONS_SNIPPET);
|
|
37
|
+
expect(app.pluginSettingsManager.getRoutePath('ai-api.user-permissions')).toBe(
|
|
38
|
+
'/admin/settings/ai-api/user-permissions',
|
|
39
|
+
);
|
|
40
|
+
expect(app.pluginSettingsManager.getList().map((item) => item.name)).not.toContain('ai-api-user-permissions');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('requires access to the legacy parent before exposing the user permissions tab', async () => {
|
|
44
|
+
const app = await loadPlugin();
|
|
45
|
+
app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`]);
|
|
46
|
+
|
|
47
|
+
expect(app.pluginSettingsManager.getList()).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('hides only the user permissions tab when its own snippet is denied', async () => {
|
|
51
|
+
const app = await loadPlugin();
|
|
52
|
+
app.pluginSettingsManager.setAclSnippets([`!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
|
|
53
|
+
|
|
54
|
+
expect(app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name)).toEqual([
|
|
55
|
+
'ai-api.config',
|
|
56
|
+
'ai-api.model-pricing',
|
|
57
|
+
'ai-api.model-metadata',
|
|
58
|
+
'ai-api.user-quotas',
|
|
59
|
+
'ai-api.usage',
|
|
60
|
+
]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('hides both settings surfaces when both snippets are denied', async () => {
|
|
64
|
+
const app = await loadPlugin();
|
|
65
|
+
app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`, `!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
|
|
66
|
+
|
|
67
|
+
expect(app.pluginSettingsManager.getList()).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/client/plugin.tsx
CHANGED
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
|
|
10
10
|
import { Plugin, lazy } from '@nocobase/client';
|
|
11
11
|
import PluginACLClient from '@nocobase/plugin-acl/client';
|
|
12
|
-
import { AI_API_ACL_SNIPPET } from '../constants';
|
|
12
|
+
import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
|
|
13
13
|
import React from 'react';
|
|
14
14
|
|
|
15
15
|
const AiApiConfigPage = React.lazy(() => import('../client-v2/pages/GeneralPage'));
|
|
16
16
|
const AiApiModelPricingPage = React.lazy(() => import('../client-v2/pages/ModelPricingPage'));
|
|
17
17
|
const AiApiModelMetadataPage = React.lazy(() => import('../client-v2/pages/ModelMetadataPage'));
|
|
18
|
+
const AiApiUserPermissionsPage = React.lazy(() => import('../client-v2/pages/UserPermissionsPage'));
|
|
18
19
|
const AiApiUserQuotasPage = React.lazy(() => import('../client-v2/pages/UserQuotasPage'));
|
|
19
20
|
const AiApiUsagePage = React.lazy(() => import('../client-v2/pages/UsagePage'));
|
|
20
21
|
const { AiApiRolePermissions } = lazy(() => import('./components/AiApiRolePermissions'), 'AiApiRolePermissions');
|
|
@@ -48,18 +49,28 @@ export class PluginAiApiClient extends Plugin {
|
|
|
48
49
|
sort: 3,
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
// Keep the legacy settings layout aligned with client-v2. The v1 settings
|
|
53
|
+
// manager evaluates the parent ACL first, so this tab additionally requires
|
|
54
|
+
// access to the AI API settings parent even though it keeps its own snippet.
|
|
55
|
+
this.app.pluginSettingsManager.add('ai-api.user-permissions', {
|
|
56
|
+
title: this.t('User LLM permissions'),
|
|
57
|
+
Component: AiApiUserPermissionsPage,
|
|
58
|
+
aclSnippet: AI_API_USER_PERMISSIONS_SNIPPET,
|
|
59
|
+
sort: 4,
|
|
60
|
+
});
|
|
61
|
+
|
|
51
62
|
this.app.pluginSettingsManager.add('ai-api.user-quotas', {
|
|
52
63
|
title: this.t('User quotas'),
|
|
53
64
|
Component: AiApiUserQuotasPage,
|
|
54
65
|
aclSnippet: AI_API_ACL_SNIPPET,
|
|
55
|
-
sort:
|
|
66
|
+
sort: 5,
|
|
56
67
|
});
|
|
57
68
|
|
|
58
69
|
this.app.pluginSettingsManager.add('ai-api.usage', {
|
|
59
70
|
title: this.t('Usage'),
|
|
60
71
|
Component: AiApiUsagePage,
|
|
61
72
|
aclSnippet: AI_API_ACL_SNIPPET,
|
|
62
|
-
sort:
|
|
73
|
+
sort: 6,
|
|
63
74
|
});
|
|
64
75
|
|
|
65
76
|
// Add "AI API" tab in Settings → Users & Permissions → [Role]
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { createMockClient } from '@nocobase/client-v2';
|
|
11
|
-
import { AI_API_ACL_SNIPPET } from '../../constants';
|
|
11
|
+
import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../../constants';
|
|
12
12
|
import { PluginAiApiClient } from '../plugin';
|
|
13
13
|
|
|
14
14
|
/**
|
|
@@ -17,6 +17,11 @@ import { PluginAiApiClient } from '../plugin';
|
|
|
17
17
|
* Two regressions are cheap to reintroduce and invisible until a non-admin logs in:
|
|
18
18
|
* omitting `aclSnippet` makes addPageTabItem default to `pm.ai-api.<key>`, a snippet the
|
|
19
19
|
* server never registers, and omitting `sort` silently reorders the tabs alphabetically.
|
|
20
|
+
*
|
|
21
|
+
* The tabs deliberately span two snippets: everything sits under AI_API_ACL_SNIPPET except
|
|
22
|
+
* user permissions, which is gated separately so granting it does not also grant gateway
|
|
23
|
+
* configuration. Each snippet is evaluated independently, so denying one leaves the other's
|
|
24
|
+
* tabs — and therefore the menu — reachable.
|
|
20
25
|
*/
|
|
21
26
|
describe('AI API v2 settings registration', () => {
|
|
22
27
|
async function loadPlugin() {
|
|
@@ -25,24 +30,47 @@ describe('AI API v2 settings registration', () => {
|
|
|
25
30
|
return app;
|
|
26
31
|
}
|
|
27
32
|
|
|
28
|
-
it('registers every page under
|
|
33
|
+
it('registers every page under a snippet the server exposes', async () => {
|
|
29
34
|
const app = await loadPlugin();
|
|
30
35
|
const menu = app.pluginSettingsManager.get('ai-api', false);
|
|
31
36
|
|
|
32
37
|
expect(menu?.aclSnippet).toBe(AI_API_ACL_SNIPPET);
|
|
33
38
|
for (const child of menu?.children ?? []) {
|
|
34
|
-
expect(app.pluginSettingsManager.getAclSnippet(child.name), child.name).toBe(
|
|
39
|
+
expect(app.pluginSettingsManager.getAclSnippet(child.name), child.name).toBe(
|
|
40
|
+
child.name === 'ai-api.user-permissions' ? AI_API_USER_PERMISSIONS_SNIPPET : AI_API_ACL_SNIPPET,
|
|
41
|
+
);
|
|
35
42
|
}
|
|
36
43
|
});
|
|
37
44
|
|
|
38
|
-
it('
|
|
45
|
+
it('leaves only the separately-gated tab when the main snippet is denied', async () => {
|
|
39
46
|
const app = await loadPlugin();
|
|
40
47
|
app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`]);
|
|
41
48
|
|
|
49
|
+
// User permissions carries its own snippet, so it survives on its own merit and keeps the
|
|
50
|
+
// menu reachable. That separation is the point: a role can be allowed to hand out model
|
|
51
|
+
// access without also being allowed to reconfigure the gateway.
|
|
52
|
+
expect(app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name)).toEqual([
|
|
53
|
+
'ai-api.user-permissions',
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('hides the menu and every tab when both snippets are denied', async () => {
|
|
58
|
+
const app = await loadPlugin();
|
|
59
|
+
app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`, `!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
|
|
60
|
+
|
|
42
61
|
expect(app.pluginSettingsManager.get('ai-api')).toBeNull();
|
|
43
62
|
expect(app.pluginSettingsManager.getList().map((item) => item.name)).not.toContain('ai-api');
|
|
44
63
|
});
|
|
45
64
|
|
|
65
|
+
it('hides only the user permissions tab when its own snippet is denied', async () => {
|
|
66
|
+
const app = await loadPlugin();
|
|
67
|
+
app.pluginSettingsManager.setAclSnippets([`!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
|
|
68
|
+
|
|
69
|
+
const children = app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name) ?? [];
|
|
70
|
+
expect(children).not.toContain('ai-api.user-permissions');
|
|
71
|
+
expect(children).toContain('ai-api.user-quotas');
|
|
72
|
+
});
|
|
73
|
+
|
|
46
74
|
it('keeps registration order instead of sorting tabs by name', async () => {
|
|
47
75
|
const app = await loadPlugin();
|
|
48
76
|
app.pluginSettingsManager.setAclSnippets([]);
|
|
@@ -51,6 +79,7 @@ describe('AI API v2 settings registration', () => {
|
|
|
51
79
|
'ai-api.index',
|
|
52
80
|
'ai-api.model-pricing',
|
|
53
81
|
'ai-api.model-metadata',
|
|
82
|
+
'ai-api.user-permissions',
|
|
54
83
|
'ai-api.user-quotas',
|
|
55
84
|
'ai-api.usage',
|
|
56
85
|
]);
|