plugin-ai-api 1.1.1 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +51 -12
  2. package/dist/client/185.c47663fefaeb0e5b.js +10 -0
  3. package/dist/client/562.9012cfd1fa04303d.js +10 -0
  4. package/dist/client/685.b5b1e0a5b825d253.js +10 -0
  5. package/dist/client/index.js +1 -1
  6. package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
  7. package/dist/client-v2/562.db2984167250b1be.js +10 -0
  8. package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
  9. package/dist/client-v2/index.js +1 -1
  10. package/dist/externalVersion.js +8 -8
  11. package/dist/locale/en-US.json +175 -139
  12. package/dist/locale/vi-VN.json +40 -2
  13. package/dist/locale/zh-CN.json +40 -2
  14. package/dist/server/collections/ai-api-model-metadata.js +26 -0
  15. package/dist/server/collections/ai-api-response-records.js +101 -0
  16. package/dist/server/collections/ai-api-virtual-models.js +68 -0
  17. package/dist/server/middleware/response-record-resource.js +66 -0
  18. package/dist/server/middleware/role-permission.js +43 -18
  19. package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
  20. package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
  21. package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
  22. package/dist/server/plugin.js +66 -16
  23. package/dist/server/routes/chat-completions.js +38 -6
  24. package/dist/server/routes/completions.js +16 -4
  25. package/dist/server/routes/embeddings.js +25 -6
  26. package/dist/server/routes/models.js +29 -0
  27. package/dist/server/routes/responses.js +530 -0
  28. package/dist/server/routes/router.js +65 -10
  29. package/dist/server/usage.js +25 -4
  30. package/dist/server/utils/direct-llm-context.js +1 -1
  31. package/dist/server/utils/resolve-service.js +24 -0
  32. package/dist/server/utils/response-store.js +138 -0
  33. package/dist/server/utils/responses-format.js +686 -0
  34. package/dist/server/utils/responses-stream.js +330 -0
  35. package/dist/server/utils/virtual-models.js +238 -0
  36. package/dist/server/validation.js +44 -2
  37. package/dist/swagger.js +137 -0
  38. package/package.json +34 -32
  39. package/src/__tests__/locale.test.ts +43 -0
  40. package/src/client/__tests__/settings-registration.test.tsx +1 -0
  41. package/src/client/plugin.tsx +9 -1
  42. package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
  43. package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
  44. package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
  45. package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
  46. package/src/client-v2/plugin.tsx +8 -0
  47. package/src/locale/en-US.json +175 -139
  48. package/src/locale/vi-VN.json +40 -2
  49. package/src/locale/zh-CN.json +40 -2
  50. package/src/server/__tests__/embeddings.test.ts +184 -0
  51. package/src/server/__tests__/models.test.ts +21 -1
  52. package/src/server/__tests__/response-record-resource.test.ts +50 -0
  53. package/src/server/__tests__/response-store-integration.test.ts +341 -0
  54. package/src/server/__tests__/response-store.test.ts +195 -0
  55. package/src/server/__tests__/responses-contract.test.ts +469 -0
  56. package/src/server/__tests__/responses-format.test.ts +299 -0
  57. package/src/server/__tests__/responses-router.test.ts +182 -0
  58. package/src/server/__tests__/responses-streaming.test.ts +368 -0
  59. package/src/server/__tests__/responses.test.ts +462 -0
  60. package/src/server/__tests__/role-permission.test.ts +139 -0
  61. package/src/server/__tests__/seed-role-permission.test.ts +88 -0
  62. package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
  63. package/src/server/__tests__/usage-groups.test.ts +96 -0
  64. package/src/server/__tests__/usage-route.test.ts +1 -0
  65. package/src/server/__tests__/usage.test.ts +14 -0
  66. package/src/server/__tests__/validation.test.ts +66 -7
  67. package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
  68. package/src/server/collections/ai-api-model-metadata.ts +26 -0
  69. package/src/server/collections/ai-api-response-records.ts +77 -0
  70. package/src/server/collections/ai-api-virtual-models.ts +58 -0
  71. package/src/server/middleware/response-record-resource.ts +44 -0
  72. package/src/server/middleware/role-permission.ts +69 -35
  73. package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
  74. package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
  75. package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
  76. package/src/server/plugin.ts +84 -20
  77. package/src/server/resource/ai-api-config.ts +2 -1
  78. package/src/server/routes/agent-completions.ts +3 -0
  79. package/src/server/routes/chat-completions.ts +34 -10
  80. package/src/server/routes/completions.ts +16 -4
  81. package/src/server/routes/embeddings.ts +32 -10
  82. package/src/server/routes/models.ts +34 -0
  83. package/src/server/routes/responses.ts +640 -0
  84. package/src/server/routes/router.ts +81 -12
  85. package/src/server/services/__tests__/file-processor.test.ts +1 -0
  86. package/src/server/usage.ts +29 -2
  87. package/src/server/utils/app-observability.ts +1 -1
  88. package/src/server/utils/direct-llm-context.ts +2 -1
  89. package/src/server/utils/openai-format.ts +1 -0
  90. package/src/server/utils/resolve-service.ts +39 -1
  91. package/src/server/utils/response-store.ts +148 -0
  92. package/src/server/utils/responses-format.ts +974 -0
  93. package/src/server/utils/responses-stream.ts +384 -0
  94. package/src/server/utils/virtual-models.ts +320 -0
  95. package/src/server/validation.ts +49 -0
  96. package/src/swagger.ts +139 -0
  97. package/dist/client/562.44b16aad4718b4c7.js +0 -10
  98. package/dist/client/685.ae483e17b6b49c98.js +0 -10
  99. package/dist/client-v2/562.45d5c504433be38b.js +0 -10
  100. package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
  101. package/dist/server/collections/ai-api-user-permissions.js +0 -67
  102. package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
  103. package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
  104. package/dist/server/resource/ai-api-usage-groups.js +0 -168
  105. package/src/server/collections/ai-api-user-permissions.ts +0 -46
  106. package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
  107. package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
  108. package/src/server/resource/ai-api-usage-groups.ts +0 -171
