plugin-ai-api 1.0.8 → 1.0.9
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/externalVersion.js +1 -1
- package/dist/server/collections/ai-api-usage-records.js +63 -0
- package/dist/server/middleware/role-permission.js +15 -5
- package/dist/server/plugin.js +8 -1
- package/dist/server/routes/auth.js +37 -7
- package/dist/server/routes/router.js +22 -1
- package/dist/server/usage.js +80 -0
- package/package.json +1 -1
- package/src/server/collections/ai-api-usage-records.ts +33 -0
- package/src/server/middleware/role-permission.ts +79 -66
- package/src/server/plugin.ts +99 -89
- package/src/server/routes/auth.ts +142 -111
- package/src/server/routes/router.ts +304 -283
- package/src/server/usage.ts +52 -0
package/dist/externalVersion.js
CHANGED
|
@@ -16,8 +16,8 @@ module.exports = {
|
|
|
16
16
|
"@nocobase/plugin-acl": "2.1.23",
|
|
17
17
|
"@nocobase/server": "2.1.23",
|
|
18
18
|
"dayjs": "1.11.13",
|
|
19
|
-
"@nocobase/database": "2.1.23",
|
|
20
19
|
"@nocobase/actions": "2.1.23",
|
|
20
|
+
"@nocobase/database": "2.1.23",
|
|
21
21
|
"@nocobase/resourcer": "2.1.23",
|
|
22
22
|
"@nocobase/plugin-ai": "2.1.23"
|
|
23
23
|
};
|
|
@@ -0,0 +1,63 @@
|
|
|
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 ai_api_usage_records_exports = {};
|
|
28
|
+
__export(ai_api_usage_records_exports, {
|
|
29
|
+
default: () => ai_api_usage_records_default
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(ai_api_usage_records_exports);
|
|
32
|
+
var import_database = require("@nocobase/database");
|
|
33
|
+
var ai_api_usage_records_default = (0, import_database.defineCollection)({
|
|
34
|
+
name: "aiApiUsageRecords",
|
|
35
|
+
autoGenId: true,
|
|
36
|
+
fields: [
|
|
37
|
+
{ name: "requestId", type: "string", unique: true, index: true },
|
|
38
|
+
{ name: "userId", type: "string", index: true },
|
|
39
|
+
{ name: "roleName", type: "string", index: true },
|
|
40
|
+
{ name: "authType", type: "string", index: true },
|
|
41
|
+
{ name: "oauthClientId", type: "string", allowNull: true, index: true },
|
|
42
|
+
{ name: "oauthSubject", type: "string", allowNull: true },
|
|
43
|
+
{ name: "oauthScopes", type: "json", allowNull: true },
|
|
44
|
+
{ name: "endpoint", type: "string" },
|
|
45
|
+
{ name: "mode", type: "string", allowNull: true },
|
|
46
|
+
{ name: "model", type: "string", allowNull: true, index: true },
|
|
47
|
+
{ name: "status", type: "string", index: true },
|
|
48
|
+
{ name: "httpStatus", type: "integer", allowNull: true },
|
|
49
|
+
{ name: "errorCode", type: "string", allowNull: true },
|
|
50
|
+
{ name: "streaming", type: "boolean", defaultValue: false },
|
|
51
|
+
{ name: "inputTokens", type: "integer", allowNull: true },
|
|
52
|
+
{ name: "outputTokens", type: "integer", allowNull: true },
|
|
53
|
+
{ name: "totalTokens", type: "integer", allowNull: true },
|
|
54
|
+
{ name: "estimatedCost", type: "decimal", allowNull: true, precision: 20, scale: 8 },
|
|
55
|
+
{ name: "currency", type: "string", allowNull: true },
|
|
56
|
+
{ name: "providerRequestId", type: "string", allowNull: true },
|
|
57
|
+
{ name: "requestMetadata", type: "jsonb", defaultValue: {} },
|
|
58
|
+
{ name: "responseMetadata", type: "jsonb", defaultValue: {} },
|
|
59
|
+
{ name: "startedAt", type: "datetimeTz", allowNull: true },
|
|
60
|
+
{ name: "completedAt", type: "datetimeTz", allowNull: true },
|
|
61
|
+
{ name: "durationMs", type: "integer", allowNull: true }
|
|
62
|
+
]
|
|
63
|
+
});
|
|
@@ -27,19 +27,28 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
27
27
|
var role_permission_exports = {};
|
|
28
28
|
__export(role_permission_exports, {
|
|
29
29
|
checkEmployeeAccess: () => checkEmployeeAccess,
|
|
30
|
-
checkRolePermission: () => checkRolePermission
|
|
30
|
+
checkRolePermission: () => checkRolePermission,
|
|
31
|
+
invalidateRolePermissionCache: () => invalidateRolePermissionCache
|
|
31
32
|
});
|
|
32
33
|
module.exports = __toCommonJS(role_permission_exports);
|
|
33
34
|
var import_openai_format = require("../utils/openai-format");
|
|
35
|
+
const PERMISSION_TTL_MS = 15e3;
|
|
36
|
+
const permissionCache = /* @__PURE__ */ new Map();
|
|
37
|
+
function invalidateRolePermissionCache(roleName) {
|
|
38
|
+
if (roleName) permissionCache.delete(roleName);
|
|
39
|
+
else permissionCache.clear();
|
|
40
|
+
}
|
|
34
41
|
async function checkRolePermission(ctx) {
|
|
35
42
|
var _a;
|
|
36
43
|
const roleName = ((_a = ctx.state.currentRoles) == null ? void 0 : _a[0]) || "member";
|
|
37
44
|
if (roleName === "root" || roleName === "admin") {
|
|
38
45
|
return true;
|
|
39
46
|
}
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
const cached = permissionCache.get(roleName);
|
|
48
|
+
const record = cached && cached.expiresAt > Date.now() ? cached.record : await ctx.db.getRepository("aiApiRolePermissions").findOne({ filter: { roleName } });
|
|
49
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
50
|
+
permissionCache.set(roleName, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
|
|
51
|
+
}
|
|
43
52
|
if (!(record == null ? void 0 : record.enabled)) {
|
|
44
53
|
ctx.status = 403;
|
|
45
54
|
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
@@ -62,5 +71,6 @@ function checkEmployeeAccess(ctx, employeeUsername) {
|
|
|
62
71
|
// Annotate the CommonJS export names for ESM import in node:
|
|
63
72
|
0 && (module.exports = {
|
|
64
73
|
checkEmployeeAccess,
|
|
65
|
-
checkRolePermission
|
|
74
|
+
checkRolePermission,
|
|
75
|
+
invalidateRolePermissionCache
|
|
66
76
|
});
|
package/dist/server/plugin.js
CHANGED
|
@@ -44,6 +44,7 @@ var import_server = require("@nocobase/server");
|
|
|
44
44
|
var import_router = require("./routes/router");
|
|
45
45
|
var import_ai_api_config = __toESM(require("./resource/ai-api-config"));
|
|
46
46
|
var import_rate_limiter = require("./utils/rate-limiter");
|
|
47
|
+
var import_role_permission = require("./middleware/role-permission");
|
|
47
48
|
var import_dayjs = __toESM(require("dayjs"));
|
|
48
49
|
var import_utc = __toESM(require("dayjs/plugin/utc"));
|
|
49
50
|
var import_timezone = __toESM(require("dayjs/plugin/timezone"));
|
|
@@ -61,8 +62,14 @@ class PluginAiApiServer extends import_server.Plugin {
|
|
|
61
62
|
async beforeLoad() {
|
|
62
63
|
}
|
|
63
64
|
async load() {
|
|
64
|
-
this.app.use((0, import_router.createAiLlmRouter)(this), { before: "resourcer" });
|
|
65
|
+
this.app.use((0, import_router.createAiLlmRouter)(this), { after: "idp-oauth-resource-auth", before: "resourcer" });
|
|
65
66
|
this.app.resourceManager.define(import_ai_api_config.default);
|
|
67
|
+
this.app.db.on("aiApiRolePermissions.afterSave", (model) => {
|
|
68
|
+
(0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
|
|
69
|
+
});
|
|
70
|
+
this.app.db.on("aiApiRolePermissions.afterDestroy", (model) => {
|
|
71
|
+
(0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
|
|
72
|
+
});
|
|
66
73
|
this.app.acl.registerSnippet({
|
|
67
74
|
name: `pm.${this.name}.configuration`,
|
|
68
75
|
actions: ["aiApiConfig:*", "aiApiRolePermissions:*"]
|
|
@@ -37,7 +37,7 @@ async function authenticateBearer(ctx) {
|
|
|
37
37
|
ctx.status = 401;
|
|
38
38
|
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
39
39
|
401,
|
|
40
|
-
"Missing or invalid Authorization header. Expected: Bearer <
|
|
40
|
+
"Missing or invalid Authorization header. Expected: Bearer <access-token>",
|
|
41
41
|
"invalid_request_error",
|
|
42
42
|
"invalid_api_key"
|
|
43
43
|
);
|
|
@@ -50,11 +50,16 @@ async function authenticateBearer(ctx) {
|
|
|
50
50
|
return false;
|
|
51
51
|
}
|
|
52
52
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
if (ctx.state.currentUser) {
|
|
54
|
+
if (!ctx.state.currentRole) {
|
|
55
|
+
const requestedRole = ctx.get("X-Role");
|
|
56
|
+
const rolesRepository2 = ctx.db.getRepository("users.roles", ctx.state.currentUser.id);
|
|
57
|
+
const roles2 = await rolesRepository2.find({ fields: ["name"] });
|
|
58
|
+
const roleNames2 = roles2.map((role) => role.name);
|
|
59
|
+
ctx.state.currentRole = roleNames2.includes(requestedRole) ? requestedRole : roleNames2[0];
|
|
60
|
+
ctx.state.currentRoles = ctx.state.currentRole ? [ctx.state.currentRole] : roleNames2;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
58
63
|
}
|
|
59
64
|
const jwt = (_a = ctx.app["authManager"]) == null ? void 0 : _a.jwt;
|
|
60
65
|
if (!jwt) {
|
|
@@ -75,6 +80,16 @@ async function authenticateBearer(ctx) {
|
|
|
75
80
|
ctx.body = (0, import_openai_format.toOpenAIError)(401, "Invalid or expired API key", "invalid_request_error", "invalid_api_key");
|
|
76
81
|
return false;
|
|
77
82
|
}
|
|
83
|
+
if (!decoded.roleName) {
|
|
84
|
+
ctx.status = 401;
|
|
85
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
86
|
+
401,
|
|
87
|
+
"Token was not resolved by the NocoBase auth middleware",
|
|
88
|
+
"invalid_request_error",
|
|
89
|
+
"invalid_api_key"
|
|
90
|
+
);
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
78
93
|
const user = await ctx.db.getRepository("users").findOne({
|
|
79
94
|
filterByTk: decoded.userId
|
|
80
95
|
});
|
|
@@ -84,7 +99,22 @@ async function authenticateBearer(ctx) {
|
|
|
84
99
|
return false;
|
|
85
100
|
}
|
|
86
101
|
ctx.state.currentUser = user;
|
|
87
|
-
|
|
102
|
+
const rolesRepository = ctx.db.getRepository("users.roles", user.id);
|
|
103
|
+
const roles = await rolesRepository.find({ fields: ["name"] });
|
|
104
|
+
const roleNames = roles.map((role) => role.name);
|
|
105
|
+
if (!roleNames.includes(decoded.roleName)) {
|
|
106
|
+
ctx.status = 403;
|
|
107
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
108
|
+
403,
|
|
109
|
+
"The API key role is no longer assigned to this user",
|
|
110
|
+
"permission_denied",
|
|
111
|
+
"role_not_permitted"
|
|
112
|
+
);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
ctx.state.currentRole = decoded.roleName;
|
|
116
|
+
ctx.state.currentRoles = [decoded.roleName];
|
|
117
|
+
ctx.state.aiApiAuthType = "apiKey";
|
|
88
118
|
if (!ctx.auth) {
|
|
89
119
|
ctx.auth = {};
|
|
90
120
|
}
|
|
@@ -49,11 +49,12 @@ var import_embeddings = require("./embeddings");
|
|
|
49
49
|
var import_openai_format = require("../utils/openai-format");
|
|
50
50
|
var import_rate_limit = require("../middleware/rate-limit");
|
|
51
51
|
var import_role_permission = require("../middleware/role-permission");
|
|
52
|
+
var import_usage = require("../usage");
|
|
52
53
|
const API_PREFIX = "/api/ai-llm/v1";
|
|
53
54
|
function createAiLlmRouter(plugin) {
|
|
54
55
|
const checkRateLimit = (0, import_rate_limit.createRateLimitMiddleware)(plugin.rateLimiter);
|
|
55
56
|
return async (ctx, next) => {
|
|
56
|
-
var _a;
|
|
57
|
+
var _a, _b;
|
|
57
58
|
const { path, method } = ctx;
|
|
58
59
|
if (!path.startsWith(API_PREFIX)) {
|
|
59
60
|
return next();
|
|
@@ -100,6 +101,18 @@ function createAiLlmRouter(plugin) {
|
|
|
100
101
|
}
|
|
101
102
|
const model = ((_a = ctx.request.body) == null ? void 0 : _a.model) ?? "-";
|
|
102
103
|
const t0 = Date.now();
|
|
104
|
+
let usageId;
|
|
105
|
+
try {
|
|
106
|
+
usageId = await (0, import_usage.startUsageRecord)(
|
|
107
|
+
ctx,
|
|
108
|
+
requestId,
|
|
109
|
+
subPath,
|
|
110
|
+
String(model),
|
|
111
|
+
Boolean((_b = ctx.request.body) == null ? void 0 : _b.stream)
|
|
112
|
+
);
|
|
113
|
+
} catch (usageError) {
|
|
114
|
+
ctx.log.error("AI API usage record could not be created:", usageError);
|
|
115
|
+
}
|
|
103
116
|
try {
|
|
104
117
|
if (method === "POST" && subPath === "/chat/completions") {
|
|
105
118
|
const mode = await resolveMode(ctx);
|
|
@@ -166,6 +179,14 @@ function createAiLlmRouter(plugin) {
|
|
|
166
179
|
ctx.status = 500;
|
|
167
180
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, err.message || "Internal server error", "server_error");
|
|
168
181
|
}
|
|
182
|
+
} finally {
|
|
183
|
+
if (usageId !== void 0) {
|
|
184
|
+
try {
|
|
185
|
+
await (0, import_usage.finishUsageRecord)(ctx, usageId, t0, ctx.status >= 200 && ctx.status < 400 ? "succeeded" : "failed");
|
|
186
|
+
} catch (usageError) {
|
|
187
|
+
ctx.log.error("AI API usage record could not be finalized:", usageError);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
169
190
|
}
|
|
170
191
|
};
|
|
171
192
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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 usage_exports = {};
|
|
28
|
+
__export(usage_exports, {
|
|
29
|
+
finishUsageRecord: () => finishUsageRecord,
|
|
30
|
+
startUsageRecord: () => startUsageRecord
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(usage_exports);
|
|
33
|
+
async function startUsageRecord(ctx, requestId, endpoint, model, streaming) {
|
|
34
|
+
var _a, _b;
|
|
35
|
+
const body = ctx.request.body || {};
|
|
36
|
+
const messages = Array.isArray(body.messages) ? body.messages : void 0;
|
|
37
|
+
const oauth = ctx.state.oauthPrincipal;
|
|
38
|
+
const record = await ctx.db.getRepository("aiApiUsageRecords").create({
|
|
39
|
+
values: {
|
|
40
|
+
requestId,
|
|
41
|
+
userId: String((_a = ctx.state.currentUser) == null ? void 0 : _a.id),
|
|
42
|
+
roleName: ctx.state.currentRole || ((_b = ctx.state.currentRoles) == null ? void 0 : _b[0]) || "unknown",
|
|
43
|
+
authType: ctx.state.aiApiAuthType || (oauth ? "oidc" : "session"),
|
|
44
|
+
oauthClientId: oauth == null ? void 0 : oauth.clientId,
|
|
45
|
+
oauthSubject: oauth == null ? void 0 : oauth.subject,
|
|
46
|
+
oauthScopes: oauth == null ? void 0 : oauth.scopes,
|
|
47
|
+
endpoint,
|
|
48
|
+
mode: ctx.get("X-AI-Mode") || void 0,
|
|
49
|
+
model: model === "-" ? void 0 : model,
|
|
50
|
+
status: "pending",
|
|
51
|
+
streaming,
|
|
52
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
53
|
+
requestMetadata: { messageCount: messages == null ? void 0 : messages.length, requestedMaxTokens: body.max_tokens }
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return record.id;
|
|
57
|
+
}
|
|
58
|
+
async function finishUsageRecord(ctx, id, startedAt, status) {
|
|
59
|
+
var _a;
|
|
60
|
+
const response = ctx.body || {};
|
|
61
|
+
const usage = response.usage;
|
|
62
|
+
const values = {
|
|
63
|
+
status,
|
|
64
|
+
httpStatus: ctx.status,
|
|
65
|
+
errorCode: (_a = response.error) == null ? void 0 : _a.code,
|
|
66
|
+
inputTokens: usage == null ? void 0 : usage.prompt_tokens,
|
|
67
|
+
outputTokens: usage == null ? void 0 : usage.completion_tokens,
|
|
68
|
+
totalTokens: usage == null ? void 0 : usage.total_tokens,
|
|
69
|
+
providerRequestId: response.id,
|
|
70
|
+
completedAt: /* @__PURE__ */ new Date(),
|
|
71
|
+
durationMs: Date.now() - startedAt,
|
|
72
|
+
responseMetadata: { usageSource: usage ? "response" : "unavailable" }
|
|
73
|
+
};
|
|
74
|
+
await ctx.db.getRepository("aiApiUsageRecords").update({ filterByTk: id, values });
|
|
75
|
+
}
|
|
76
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
77
|
+
0 && (module.exports = {
|
|
78
|
+
finishUsageRecord,
|
|
79
|
+
startUsageRecord
|
|
80
|
+
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineCollection } from '@nocobase/database';
|
|
2
|
+
|
|
3
|
+
export default defineCollection({
|
|
4
|
+
name: 'aiApiUsageRecords',
|
|
5
|
+
autoGenId: true,
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: 'requestId', type: 'string', unique: true, index: true },
|
|
8
|
+
{ name: 'userId', type: 'string', index: true },
|
|
9
|
+
{ name: 'roleName', type: 'string', index: true },
|
|
10
|
+
{ name: 'authType', type: 'string', index: true },
|
|
11
|
+
{ name: 'oauthClientId', type: 'string', allowNull: true, index: true },
|
|
12
|
+
{ name: 'oauthSubject', type: 'string', allowNull: true },
|
|
13
|
+
{ name: 'oauthScopes', type: 'json', allowNull: true },
|
|
14
|
+
{ name: 'endpoint', type: 'string' },
|
|
15
|
+
{ name: 'mode', type: 'string', allowNull: true },
|
|
16
|
+
{ name: 'model', type: 'string', allowNull: true, index: true },
|
|
17
|
+
{ name: 'status', type: 'string', index: true },
|
|
18
|
+
{ name: 'httpStatus', type: 'integer', allowNull: true },
|
|
19
|
+
{ name: 'errorCode', type: 'string', allowNull: true },
|
|
20
|
+
{ name: 'streaming', type: 'boolean', defaultValue: false },
|
|
21
|
+
{ name: 'inputTokens', type: 'integer', allowNull: true },
|
|
22
|
+
{ name: 'outputTokens', type: 'integer', allowNull: true },
|
|
23
|
+
{ name: 'totalTokens', type: 'integer', allowNull: true },
|
|
24
|
+
{ name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
|
|
25
|
+
{ name: 'currency', type: 'string', allowNull: true },
|
|
26
|
+
{ name: 'providerRequestId', type: 'string', allowNull: true },
|
|
27
|
+
{ name: 'requestMetadata', type: 'jsonb', defaultValue: {} },
|
|
28
|
+
{ name: 'responseMetadata', type: 'jsonb', defaultValue: {} },
|
|
29
|
+
{ name: 'startedAt', type: 'datetimeTz', allowNull: true },
|
|
30
|
+
{ name: 'completedAt', type: 'datetimeTz', allowNull: true },
|
|
31
|
+
{ name: 'durationMs', type: 'integer', allowNull: true },
|
|
32
|
+
],
|
|
33
|
+
});
|
|
@@ -1,66 +1,79 @@
|
|
|
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 '../utils/openai-format';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 '../utils/openai-format';
|
|
12
|
+
|
|
13
|
+
const PERMISSION_TTL_MS = 15_000;
|
|
14
|
+
const permissionCache = new Map<string, { record: any; expiresAt: number }>();
|
|
15
|
+
|
|
16
|
+
export function invalidateRolePermissionCache(roleName?: string): void {
|
|
17
|
+
if (roleName) permissionCache.delete(roleName);
|
|
18
|
+
else permissionCache.clear();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check whether the authenticated role is allowed to use the AI API.
|
|
23
|
+
* Loads the permission record and stores it in ctx.state.aiApiRolePermission.
|
|
24
|
+
*
|
|
25
|
+
* Returns true if access is allowed (caller may proceed).
|
|
26
|
+
* Returns false if access is denied (403 already written to ctx, caller must return).
|
|
27
|
+
*
|
|
28
|
+
* The 'root' and 'admin' roles always bypass the check.
|
|
29
|
+
*/
|
|
30
|
+
export async function checkRolePermission(ctx: Context): Promise<boolean> {
|
|
31
|
+
const roleName = ctx.state.currentRoles?.[0] || 'member';
|
|
32
|
+
|
|
33
|
+
// root / admin always allowed
|
|
34
|
+
if (roleName === 'root' || roleName === 'admin') {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const cached = permissionCache.get(roleName);
|
|
39
|
+
const record =
|
|
40
|
+
cached && cached.expiresAt > Date.now()
|
|
41
|
+
? cached.record
|
|
42
|
+
: await ctx.db.getRepository('aiApiRolePermissions').findOne({ filter: { roleName } });
|
|
43
|
+
if (!cached || cached.expiresAt <= Date.now()) {
|
|
44
|
+
permissionCache.set(roleName, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!record?.enabled) {
|
|
48
|
+
ctx.status = 403;
|
|
49
|
+
ctx.body = toOpenAIError(
|
|
50
|
+
403,
|
|
51
|
+
`Role '${roleName}' is not authorized to use the AI API. ` +
|
|
52
|
+
`An admin must enable access in Settings → Users & Permissions → [Role] → AI API.`,
|
|
53
|
+
'permission_denied',
|
|
54
|
+
'role_not_permitted',
|
|
55
|
+
);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Store for downstream handlers
|
|
60
|
+
ctx.state.aiApiRolePermission = record;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check whether the current role is allowed to use a specific AI Employee.
|
|
66
|
+
* Must be called after checkRolePermission (so ctx.state.aiApiRolePermission is set).
|
|
67
|
+
*
|
|
68
|
+
* Returns true when:
|
|
69
|
+
* - Role is admin/root (no permission record stored)
|
|
70
|
+
* - allowAllEmployees is true
|
|
71
|
+
* - The employeeUsername is in the allowedEmployees list
|
|
72
|
+
*/
|
|
73
|
+
export function checkEmployeeAccess(ctx: Context, employeeUsername: string): boolean {
|
|
74
|
+
const perm = ctx.state.aiApiRolePermission;
|
|
75
|
+
// admin/root paths have no record stored → always allowed
|
|
76
|
+
if (!perm) return true;
|
|
77
|
+
if (perm.allowAllEmployees) return true;
|
|
78
|
+
return ((perm.allowedEmployees as string[]) || []).includes(employeeUsername);
|
|
79
|
+
}
|