plugin-ai-api 1.0.25 → 1.1.0

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 (115) hide show
  1. package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
  2. package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
  3. package/dist/client/562.44b16aad4718b4c7.js +10 -0
  4. package/dist/client/685.ae483e17b6b49c98.js +10 -0
  5. package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
  6. package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.3971233415999b2c.js +10 -0
  9. package/dist/client-v2/562.45d5c504433be38b.js +10 -0
  10. package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
  11. package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
  12. package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
  13. package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +2 -5
  16. package/dist/locale/en-US.json +26 -8
  17. package/dist/locale/vi-VN.json +26 -8
  18. package/dist/locale/zh-CN.json +26 -8
  19. package/dist/server/billing.js +25 -32
  20. package/dist/server/collections/ai-api-config.js +1 -7
  21. package/dist/server/collections/ai-api-group-members.js +62 -0
  22. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  24. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  25. package/dist/server/collections/ai-api-usage-records.js +1 -0
  26. package/dist/server/middleware/rate-limit.js +7 -6
  27. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  28. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  29. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  30. package/dist/server/plugin.js +90 -22
  31. package/dist/server/quota-groups.js +108 -0
  32. package/dist/server/resource/ai-api-config.js +0 -3
  33. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  34. package/dist/server/routes/agent-completions.js +2 -1
  35. package/dist/server/routes/chat-completions.js +32 -32
  36. package/dist/server/routes/completions.js +16 -19
  37. package/dist/server/routes/embeddings.js +2 -1
  38. package/dist/server/routes/models.js +2 -1
  39. package/dist/server/routes/router.js +3 -2
  40. package/dist/server/services/file-processor.js +186 -22
  41. package/dist/server/usage.js +5 -1
  42. package/dist/server/utils/direct-llm-context.js +13 -11
  43. package/dist/server/utils/rate-limiter.js +1 -1
  44. package/dist/server/utils/request-cache.js +61 -0
  45. package/dist/server/utils/resolve-service.js +2 -1
  46. package/dist/server/utils/user-permissions.js +25 -39
  47. package/dist/server/validation.js +7 -0
  48. package/dist/swagger.js +6 -7
  49. package/package.json +1 -1
  50. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  51. package/src/client/plugin.tsx +5 -16
  52. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  53. package/src/client-v2/locale.ts +3 -1
  54. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  55. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  56. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  57. package/src/client-v2/plugin.tsx +4 -13
  58. package/src/constants.ts +0 -7
  59. package/src/locale/en-US.json +26 -8
  60. package/src/locale/vi-VN.json +26 -8
  61. package/src/locale/zh-CN.json +26 -8
  62. package/src/server/__tests__/billing-quota.test.ts +28 -9
  63. package/src/server/__tests__/direct-llm-context.test.ts +122 -4
  64. package/src/server/__tests__/file-processor.test.ts +225 -0
  65. package/src/server/__tests__/models.test.ts +1 -1
  66. package/src/server/__tests__/permission-sync.test.ts +34 -35
  67. package/src/server/__tests__/usage-groups.test.ts +160 -0
  68. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  69. package/src/server/__tests__/usage-route.test.ts +262 -2
  70. package/src/server/__tests__/usage.test.ts +38 -0
  71. package/src/server/__tests__/user-permissions.test.ts +214 -133
  72. package/src/server/__tests__/validation.test.ts +11 -0
  73. package/src/server/billing.ts +30 -38
  74. package/src/server/collections/ai-api-config.ts +1 -7
  75. package/src/server/collections/ai-api-group-members.ts +41 -0
  76. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  77. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  78. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  79. package/src/server/collections/ai-api-usage-records.ts +1 -0
  80. package/src/server/middleware/rate-limit.ts +10 -12
  81. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  82. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  83. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  84. package/src/server/plugin.ts +101 -30
  85. package/src/server/quota-groups.ts +117 -0
  86. package/src/server/resource/ai-api-config.ts +0 -3
  87. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  88. package/src/server/routes/agent-completions.ts +2 -1
  89. package/src/server/routes/chat-completions.ts +39 -36
  90. package/src/server/routes/completions.ts +18 -21
  91. package/src/server/routes/embeddings.ts +2 -1
  92. package/src/server/routes/models.ts +4 -3
  93. package/src/server/routes/router.ts +4 -3
  94. package/src/server/services/file-processor.ts +214 -24
  95. package/src/server/usage.ts +5 -1
  96. package/src/server/utils/direct-llm-context.ts +20 -11
  97. package/src/server/utils/rate-limiter.ts +1 -1
  98. package/src/server/utils/request-cache.ts +59 -0
  99. package/src/server/utils/resolve-service.ts +2 -1
  100. package/src/server/utils/user-permissions.ts +49 -69
  101. package/src/server/validation.ts +7 -0
  102. package/src/swagger.ts +7 -8
  103. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  104. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  105. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  106. package/dist/client/902.e74518750f1e4201.js +0 -10
  107. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  108. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  109. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  110. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  111. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  112. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  113. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  114. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  115. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -0,0 +1,190 @@
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 { Migration } from '@nocobase/server';
11
+
12
+ interface PermissionSignature {
13
+ allowedLlmServices: string[];
14
+ allowAllModels: boolean;
15
+ allowedModels: string[];
16
+ }
17
+
18
+ /**
19
+ * Model access moved from per-user rows (aiApiUserPermissions) to usage groups.
20
+ * Users with identical grants share one migrated group; quota settings are copied
21
+ * from the default group so migrated users keep the same quota behaviour.
22
+ *
23
+ * The old per-user semantics cannot be fully expressed on groups: an empty grant
24
+ * meant "deny all". Rows that denied everything (enabled=false, no services, or
25
+ * allowAllModels=false with no models) are left unmigrated — those users inherit
26
+ * the default group's open access and a warning is logged.
27
+ */
28
+ export default class MigrateUserPermissionsToGroups extends Migration {
29
+ // Must run afterSync: the new allowedLlmServices/allowAllModels/allowedModels
30
+ // columns on aiApiUsageGroups only exist once collections have been synced.
31
+ on = 'afterSync' as const;
32
+
33
+ async up() {
34
+ const collection = this.db.getCollection('aiApiUserPermissions');
35
+ if (!collection || !(await collection.existsInDb())) return;
36
+
37
+ const repository = this.db.getRepository('aiApiUserPermissions');
38
+ const rows: unknown[] = [];
39
+ for (let page = 1; ; page += 1) {
40
+ const batch = (await repository.find({ page, pageSize: 1000, sort: 'id' })) as unknown[];
41
+ rows.push(...batch);
42
+ if (batch.length < 1000) break;
43
+ }
44
+ if (!rows.length) return;
45
+
46
+ const migratable: { userId: unknown; sig: PermissionSignature }[] = [];
47
+ const deniedUserIds: unknown[] = [];
48
+
49
+ for (const row of rows) {
50
+ const userId = this.valueOf(row, 'userId');
51
+ if (userId === undefined || userId === null) continue;
52
+
53
+ const enabled = this.valueOf(row, 'enabled') !== false;
54
+ const allowedLlmServices = this.stringArray(this.valueOf(row, 'allowedLlmServices'));
55
+ const allowAllModels = this.valueOf(row, 'allowAllModels') !== false;
56
+ const allowedModels = this.stringArray(this.valueOf(row, 'allowedModels'));
57
+
58
+ // Old semantics: no services = deny all; allowAllModels=false with no models = deny all.
59
+ const hasEffectiveGrant = allowedLlmServices.length > 0 && (allowAllModels || allowedModels.length > 0);
60
+ if (!enabled || !hasEffectiveGrant) {
61
+ deniedUserIds.push(userId);
62
+ continue;
63
+ }
64
+ migratable.push({ userId, sig: { allowedLlmServices, allowAllModels, allowedModels } });
65
+ }
66
+
67
+ if (deniedUserIds.length) {
68
+ this.app.logger.warn(
69
+ `[ai-api] ${deniedUserIds.length} aiApiUserPermissions row(s) denied all access ` +
70
+ `(enabled=false or empty grant) and cannot be migrated to usage groups. These users now ` +
71
+ `inherit the default group's access. Affected userIds: ${deniedUserIds.join(', ')}. ` +
72
+ `Use role permissions to block API access entirely.`,
73
+ );
74
+ }
75
+
76
+ if (!migratable.length) return;
77
+
78
+ const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
79
+ filter: { isDefault: true },
80
+ });
81
+ const quotaValues = this.quotaValuesFrom(defaultGroup);
82
+
83
+ // Group rows by access signature so users with identical grants share one group.
84
+ const bySignature = new Map<string, { sig: PermissionSignature; userIds: unknown[] }>();
85
+ for (const { userId, sig } of migratable) {
86
+ const key = JSON.stringify({
87
+ allowedLlmServices: [...sig.allowedLlmServices].sort(),
88
+ allowAllModels: sig.allowAllModels,
89
+ allowedModels: [...sig.allowedModels].sort(),
90
+ });
91
+ const entry = bySignature.get(key);
92
+ if (entry) {
93
+ entry.userIds.push(userId);
94
+ } else {
95
+ bySignature.set(key, { sig, userIds: [userId] });
96
+ }
97
+ }
98
+
99
+ let index = 0;
100
+ for (const { sig, userIds } of bySignature.values()) {
101
+ index += 1;
102
+ const group = await this.db.getRepository('aiApiUsageGroups').create({
103
+ values: {
104
+ ...quotaValues,
105
+ name: `Migrated permissions ${index}`,
106
+ isDefault: false,
107
+ quotaMode: 'per_user',
108
+ allowedLlmServices: sig.allowedLlmServices,
109
+ allowAllModels: sig.allowAllModels,
110
+ allowedModels: sig.allowedModels,
111
+ },
112
+ });
113
+ const groupId = this.valueOf(group, 'id');
114
+
115
+ for (const userId of userIds) {
116
+ const existing = await this.db.getRepository('aiApiGroupMembers').findOne({ filter: { userId } });
117
+ if (existing) {
118
+ await this.db.getRepository('aiApiGroupMembers').update({
119
+ filterByTk: this.valueOf(existing, 'id'),
120
+ values: { groupId },
121
+ });
122
+ } else {
123
+ await this.db.getRepository('aiApiGroupMembers').create({ values: { groupId, userId } });
124
+ }
125
+ }
126
+ }
127
+
128
+ this.app.logger.info(
129
+ `[ai-api] Migrated ${migratable.length} aiApiUserPermissions row(s) into ${bySignature.size} usage group(s).`,
130
+ );
131
+ }
132
+
133
+ async down() {
134
+ // The legacy rows are never modified, so rolling back only means removing the
135
+ // migrated groups and their memberships.
136
+ const groups = (await this.db.getRepository('aiApiUsageGroups').find({
137
+ filter: { 'name.$startsWith': 'Migrated permissions' },
138
+ })) as unknown[];
139
+ for (const group of groups) {
140
+ const groupId = this.valueOf(group, 'id');
141
+ await this.db.getRepository('aiApiGroupMembers').destroy({ filter: { groupId } });
142
+ await this.db.getRepository('aiApiUsageGroups').destroy({ filterByTk: groupId });
143
+ }
144
+ }
145
+
146
+ private quotaValuesFrom(defaultGroup: unknown): Record<string, unknown> {
147
+ if (!defaultGroup) {
148
+ return {
149
+ rateLimitPerMinute: 60,
150
+ enabled: false,
151
+ periodType: 'monthly',
152
+ timezone: 'UTC',
153
+ requestLimit: null,
154
+ totalTokenLimit: null,
155
+ costLimit: null,
156
+ currency: 'USD',
157
+ rejectUnpricedModel: true,
158
+ missingUsageBehavior: 'use_reserved',
159
+ contextOverflowBehavior: 'reject',
160
+ };
161
+ }
162
+ return {
163
+ rateLimitPerMinute: Number(this.valueOf(defaultGroup, 'rateLimitPerMinute') ?? 60),
164
+ enabled: !!this.valueOf(defaultGroup, 'enabled'),
165
+ periodType: this.valueOf(defaultGroup, 'periodType') ?? 'monthly',
166
+ timezone: this.valueOf(defaultGroup, 'timezone') ?? 'UTC',
167
+ requestLimit: this.valueOf(defaultGroup, 'requestLimit') ?? null,
168
+ totalTokenLimit: this.valueOf(defaultGroup, 'totalTokenLimit') ?? null,
169
+ costLimit: this.valueOf(defaultGroup, 'costLimit') ?? null,
170
+ currency: this.valueOf(defaultGroup, 'currency') ?? 'USD',
171
+ rejectUnpricedModel: !!this.valueOf(defaultGroup, 'rejectUnpricedModel'),
172
+ missingUsageBehavior: this.valueOf(defaultGroup, 'missingUsageBehavior') ?? 'use_reserved',
173
+ contextOverflowBehavior: this.valueOf(defaultGroup, 'contextOverflowBehavior') ?? 'reject',
174
+ };
175
+ }
176
+
177
+ private stringArray(value: unknown): string[] {
178
+ return Array.isArray(value)
179
+ ? value.filter((item): item is string => typeof item === 'string' && item.length > 0)
180
+ : [];
181
+ }
182
+
183
+ private valueOf(model: unknown, name: string): unknown {
184
+ if (!model) return undefined;
185
+ if (typeof (model as Record<string, unknown>).get === 'function') {
186
+ return (model as Record<string, unknown>).get(name);
187
+ }
188
+ return (model as Record<string, unknown>)[name];
189
+ }
190
+ }
@@ -0,0 +1,46 @@
1
+ import { Migration } from '@nocobase/server';
2
+
3
+ export default class AddSystemPromptToModelMetadata extends Migration {
4
+ on = 'beforeLoad' as const;
5
+
6
+ async up() {
7
+ const collection = this.db.getCollection('aiApiModelMetadata');
8
+ if (!collection) return;
9
+
10
+ const field = collection.getField('systemPrompt');
11
+ if (!field) {
12
+ collection.addField('systemPrompt', { type: 'text', allowNull: true });
13
+ }
14
+
15
+ if (await collection.existsInDb()) {
16
+ const tableName = collection.getTableNameWithSchema();
17
+ const exists = await this.tableColumnExists(tableName, 'systemPrompt');
18
+ if (!exists) {
19
+ await this.queryInterface.addColumn(tableName, 'systemPrompt', {
20
+ type: 'TEXT',
21
+ allowNull: true,
22
+ });
23
+ }
24
+ }
25
+ }
26
+
27
+ async down() {
28
+ const collection = this.db.getCollection('aiApiModelMetadata');
29
+ if (!collection) return;
30
+
31
+ if (await collection.existsInDb()) {
32
+ const tableName = collection.getTableNameWithSchema();
33
+ const exists = await this.tableColumnExists(tableName, 'systemPrompt');
34
+ if (exists) {
35
+ await this.queryInterface.removeColumn(tableName, 'systemPrompt');
36
+ }
37
+ }
38
+
39
+ collection.removeField('systemPrompt');
40
+ }
41
+
42
+ private async tableColumnExists(tableName: string, columnName: string): Promise<boolean> {
43
+ const columns = await this.queryInterface.describeTable(tableName);
44
+ return Object.prototype.hasOwnProperty.call(columns, columnName);
45
+ }
46
+ }
@@ -12,12 +12,12 @@ import type { Transactionable } from '@nocobase/database';
12
12
  import { createAiLlmRouter, AI_LLM_PREFIX } from './routes/router';