@@ -0,0 +1,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
+ 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 response_record_resource_exports = {};
28
+ __export(response_record_resource_exports, {
29
+ RESPONSE_RECORD_COLLECTION: () => RESPONSE_RECORD_COLLECTION,
30
+ blockResponseRecordResource: () => blockResponseRecordResource,
31
+ targetsResponseRecordCollection: () => targetsResponseRecordCollection
32
+ });
33
+ module.exports = __toCommonJS(response_record_resource_exports);
34
+ const RESPONSE_RECORD_COLLECTION = "aiApiResponseRecords";
35
+ function routeCandidates(ctx) {
36
+ var _a, _b;
37
+ const params = (_a = ctx.action) == null ? void 0 : _a.params;
38
+ const candidates = [(_b = ctx.action) == null ? void 0 : _b.resourceName, params == null ? void 0 : params.resourceName, params == null ? void 0 : params.associatedName, params == null ? void 0 : params.targetCollection];
39
+ return candidates.filter((value) => typeof value === "string").flatMap((value) => value.split("."));
40
+ }
41
+ function targetsResponseRecordCollection(ctx) {
42
+ var _a, _b;
43
+ const repositoryContext = ctx;
44
+ if (repositoryContext.getCurrentRepository) {
45
+ try {
46
+ if (((_b = (_a = repositoryContext.getCurrentRepository()) == null ? void 0 : _a.targetCollection) == null ? void 0 : _b.name) === RESPONSE_RECORD_COLLECTION) return true;
47
+ } catch {
48
+ }
49
+ }
50
+ return routeCandidates(ctx).includes(RESPONSE_RECORD_COLLECTION);
51
+ }
52
+ function blockResponseRecordResource() {
53
+ return async (ctx, next) => {
54
+ if (targetsResponseRecordCollection(ctx)) {
55
+ ctx.throw(404, "Not Found");
56
+ return;
57
+ }
58
+ await next();
59
+ };
60
+ }
61
+ // Annotate the CommonJS export names for ESM import in node:
62
+ 0 && (module.exports = {
63
+ RESPONSE_RECORD_COLLECTION,
64
+ blockResponseRecordResource,
65
+ targetsResponseRecordCollection
66
+ });
@@ -35,38 +35,63 @@ var import_openai_format = require("../utils/openai-format");
35
35
  const PERMISSION_TTL_MS = 15e3;
36
36
  const permissionCache = /* @__PURE__ */ new Map();