13
13
  import aiApiConfigResource from './resource/ai-api-config';
14
14
  import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
15
- import aiApiUserPermissionsResource from './resource/ai-api-user-permissions';
15
+ import aiApiUsageGroupsResource from './resource/ai-api-usage-groups';
16
16
  import { RateLimiter } from './utils/rate-limiter';
17
17
  import { invalidateRolePermissionCache } from './middleware/role-permission';
18
- import { invalidateUserPermissionCache } from './utils/user-permissions';
18
+ import { invalidateGroupAccessCache } from './utils/user-permissions';
19
19
  import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
20
- import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
20
+ import { AI_API_ACL_SNIPPET } from '../constants';
21
21
  import {
22
22
  FileProcessorService,
23
23
  base64FileForwarder,
@@ -41,7 +41,7 @@ import timezonePlugin from 'dayjs/plugin/timezone';
41
41
  export class PluginAiApiServer extends Plugin {
42
42
  /**
43
43
  * Singleton rate limiter — lives for the entire plugin lifetime, shared across all requests.
44
- * Uses a 1-minute sliding window to enforce rateLimitPerMinute from aiApiConfig.
44
+ * Uses a 1-minute sliding window to enforce rateLimitPerMinute from the user's usage group.
45
45
  */
46
46
  rateLimiter = new RateLimiter(60_000);
47
47
 
@@ -62,8 +62,60 @@ export class PluginAiApiServer extends Plugin {
62
62
  this.app.db.on('aiApiModelMetadata.beforeSave', (model) => {
63
63
  validateModelMetadata(model);
64
64
  });
65
- this.app.db.on('aiApiUserQuotaPolicies.beforeSave', (model) => {
65
+ this.app.db.on('aiApiUsageGroups.beforeSave', async (model, options) => {
66
66
  validateQuotaPolicy(model);
67
+ // The partial unique index on isDefault is not supported on MySQL,
68
+ // so enforce single-default-group here as well.
69
+ if (model.get('isDefault')) {
70
+ const filter: Record<string, unknown> = { isDefault: true };
71
+ if (model.get('id')) filter.id = { $ne: model.get('id') };
72
+ const other = await this.db.getRepository('aiApiUsageGroups').findOne({
73
+ filter,
74
+ transaction: options?.transaction,
75
+ });
76
+ if (other) {
77
+ throw new Error('Only one default usage group is allowed.');
78
+ }
79
+ }
80
+ });
81
+
82
+ this.app.db.on('aiApiGroupMembers.beforeSave', async (model, options) => {
83
+ const userId = model.get('userId');
84
+ if (!userId) return;
85
+ const existing = await this.db.getRepository('aiApiGroupMembers').findOne({
86
+ filter: { userId },
87
+ transaction: options?.transaction,
88
+ });
89
+ if (existing && existing.get('id') !== model.get('id')) {
90
+ throw new Error('User already belongs to another usage group.');
91
+ }
92
+ });
93
+
94
+ this.app.db.on('aiApiUsageGroups.beforeDestroy', async (model, options) => {
95
+ if (model.get('isDefault')) {
96
+ throw new Error('The default usage group cannot be deleted.');
97
+ }
98
+ const members = await this.db.getRepository('aiApiGroupMembers').find({
99
+ filter: { groupId: model.get('id') },
100
+ transaction: options?.transaction,
101
+ });
102
+ if (members.length === 0) return;
103
+
104
+ const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
105
+ filter: { isDefault: true },
106
+ transaction: options?.transaction,
107
+ });
108
+ if (!defaultGroup) {
109
+ throw new Error('Default usage group is missing; cannot reassign members.');
110
+ }
111
+
112
+ for (const member of members) {
113
+ await this.db.getRepository('aiApiGroupMembers').update({
114
+ filterByTk: member.get('id'),
115
+ values: { groupId: defaultGroup.get('id') },
116
+ transaction: options?.transaction,
117
+ });
118
+ }
67
119
  });
68
120
  }
69
121
 
@@ -99,7 +151,7 @@ export class PluginAiApiServer extends Plugin {
99
151
  // 2. Register admin config resource
100
152
  this.app.resourceManager.define(aiApiConfigResource);
101
153
  this.app.resourceManager.define(aiApiUsageMonitorResource);
102
- this.app.resourceManager.define(aiApiUserPermissionsResource);
154
+ this.app.resourceManager.define(aiApiUsageGroupsResource);
103
155
 
104
156
  this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
105
157
  invalidateRolePermissionCache(model.get('roleName'));
@@ -108,11 +160,11 @@ export class PluginAiApiServer extends Plugin {
108
160
  invalidateRolePermissionCache(model.get('roleName'));
109
161
  });
110
162
 
111
- this.app.db.on('aiApiUserPermissions.afterSave', (model, options) => {
112
- this.revokeUserPermissions(model.get('userId'), options?.transaction);
163
+ this.app.db.on('aiApiUsageGroups.afterSave', (model, options) => {
164
+ this.invalidateGroupAccess(model.get('id'), options?.transaction);
113
165
  });
114
- this.app.db.on('aiApiUserPermissions.afterDestroy', (model, options) => {
115
- this.revokeUserPermissions(model.get('userId'), options?.transaction);
166
+ this.app.db.on('aiApiUsageGroups.afterDestroy', (model, options) => {
167
+ this.invalidateGroupAccess(model.get('id'), options?.transaction);
116
168
  });
117
169
 
118
170
  // 3. Set ACL permissions for admin config + role permissions management
@@ -123,24 +175,16 @@ export class PluginAiApiServer extends Plugin {
123
175
  'aiApiRolePermissions:*',
124
176
  'aiApiModelPrices:*',
125
177
  'aiApiModelMetadata:*',
126
- 'aiApiUserQuotaPolicies:*',
127
- 'aiApiUserQuotaBuckets:list',
128
- 'aiApiUserQuotaBuckets:get',
178
+ 'aiApiUsageGroups:*',
179
+ 'aiApiGroupMembers:*',
180
+ 'aiApiGroupQuotaBuckets:list',
181
+ 'aiApiGroupQuotaBuckets:get',
129
182
  'aiApiUsageRecords:list',
130
183
  'aiApiUsageRecords:get',
131
184
  'aiApiUsageMonitor:summary',
132
185
  ],
133
186
  });
134
187
 
135
- // Per-user LLM grants are a separate child permission: handing out model access is a
136
- // stronger capability than editing gateway settings, so it ticks independently.
137
- // The wildcard also covers `listUsers`, which backs the page's user picker — without it
138
- // the page would depend on `pm.plugin-users` and break for a role holding only this snippet.
139
- this.app.acl.registerSnippet({
140
- name: AI_API_USER_PERMISSIONS_SNIPPET,
141
- actions: ['aiApiUserPermissions:*'],
142
- });
143
-
144
188
  // 4. GC the rate limiter every 5 minutes to evict stale user entries.
145
189
  // .unref() prevents this timer from keeping the process alive on shutdown.
146
190
  this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
@@ -148,23 +192,23 @@ export class PluginAiApiServer extends Plugin {
148
192
  }
149
193
 
150
194
  /**
151
- * Drop a user's cached LLM scope on every node.
195
+ * Drop a group's cached model-access scope on every node.
152
196
  *
153
197
  * The local call is not redundant: syncMessageManager hardcodes skipSelf, so the publishing
154
198
  * node never receives its own message. Passing the transaction defers the broadcast until
155
199
  * the write commits, so other nodes cannot re-read the old row and re-cache it.
156
200
  */
157
- private revokeUserPermissions(userId: unknown, transaction?: Transactionable['transaction']) {
158
- invalidateUserPermissionCache(userId as string | number | bigint);
159
- this.sendSyncMessage({ type: 'invalidateUserPermissions', userId }, { transaction });
201
+ private invalidateGroupAccess(groupId: unknown, transaction?: Transactionable['transaction']) {
202
+ invalidateGroupAccessCache(groupId as string | number | bigint);
203
+ this.sendSyncMessage({ type: 'invalidateGroupAccess', groupId }, { transaction });
160
204
  }
161
205
 
162
206
  /**
163
207
  * Received only on the *other* nodes (skipSelf), so this must not re-broadcast.
164
208
  */
165
- async handleSyncMessage(message: { type?: string; userId?: unknown }) {
166
- if (message?.type === 'invalidateUserPermissions') {
167
- invalidateUserPermissionCache(message.userId as string | number | bigint);
209
+ async handleSyncMessage(message: { type?: string; groupId?: unknown }) {
210
+ if (message?.type === 'invalidateGroupAccess') {
211
+ invalidateGroupAccessCache(message.groupId as string | number | bigint);
168
212
  }
169
213
  }
170
214
 
@@ -176,13 +220,40 @@ export class PluginAiApiServer extends Plugin {
176
220
  values: {
177
221
  defaultAiEmployee: '',
178
222
  enabledLlmServices: [],
179
- rateLimitPerMinute: 60,
180
223
  quotaEnabled: false,
181
224
  pdfRenderPagesAsImages: false,
182
225
  defaultReservationOutputTokens: 4096,
183
226
  },
184
227
  });
185
228
  }
229
+
230
+ // Create default usage group on first install
231
+ const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
232
+ filter: { isDefault: true },
233
+ });
234
+ if (!defaultGroup) {
235
+ await this.db.getRepository('aiApiUsageGroups').create({
236
+ values: {
237
+ name: 'Default',
238
+ isDefault: true,
239
+ quotaMode: 'per_user',
240
+ rateLimitPerMinute: 60,
241
+ enabled: false,
242
+ periodType: 'monthly',
243
+ timezone: 'UTC',
244
+ requestLimit: null,
245
+ totalTokenLimit: null,
246
+ costLimit: null,
247
+ currency: 'USD',
248
+ rejectUnpricedModel: true,
249
+ missingUsageBehavior: 'use_reserved',
250
+ contextOverflowBehavior: 'reject',
251
+ allowedLlmServices: [],
252
+ allowAllModels: true,
253
+ allowedModels: [],
254
+ },
255
+ });
256
+ }
186
257
  }
187
258
 
188
259
  async afterEnable() {}
@@ -0,0 +1,117 @@
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 type { Model } from '@nocobase/database';
12
+
13
+ export type QuotaMode = 'share' | 'per_user';
14
+
15
+ export interface AiApiUsageGroup {
16
+ id: string | number | bigint;
17
+ name: string;
18
+ isDefault: boolean;
19
+ quotaMode: QuotaMode;
20
+ rateLimitPerMinute: number;
21
+ enabled: boolean;
22
+ periodType: string;
23
+ timezone: string;
24
+ requestLimit: string | number | bigint | null;
25
+ totalTokenLimit: string | number | bigint | null;
26
+ costLimit: string | null;
27
+ currency: string;
28
+ rejectUnpricedModel: boolean;
29
+ missingUsageBehavior: 'allow' | 'use_reserved';
30
+ contextOverflowBehavior: 'reject' | 'truncate';
31
+ allowedLlmServices: string[];
32
+ allowAllModels: boolean;
33
+ allowedModels: string[];
34
+ }
35
+
36
+ export async function getDefaultGroup(ctx: Context): Promise<AiApiUsageGroup> {
37
+ const existing = await ctx.db.getRepository('aiApiUsageGroups').findOne({
38
+ filter: { isDefault: true },
39
+ });
40
+
41
+ if (existing) {
42
+ return modelToGroup(existing);
43
+ }
44
+
45
+ const created = await ctx.db.getRepository('aiApiUsageGroups').create({
46
+ values: {
47
+ name: 'Default',
48
+ isDefault: true,
49
+ quotaMode: 'per_user',
50
+ rateLimitPerMinute: 60,
51
+ enabled: false,
52
+ periodType: 'monthly',
53
+ timezone: 'UTC',
54
+ requestLimit: null,
55
+ totalTokenLimit: null,
56
+ costLimit: null,
57
+ currency: 'USD',
58
+ rejectUnpricedModel: true,
59
+ missingUsageBehavior: 'use_reserved',
60
+ contextOverflowBehavior: 'reject',
61
+ allowedLlmServices: [],
62
+ allowAllModels: true,
63
+ allowedModels: [],
64
+ },
65
+ });
66
+
67
+ return modelToGroup(created);
68
+ }
69
+
70
+ export async function resolveUserGroup(
71
+ ctx: Context,
72
+ userId: string | number | bigint | undefined | null,
73
+ ): Promise<AiApiUsageGroup> {
74
+ if (userId === undefined || userId === null) {
75
+ return getDefaultGroup(ctx);
76
+ }
77
+
78
+ const member = await ctx.db.getRepository('aiApiGroupMembers').findOne({
79
+ filter: { userId },
80
+ appends: ['group'],
81
+ });
82
+
83
+ if (member && member.get('group')) {
84
+ return modelToGroup(member.get('group'));
85
+ }
86
+
87
+ return getDefaultGroup(ctx);
88
+ }
89
+
90
+ export function modelToGroup(model: Model | Record<string, unknown>): AiApiUsageGroup {
91
+ const record =
92
+ typeof (model as Model).get === 'function' ? (model as Model).get() : (model as Record<string, unknown>);
93
+ return {
94
+ id: record.id as string | number | bigint,
95
+ name: (record.name as string) ?? '',
96
+ isDefault: !!record.isDefault,
97
+ quotaMode: (record.quotaMode as QuotaMode) ?? 'per_user',
98
+ rateLimitPerMinute: Number(record.rateLimitPerMinute ?? 60),
99
+ enabled: !!record.enabled,
100
+ periodType: (record.periodType as string) ?? 'monthly',
101
+ timezone: (record.timezone as string) ?? 'UTC',
102
+ requestLimit: (record.requestLimit as string | number | bigint | null) ?? null,
103
+ totalTokenLimit: (record.totalTokenLimit as string | number | bigint | null) ?? null,
104
+ costLimit: (record.costLimit as string | null) ?? null,
105
+ currency: (record.currency as string) ?? 'USD',
106
+ rejectUnpricedModel: !!record.rejectUnpricedModel,
107
+ missingUsageBehavior: (record.missingUsageBehavior as 'allow' | 'use_reserved') ?? 'use_reserved',
108
+ contextOverflowBehavior: (record.contextOverflowBehavior as 'reject' | 'truncate') ?? 'reject',
109
+ allowedLlmServices: stringArray(record.allowedLlmServices),
110
+ allowAllModels: record.allowAllModels !== false,
111
+ allowedModels: stringArray(record.allowedModels),
112
+ };
113
+ }
114
+
115
+ function stringArray(value: unknown): string[] {
116
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
117
+ }
@@ -46,7 +46,6 @@ const aiApiConfigResource: ResourceOptions = {
46
46
  defaultAiEmployee: '',
47
47
  defaultLlmService: '',
48
48
  enabledLlmServices: [],
49
- rateLimitPerMinute: 60,
50
49
  maxRequestBodyMb: 10,
51
50
  pdfRenderPagesAsImages: false,
52
51
  quotaEnabled: false,
@@ -71,7 +70,6 @@ const aiApiConfigResource: ResourceOptions = {
71
70
  defaultAiEmployee: values.defaultAiEmployee ?? '',
72
71
  defaultLlmService: values.defaultLlmService ?? '',
73
72
  enabledLlmServices: values.enabledLlmServices ?? [],
74
- rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
75
73
  maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
76
74
  pdfRenderPagesAsImages: values.pdfRenderPagesAsImages ?? false,
77
75
  quotaEnabled: values.quotaEnabled ?? false,
@@ -85,7 +83,6 @@ const aiApiConfigResource: ResourceOptions = {
85
83
  if (values.defaultAiEmployee !== undefined) updateData.defaultAiEmployee = values.defaultAiEmployee;
86
84
  if (values.defaultLlmService !== undefined) updateData.defaultLlmService = values.defaultLlmService;
87
85
  if (values.enabledLlmServices !== undefined) updateData.enabledLlmServices = values.enabledLlmServices;
88
- if (values.rateLimitPerMinute !== undefined) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
89
86
  if (values.maxRequestBodyMb !== undefined) {
90
87
  updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
91
88
  }