37
37
  function invalidateRolePermissionCache(roleName) {
38
- if (roleName) permissionCache.delete(roleName);
39
- else permissionCache.clear();
38
+ if (!roleName) {
39
+ permissionCache.clear();
40
+ return;
41
+ }
42
+ const suffix = `:role:${roleName}`;
43
+ for (const key of permissionCache.keys()) {
44
+ if (key.endsWith(suffix)) permissionCache.delete(key);
45
+ }
40
46
  }
41
- async function checkRolePermission(ctx) {
42
- var _a;
43
- const roleName = ((_a = ctx.state.currentRoles) == null ? void 0 : _a[0]) || "member";
44
- if (roleName === "root" || roleName === "admin") {
45
- return true;
47
+ function unwrapRecord(raw) {
48
+ if (!raw) return null;
49
+ if (typeof raw.get === "function") {
50
+ const model = raw;
51
+ return {
52
+ roleName: model.get("roleName"),
53
+ enabled: model.get("enabled"),
54
+ allowAllEmployees: model.get("allowAllEmployees"),
55
+ allowedEmployees: model.get("allowedEmployees")
56
+ };
46
57
  }
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 });
58
+ return raw;
59
+ }
60
+ async function loadRolePermission(ctx, roleName) {
61
+ var _a;
62
+ const cacheKey = `${((_a = ctx.app) == null ? void 0 : _a.name) ?? "main"}:role:${roleName}`;
63
+ const cached = permissionCache.get(cacheKey);
64
+ if (cached && cached.expiresAt > Date.now()) {
65
+ return cached.record;
51
66
  }
52
- if (!(record == null ? void 0 : record.enabled)) {
67
+ const record = unwrapRecord(await ctx.db.getRepository("aiApiRolePermissions").findOne({ filter: { roleName } }));
68
+ permissionCache.set(cacheKey, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
69
+ return record;
70
+ }
71
+ async function checkRolePermission(ctx) {
72
+ var _a;
73
+ const roleNames = ((_a = ctx.state.currentRoles) == null ? void 0 : _a.length) ? ctx.state.currentRoles : [ctx.state.currentRole || "member"];
74
+ const records = (await Promise.all(roleNames.map((name) => loadRolePermission(ctx, name)))).filter(
75
+ (record) => !!(record == null ? void 0 : record.enabled)
76
+ );
77
+ if (!records.length) {
53
78
  ctx.status = 403;
54
79
  ctx.body = (0, import_openai_format.toOpenAIError)(
55
80
  403,
56
- `Role '${roleName}' is not authorized to use the AI API. An admin must enable access in Settings \u2192 Users & Permissions \u2192 [Role] \u2192 AI API.`,
81
+ `None of the roles [${roleNames.join(", ")}] is authorized to use the AI API. An admin must enable access in Settings \u2192 Users & Permissions \u2192 [Role] \u2192 AI API.`,
57
82
  "permission_denied",
58
83
  "role_not_permitted"
59
84
  );
60
85
  return false;
61
86
  }
62
- ctx.state.aiApiRolePermission = record;
87
+ ctx.state.aiApiRolePermissions = records;
63
88
  return true;
64
89
  }
65
90
  function checkEmployeeAccess(ctx, employeeUsername) {
66
- const perm = ctx.state.aiApiRolePermission;
67
- if (!perm) return true;
68
- if (perm.allowAllEmployees) return true;
69
- return (perm.allowedEmployees || []).includes(employeeUsername);
91
+ const perms = ctx.state.aiApiRolePermissions || [];
92
+ return perms.some(
93
+ (perm) => perm.allowAllEmployees || (perm.allowedEmployees || []).includes(employeeUsername)
94
+ );
70
95
  }
71
96
  // Annotate the CommonJS export names for ESM import in node:
72
97
  0 && (module.exports = {
@@ -0,0 +1,60 @@
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 remove_default_group_members_exports = {};
28
+ __export(remove_default_group_members_exports, {
29
+ default: () => RemoveDefaultGroupMembers
30
+ });
31
+ module.exports = __toCommonJS(remove_default_group_members_exports);
32
+ var import_server = require("@nocobase/server");
33
+ class RemoveDefaultGroupMembers extends import_server.Migration {
34
+ // afterSync: aiApiGroupMembers / aiApiUsageGroups must be registered and synced.
35
+ on = "afterSync";
36
+ async up() {
37
+ const groupCollection = this.db.getCollection("aiApiUsageGroups");
38
+ const memberCollection = this.db.getCollection("aiApiGroupMembers");
39
+ if (!groupCollection || !memberCollection) return;
40
+ if (!await groupCollection.existsInDb() || !await memberCollection.existsInDb()) return;
41
+ const defaultGroup = await this.db.getRepository("aiApiUsageGroups").findOne({
42
+ filter: { isDefault: true }
43
+ });
44
+ if (!defaultGroup) return;
45
+ const groupId = defaultGroup.get("id");
46
+ const members = await this.db.getRepository("aiApiGroupMembers").find({
47
+ filter: { groupId }
48
+ });
49
+ for (const member of members) {
50
+ await this.db.getRepository("aiApiGroupMembers").destroy({ filterByTk: member.get("id") });
51
+ }
52
+ if (members.length) {
53
+ this.app.logger.info(
54
+ `[ai-api] Removed ${members.length} explicit member row(s) from the default usage group; those users now fall back to it implicitly.`
55
+ );
56
+ }
57
+ }
58
+ async down() {
59
+ }
60
+ }
@@ -0,0 +1,55 @@
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 seed_default_role_permissions_exports = {};
28
+ __export(seed_default_role_permissions_exports, {
29
+ default: () => SeedDefaultRolePermissions
30
+ });
31
+ module.exports = __toCommonJS(seed_default_role_permissions_exports);
32
+ var import_server = require("@nocobase/server");
33
+ class SeedDefaultRolePermissions extends import_server.Migration {
34
+ // afterSync: aiApiRolePermissions must be registered and synced before we write to it.
35
+ on = "afterSync";
36
+ async up() {
37
+ const collection = this.db.getCollection("aiApiRolePermissions");
38
+ if (!collection || !await collection.existsInDb()) return;
39
+ const repository = this.db.getRepository("aiApiRolePermissions");
40
+ const rolesToSeed = [
41
+ { roleName: "root", enabled: true, allowAllEmployees: true, allowedEmployees: [] },
42
+ { roleName: "admin", enabled: true, allowAllEmployees: true, allowedEmployees: [] }
43
+ ];
44
+ for (const values of rolesToSeed) {
45
+ const existing = await repository.findOne({ filter: { roleName: values.roleName } });
46
+ if (existing) continue;
47
+ await repository.create({ values });
48
+ this.app.logger.info(
49
+ `[ai-api] Seeded aiApiRolePermissions for role ${values.roleName} (the built-in bypass was removed).`
50
+ );
51
+ }
52
+ }
53
+ async down() {
54
+ }
55
+ }
@@ -0,0 +1,170 @@
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 seed_sample_response_records_exports = {};
28
+ __export(seed_sample_response_records_exports, {
29
+ default: () => SeedSampleResponseRecord
30
+ });
31
+ module.exports = __toCommonJS(seed_sample_response_records_exports);
32
+ var import_server = require("@nocobase/server");
33
+ const SAMPLE_IDS = ["resp_sample_welcome", "resp_sample_followup"];
34
+ class SeedSampleResponseRecord extends import_server.Migration {
35
+ on = "afterSync";
36
+ async up() {
37
+ const collection = this.db.getCollection("aiApiResponseRecords");
38
+ if (!collection || !await collection.existsInDb()) return;
39
+ if (process.env.NODE_ENV === "production" && process.env.AI_API_SEED_SAMPLE_RESPONSES !== "true") {
40
+ this.app.logger.info(
41
+ "[ai-api] Skipped sample response records in production (set AI_API_SEED_SAMPLE_RESPONSES=true to enable)."
42
+ );
43
+ return;
44
+ }
45
+ const rootUser = await this.db.getRepository("users").findOne({ filter: { "roles.name": "root" } });
46
+ if (!rootUser) {
47
+ this.app.logger.warn("[ai-api] Skipped sample response records because no root user exists.");
48
+ return;
49
+ }
50
+ const repository = this.db.getRepository("aiApiResponseRecords");
51
+ const now = Math.floor(Date.now() / 1e3);
52
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3);
53
+ const firstOutput = {
54
+ id: SAMPLE_IDS[0],
55
+ object: "response",
56
+ created_at: now,
57
+ completed_at: now,
58
+ status: "completed",
59
+ error: null,
60
+ incomplete_details: null,
61
+ instructions: null,
62
+ max_output_tokens: null,
63
+ model: "sample/gpt-4",
64
+ output: [
65
+ {
66
+ id: "msg_sample_welcome",
67
+ type: "message",
68
+ status: "completed",
69
+ role: "assistant",
70
+ content: [
71
+ {
72
+ type: "output_text",
73
+ text: "Hello! Welcome to the NocoBase AI API. How can I help you today?",
74
+ annotations: []
75
+ }
76
+ ]
77
+ }
78
+ ],
79
+ output_text: "Hello! Welcome to the NocoBase AI API. How can I help you today?",
80
+ parallel_tool_calls: true,
81
+ previous_response_id: null,
82
+ reasoning: null,
83
+ service_tier: "default",
84
+ temperature: null,
85
+ text: null,
86
+ tool_choice: "auto",
87
+ tools: [],
88
+ top_p: null,
89
+ truncation: "disabled",
90
+ usage: {
91
+ input_tokens: 15,
92
+ input_tokens_details: { cached_tokens: 0 },
93
+ output_tokens: 20,
94
+ output_tokens_details: { reasoning_tokens: 0 },
95
+ total_tokens: 35
96
+ },
97
+ metadata: { sample: "true", purpose: "documentation" }
98
+ };
99
+ const firstValues = {
100
+ responseId: SAMPLE_IDS[0],
101
+ userId: rootUser.get("id"),
102
+ model: firstOutput.model,
103
+ input: "Hello! I am testing the Responses API.",
104
+ output: firstOutput,
105
+ previousResponseId: null,
106
+ metadata: firstOutput.metadata,
107
+ expiresAt
108
+ };
109
+ const secondOutput = {
110
+ ...firstOutput,
111
+ id: SAMPLE_IDS[1],
112
+ created_at: now + 60,
113
+ completed_at: now + 60,
114
+ output: [
115
+ {
116
+ id: "msg_sample_followup",
117
+ type: "message",
118
+ status: "completed",
119
+ role: "assistant",
120
+ content: [
121
+ {
122
+ type: "output_text",
123
+ text: "I can help with questions, code, data analysis, and more.",
124
+ annotations: []
125
+ }
126
+ ]
127
+ }
128
+ ],
129
+ output_text: "I can help with questions, code, data analysis, and more.",
130
+ previous_response_id: SAMPLE_IDS[0],
131
+ usage: {
132
+ input_tokens: 25,
133
+ input_tokens_details: { cached_tokens: 0 },
134
+ output_tokens: 15,
135
+ output_tokens_details: { reasoning_tokens: 0 },
136
+ total_tokens: 40
137
+ }
138
+ };
139
+ const secondValues = {
140
+ responseId: SAMPLE_IDS[1],
141
+ userId: rootUser.get("id"),
142
+ model: secondOutput.model,
143
+ input: "What can you help me with?",
144
+ output: secondOutput,
145
+ previousResponseId: SAMPLE_IDS[0],
146
+ metadata: secondOutput.metadata,
147
+ expiresAt
148
+ };
149
+ const candidates = [firstValues, secondValues];
150
+ await this.db.sequelize.transaction(async (transaction) => {
151
+ for (const values of candidates) {
152
+ const exists = await repository.findOne({ filter: { responseId: values.responseId }, transaction });
153
+ if (!exists) {
154
+ await repository.create({ values, transaction });
155
+ }
156
+ }
157
+ });
158
+ this.app.logger.info("[ai-api] Seeded sample Responses API records for the root user.");
159
+ }
160
+ async down() {
161
+ const repository = this.db.getRepository("aiApiResponseRecords");
162
+ const records = await repository.find({ filter: { responseId: { $in: SAMPLE_IDS } } });
163
+ for (const record of records) {
164
+ const metadata = record.get("metadata");
165
+ if (metadata && typeof metadata === "object" && metadata.sample === "true") {
166
+ await repository.destroy({ filterByTk: record.get("id") });
167
+ }
168
+ }
169
+ }
170
+ }
@@ -44,10 +44,11 @@ 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_ai_api_usage_monitor = __toESM(require("./resource/ai-api-usage-monitor"));
47
- var import_ai_api_usage_groups = __toESM(require("./resource/ai-api-usage-groups"));
48
47
  var import_rate_limiter = require("./utils/rate-limiter");
49
48
  var import_role_permission = require("./middleware/role-permission");
49
+ var import_response_record_resource = require("./middleware/response-record-resource");
50
50
  var import_user_permissions = require("./utils/user-permissions");
51
+ var import_response_store = require("./utils/response-store");
51
52
  var import_validation = require("./validation");
52
53
  var import_constants = require("../constants");
53
54
  var import_file_processor = require("./services/file-processor");
@@ -68,6 +69,7 @@ class PluginAiApiServer extends import_server.Plugin {
68
69
  */
69
70
  fileProcessorService = new import_file_processor.FileProcessorService();
70
71
  gcInterval = null;
72
+ responseCleanupInterval = null;
71
73
  async afterAdd() {
72
74
  }
73
75
  async beforeLoad() {
@@ -77,6 +79,9 @@ class PluginAiApiServer extends import_server.Plugin {
77
79
  this.app.db.on("aiApiModelMetadata.beforeSave", (model) => {
78
80
  (0, import_validation.validateModelMetadata)(model);
79
81
  });
82
+ this.app.db.on("aiApiVirtualModels.beforeSave", (model) => {
83
+ (0, import_validation.validateVirtualModel)(model);
84
+ });
80
85
  this.app.db.on("aiApiUsageGroups.beforeSave", async (model, options) => {
81
86
  (0, import_validation.validateQuotaPolicy)(model);
82
87
  if (model.get("isDefault")) {
@@ -94,6 +99,16 @@ class PluginAiApiServer extends import_server.Plugin {
94
99
  this.app.db.on("aiApiGroupMembers.beforeSave", async (model, options) => {
95
100
  const userId = model.get("userId");
96
101
  if (!userId) return;
102
+ const targetGroupId = model.get("groupId");
103
+ if (targetGroupId) {
104
+ const targetGroup = await this.db.getRepository("aiApiUsageGroups").findOne({
105
+ filterByTk: targetGroupId,
106
+ transaction: options == null ? void 0 : options.transaction
107
+ });
108
+ if (targetGroup == null ? void 0 : targetGroup.get("isDefault")) {
109
+ throw new Error("Cannot add members to the default group: users without another group use it automatically.");
110
+ }
111
+ }
97
112
  const existing = await this.db.getRepository("aiApiGroupMembers").findOne({
98
113
  filter: { userId },
99
114
  transaction: options == null ? void 0 : options.transaction
@@ -110,18 +125,9 @@ class PluginAiApiServer extends import_server.Plugin {
110
125
  filter: { groupId: model.get("id") },
111
126
  transaction: options == null ? void 0 : options.transaction
112
127
  });
113
- if (members.length === 0) return;
114
- const defaultGroup = await this.db.getRepository("aiApiUsageGroups").findOne({
115
- filter: { isDefault: true },
116
- transaction: options == null ? void 0 : options.transaction
117
- });
118
- if (!defaultGroup) {
119
- throw new Error("Default usage group is missing; cannot reassign members.");
120
- }
121
128
  for (const member of members) {
122
- await this.db.getRepository("aiApiGroupMembers").update({
129
+ await this.db.getRepository("aiApiGroupMembers").destroy({
123
130
  filterByTk: member.get("id"),
124
- values: { groupId: defaultGroup.get("id") },
125
131
  transaction: options == null ? void 0 : options.transaction
126
132
  });
127
133
  }
@@ -141,12 +147,16 @@ class PluginAiApiServer extends import_server.Plugin {
141
147
  this.app.use((0, import_router.createAiLlmRouter)(this), { after: "idp-oauth-resource-auth", before: "resourcer" });
142
148
  this.app.resourceManager.define(import_ai_api_config.default);
143
149
  this.app.resourceManager.define(import_ai_api_usage_monitor.default);
144
- this.app.resourceManager.define(import_ai_api_usage_groups.default);
145
- this.app.db.on("aiApiRolePermissions.afterSave", (model) => {
146
- (0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
150
+ this.app.resourceManager.use((0, import_response_record_resource.blockResponseRecordResource)(), {
151
+ tag: "aiApiResponseRecordsPrivate",
152
+ after: "auth",
153
+ before: "acl"
147
154
  });
148
- this.app.db.on("aiApiRolePermissions.afterDestroy", (model) => {
149
- (0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
155
+ this.app.db.on("aiApiRolePermissions.afterSave", (model, options) => {
156
+ this.invalidateRolePermissionSync(model.get("roleName"), options == null ? void 0 : options.transaction);
157
+ });
158
+ this.app.db.on("aiApiRolePermissions.afterDestroy", (model, options) => {
159
+ this.invalidateRolePermissionSync(model.get("roleName"), options == null ? void 0 : options.transaction);
150
160
  });
151
161
  this.app.db.on("aiApiUsageGroups.afterSave", (model, options) => {
152
162
  this.invalidateGroupAccess(model.get("id"), options == null ? void 0 : options.transaction);
@@ -161,6 +171,7 @@ class PluginAiApiServer extends import_server.Plugin {
161
171
  "aiApiRolePermissions:*",
162
172
  "aiApiModelPrices:*",
163
173
  "aiApiModelMetadata:*",
174
+ "aiApiVirtualModels:*",
164
175
  "aiApiUsageGroups:*",
165
176
  "aiApiGroupMembers:*",
166
177
  "aiApiGroupQuotaBuckets:list",
@@ -172,6 +183,21 @@ class PluginAiApiServer extends import_server.Plugin {
172
183
  });
173
184
  this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1e3);
174
185
  this.gcInterval.unref();
186
+ this.cleanupResponseRecords();
187
+ this.responseCleanupInterval = setInterval(() => this.cleanupResponseRecords(), 24 * 60 * 60 * 1e3);
188
+ this.responseCleanupInterval.unref();
189
+ }
190
+ /**
191
+ * Drop a role's cached permission record on every node.
192
+ *
193
+ * Mirrors invalidateGroupAccess: the local call is not redundant because
194
+ * syncMessageManager hardcodes skipSelf, so the publishing node never receives
195
+ * its own message. Passing the transaction defers the broadcast until the
196
+ * write commits, so other nodes cannot re-read the old row and re-cache it.
197
+ */
198
+ invalidateRolePermissionSync(roleName, transaction) {
199
+ (0, import_role_permission.invalidateRolePermissionCache)(roleName);
200
+ this.sendSyncMessage({ type: "invalidateRolePermission", roleName }, { transaction });
175
201
  }
176
202
  /**
177
203
  * Drop a group's cached model-access scope on every node.
@@ -190,6 +216,8 @@ class PluginAiApiServer extends import_server.Plugin {
190
216
  async handleSyncMessage(message) {
191
217
  if ((message == null ? void 0 : message.type) === "invalidateGroupAccess") {
192
218
  (0, import_user_permissions.invalidateGroupAccessCache)(message.groupId);
219
+ } else if ((message == null ? void 0 : message.type) === "invalidateRolePermission") {
220
+ (0, import_role_permission.invalidateRolePermissionCache)(message.roleName);
193
221
  }
194
222
  }
195
223
  async install() {
@@ -205,6 +233,16 @@ class PluginAiApiServer extends import_server.Plugin {
205
233
  }
206
234
  });
207
235
  }
236
+ for (const roleName of ["root", "admin"]) {
237
+ const perm = await this.db.getRepository("aiApiRolePermissions").findOne({
238
+ filter: { roleName }
239
+ });
240
+ if (!perm) {
241
+ await this.db.getRepository("aiApiRolePermissions").create({
242
+ values: { roleName, enabled: true, allowAllEmployees: true, allowedEmployees: [] }
243
+ });
244
+ }
245
+ }
208
246
  const defaultGroup = await this.db.getRepository("aiApiUsageGroups").findOne({
209
247
  filter: { isDefault: true }
210
248
  });
@@ -232,6 +270,14 @@ class PluginAiApiServer extends import_server.Plugin {
232
270
  });
233
271
  }
234
272
  }
273
+ async cleanupResponseRecords() {
274
+ try {
275
+ const deleted = await (0, import_response_store.cleanupExpiredResponseRecords)({ db: this.db });
276
+ if (deleted > 0) this.app.logger.info(`[ai-api] Cleaned up ${deleted} expired response records`);
277
+ } catch (error) {
278
+ this.app.logger.warn("[ai-api] Failed to clean up expired response records:", error);
279
+ }
280
+ }
235
281
  async afterEnable() {
236
282
  }
237
283
  async afterDisable() {
@@ -241,6 +287,10 @@ class PluginAiApiServer extends import_server.Plugin {
241
287
  clearInterval(this.gcInterval);
242
288
  this.gcInterval = null;
243
289
  }
290
+ if (this.responseCleanupInterval) {
291
+ clearInterval(this.responseCleanupInterval);
292
+ this.responseCleanupInterval = null;
293
+ }
244
294
  this.rateLimiter.clear();
245
295
  }
246
296
  }