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
@@ -5,6 +5,8 @@ import timezone from 'dayjs/plugin/timezone';
5
5
  import type { Model } from '@nocobase/database';
6
6
  import type { Transaction } from 'sequelize';
7
7
  import type { Usage } from './usage';
8
+ import { type QuotaMode } from './quota-groups';
9
+ import { getAiApiConfig, resolveRequestUserGroup } from './utils/request-cache';
8
10
 
9
11
  dayjs.extend(utc);
10
12
  dayjs.extend(timezone);
@@ -28,7 +30,8 @@ export interface PriceSnapshot {
28
30
 
29
31
  interface QuotaReservation {
30
32
  bucketId: string | number | bigint;
31
- policyId: string | number | bigint;
33
+ groupId: string | number | bigint;
34
+ quotaMode: QuotaMode;
32
35
  estimatedInputTokens: number;
33
36
  estimatedOutputTokens: number;
34
37
  reservedTokens: number;
@@ -54,6 +57,8 @@ export interface BillingFinalization {
54
57
  costStatus?: 'calculated' | 'estimated' | 'unpriced' | 'usage_unavailable';
55
58
  modelPriceId?: string | number | bigint;
56
59
  quotaPolicyId?: string | number | bigint;
60
+ groupId?: string | number | bigint;
61
+ quotaMode?: QuotaMode;
57
62
  inputPricePerMillionTokens?: string;
58
63
  outputPricePerMillionTokens?: string;
59
64
  fixedCostPerRequest?: string;
@@ -195,27 +200,23 @@ export async function prepareLlmBilling(ctx: Context, resolved: ResolvedLlmModel
195
200
  };
196
201
  stateOf(ctx).aiApiLlmBilling = billing;
197
202
 
198
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
203
+ const config = await getAiApiConfig(ctx);
199
204
  if (!valueOf<boolean | undefined>(config, 'quotaEnabled') || userId === undefined || userId === null) return;
200
205
 
201
- const policy = await ctx.db.getRepository('aiApiUserQuotaPolicies').findOne({
202
- filter: { userId, enabled: true },
203
- sort: '-updatedAt',
204
- });
205
- if (!policy) return;
206
+ const group = await resolveRequestUserGroup(ctx, userId);
207
+ if (!group.enabled) return;
206
208
 
207
- const rejectUnpriced = valueOf<boolean>(policy, 'rejectUnpricedModel');
209
+ const rejectUnpriced = group.rejectUnpricedModel;
208
210
  if (!price && rejectUnpriced) {
209
211
  throw new AiApiQuotaError(
210
212
  'model_price_not_configured',
211
213
  `Pricing is not configured for '${serviceName}/${resolved.modelId}'.`,
212
214
  );
213
215
  }
214
- const policyCurrency = valueOf<string>(policy, 'currency');
215
- if (price && policyCurrency !== price.currency) {
216
+ if (price && group.currency !== price.currency) {
216
217
  throw new AiApiQuotaError(
217
218
  'quota_currency_mismatch',
218
- `Quota currency '${policyCurrency}' does not match model price currency '${price.currency}'.`,
219
+ `Quota currency '${group.currency}' does not match model price currency '${price.currency}'.`,
219
220
  );
220
221
  }
221
222
 
@@ -225,14 +226,16 @@ export async function prepareLlmBilling(ctx: Context, resolved: ResolvedLlmModel
225
226
  const estimatedOutputTokens = normalizePositiveInteger(body.max_completion_tokens ?? body.max_tokens, defaultOutput);
226
227
  const reservedTokens = estimatedInputTokens + estimatedOutputTokens;
227
228
  const reservedCost = price ? calculateLlmCost(estimatedInputTokens, estimatedOutputTokens, price) : '0.00000000';
228
- const period = getPeriodBounds(valueOf<string>(policy, 'periodType'), valueOf<string>(policy, 'timezone'));
229
- const Bucket = ctx.db.getModel('aiApiUserQuotaBuckets');
229
+ const period = getPeriodBounds(group.periodType, group.timezone);
230
+ const bucketUserId = group.quotaMode === 'share' ? 0 : userId;
231
+ const Bucket = ctx.db.getModel('aiApiGroupQuotaBuckets');
230
232
 
231
233
  const reservation = await ctx.db.sequelize.transaction(async (transaction: Transaction) => {
232
234
  const [bucket] = await Bucket.findOrCreate({
233
- where: { policyId: valueOf(policy, 'id'), periodStart: period.start },
235
+ where: { groupId: group.id, userId: bucketUserId, periodStart: period.start },
234
236
  defaults: {
235
- userId,
237
+ groupId: group.id,
238
+ userId: bucketUserId,
236
239
  periodEnd: period.end,
237
240
  requestCount: 0,
238
241
  totalTokens: 0,
@@ -247,32 +250,20 @@ export async function prepareLlmBilling(ctx: Context, resolved: ResolvedLlmModel
247
250
 
248
251
  const requestCount = BigInt(String(bucket.get('requestCount') ?? 0));
249
252
  const reservedRequests = BigInt(String(bucket.get('reservedRequests') ?? 0));
250
- if (exceedsIntegerLimit(requestCount + reservedRequests, 1n, valueOf(policy, 'requestLimit'))) {
251
- throw new AiApiQuotaError('request_quota_exceeded', 'The request quota for this user has been exceeded.');
253
+ if (exceedsIntegerLimit(requestCount + reservedRequests, 1n, group.requestLimit)) {
254
+ throw new AiApiQuotaError('request_quota_exceeded', 'The request quota for this group has been exceeded.');
252
255
  }
253
256
 
254
257
  const totalTokens = BigInt(String(bucket.get('totalTokens') ?? 0));
255
258
  const alreadyReservedTokens = BigInt(String(bucket.get('reservedTokens') ?? 0));
256
- if (
257
- exceedsIntegerLimit(
258
- totalTokens + alreadyReservedTokens,
259
- BigInt(reservedTokens),
260
- valueOf(policy, 'totalTokenLimit'),
261
- )
262
- ) {
263
- throw new AiApiQuotaError('token_quota_exceeded', 'The token quota for this user has been exceeded.');
259
+ if (exceedsIntegerLimit(totalTokens + alreadyReservedTokens, BigInt(reservedTokens), group.totalTokenLimit)) {
260
+ throw new AiApiQuotaError('token_quota_exceeded', 'The token quota for this group has been exceeded.');
264
261
  }
265
262
 
266
263
  const cost = decimalUnits(bucket.get('cost'), COST_SCALE);
267
264
  const alreadyReservedCost = decimalUnits(bucket.get('reservedCost'), COST_SCALE);
268
- if (
269
- exceedsDecimalLimit(
270
- cost + alreadyReservedCost,
271
- decimalUnits(reservedCost, COST_SCALE),
272
- valueOf(policy, 'costLimit'),
273
- )
274
- ) {
275
- throw new AiApiQuotaError('cost_quota_exceeded', 'The cost quota for this user has been exceeded.');
265
+ if (exceedsDecimalLimit(cost + alreadyReservedCost, decimalUnits(reservedCost, COST_SCALE), group.costLimit)) {
266
+ throw new AiApiQuotaError('cost_quota_exceeded', 'The cost quota for this group has been exceeded.');
276
267
  }
277
268
 
278
269
  await bucket.update(
@@ -285,13 +276,13 @@ export async function prepareLlmBilling(ctx: Context, resolved: ResolvedLlmModel
285
276
  );
286
277
  return {
287
278
  bucketId: bucket.get('id') as string | number | bigint,
288
- policyId: valueOf<string | number | bigint>(policy, 'id'),
279
+ groupId: group.id,
280
+ quotaMode: group.quotaMode,
289
281
  estimatedInputTokens,
290
282
  estimatedOutputTokens,
291
283
  reservedTokens,
292
284
  reservedCost,
293
- missingUsageBehavior:
294
- valueOf<string>(policy, 'missingUsageBehavior') === 'allow' ? ('allow' as const) : ('use_reserved' as const),
285
+ missingUsageBehavior: group.missingUsageBehavior === 'allow' ? ('allow' as const) : ('use_reserved' as const),
295
286
  };
296
287
  });
297
288
  billing.reservation = reservation;
@@ -337,7 +328,7 @@ export async function finalizeLlmBilling(
337
328
  const cost = numbers && billing.price ? calculateLlmCost(numbers.input, numbers.output, billing.price) : undefined;
338
329
  const reservation = billing.reservation;
339
330
  if (reservation) {
340
- const Bucket = ctx.db.getModel('aiApiUserQuotaBuckets');
331
+ const Bucket = ctx.db.getModel('aiApiGroupQuotaBuckets');
341
332
  await ctx.db.sequelize.transaction(async (transaction: Transaction) => {
342
333
  const bucket = await Bucket.findByPk(reservation.bucketId, { transaction, lock: transaction.LOCK.UPDATE });
343
334
  if (!bucket) return;
@@ -384,7 +375,8 @@ export async function finalizeLlmBilling(
384
375
  currency: billing.price?.currency,
385
376
  costStatus,
386
377
  modelPriceId: billing.price?.id,
387
- quotaPolicyId: reservation?.policyId,
378
+ groupId: reservation?.groupId,
379
+ quotaMode: reservation?.quotaMode,
388
380
  inputPricePerMillionTokens: billing.price?.inputPricePerMillionTokens,
389
381
  outputPricePerMillionTokens: billing.price?.outputPricePerMillionTokens,
390
382
  fixedCostPerRequest: billing.price?.fixedCostPerRequest,
@@ -22,7 +22,7 @@ export default defineCollection({
22
22
  {
23
23
  name: 'defaultAiEmployee',
24
24
  type: 'string',
25
- comment: 'Username of the default AI Employee for system prompt injection',
25
+ comment: 'Username of the default AI Employee used by agent mode. Direct LLM mode ignores it.',
26
26
  },
27
27
  {
28
28
  name: 'defaultLlmService',
@@ -35,12 +35,6 @@ export default defineCollection({
35
35
  defaultValue: [],
36
36
  comment: 'Array of llmService names to expose. Empty = expose all enabled services',
37
37
  },
38
- {
39
- name: 'rateLimitPerMinute',
40
- type: 'integer',
41
- defaultValue: 60,
42
- comment: 'Max requests per user per minute',
43
- },
44
38
  {
45
39
  name: 'maxRequestBodyMb',
46
40
  type: 'integer',
@@ -0,0 +1,41 @@
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 { defineCollection } from '@nocobase/database';
11
+
12
+ export default defineCollection({
13
+ name: 'aiApiGroupMembers',
14
+ autoGenId: true,
15
+ fields: [
16
+ { name: 'groupId', type: 'bigInt', allowNull: false, index: true },
17
+ {
18
+ name: 'group',
19
+ type: 'belongsTo',
20
+ target: 'aiApiUsageGroups',
21
+ targetKey: 'id',
22
+ foreignKey: 'groupId',
23
+ constraints: false,
24
+ },
25
+ { name: 'userId', type: 'bigInt', allowNull: false, index: true },
26
+ {
27
+ name: 'user',
28
+ type: 'belongsTo',
29
+ target: 'users',
30
+ targetKey: 'id',
31
+ foreignKey: 'userId',
32
+ constraints: false,
33
+ },
34
+ ],
35
+ indexes: [
36
+ {
37
+ fields: ['userId'],
38
+ unique: true,
39
+ },
40
+ ],
41
+ });
@@ -0,0 +1,42 @@
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 { defineCollection } from '@nocobase/database';
11
+
12
+ export default defineCollection({
13
+ name: 'aiApiGroupQuotaBuckets',
14
+ autoGenId: true,
15
+ fields: [
16
+ { name: 'groupId', type: 'bigInt', allowNull: false, index: true },
17
+ {
18
+ name: 'group',
19
+ type: 'belongsTo',
20
+ target: 'aiApiUsageGroups',
21
+ targetKey: 'id',
22
+ foreignKey: 'groupId',
23
+ constraints: false,
24
+ },
25
+ // userId = 0 means the shared bucket in share mode; real user ids are always > 0.
26
+ { name: 'userId', type: 'bigInt', allowNull: false, defaultValue: 0, index: true },
27
+ { name: 'periodStart', type: 'datetimeTz', allowNull: false, index: true },
28
+ { name: 'periodEnd', type: 'datetimeTz', allowNull: false },
29
+ { name: 'requestCount', type: 'bigInt', allowNull: false, defaultValue: 0 },
30
+ { name: 'totalTokens', type: 'bigInt', allowNull: false, defaultValue: 0 },
31
+ { name: 'cost', type: 'decimal', precision: 20, scale: 8, allowNull: false, defaultValue: 0 },
32
+ { name: 'reservedRequests', type: 'bigInt', allowNull: false, defaultValue: 0 },
33
+ { name: 'reservedTokens', type: 'bigInt', allowNull: false, defaultValue: 0 },
34
+ { name: 'reservedCost', type: 'decimal', precision: 20, scale: 8, allowNull: false, defaultValue: 0 },
35
+ ],
36
+ indexes: [
37
+ {
38
+ fields: ['groupId', 'userId', 'periodStart'],
39
+ unique: true,
40
+ },
41
+ ],
42
+ });
@@ -55,6 +55,13 @@ export default defineCollection({
55
55
  allowNull: true,
56
56
  comment: 'Human-readable description returned as description in the model object.',
57
57
  },
58
+ {
59
+ name: 'systemPrompt',
60
+ type: 'text',
61
+ allowNull: true,
62
+ comment:
63
+ 'Initial system prompt prepended as the first system message of every request for this model. Never replaces the client system prompt.',
64
+ },
58
65
  {
59
66
  name: 'enabled',
60
67
  type: 'boolean',
@@ -0,0 +1,53 @@
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 { defineCollection } from '@nocobase/database';
11
+
12
+ export default defineCollection({
13
+ name: 'aiApiUsageGroups',
14
+ autoGenId: true,
15
+ fields: [
16
+ { name: 'name', type: 'string', allowNull: false },
17
+ { name: 'isDefault', type: 'boolean', defaultValue: false, index: true },
18
+ { name: 'quotaMode', type: 'string', allowNull: false, defaultValue: 'per_user' },
19
+ { name: 'rateLimitPerMinute', type: 'integer', allowNull: false, defaultValue: 60 },
20
+ { name: 'enabled', type: 'boolean', defaultValue: true, index: true },
21
+ { name: 'periodType', type: 'string', allowNull: false, defaultValue: 'monthly' },
22
+ { name: 'timezone', type: 'string', allowNull: false, defaultValue: 'UTC' },
23
+ { name: 'requestLimit', type: 'bigInt', allowNull: true },
24
+ { name: 'totalTokenLimit', type: 'bigInt', allowNull: true },
25
+ { name: 'costLimit', type: 'decimal', precision: 20, scale: 8, allowNull: true },
26
+ { name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
27
+ { name: 'rejectUnpricedModel', type: 'boolean', defaultValue: true },
28
+ { name: 'missingUsageBehavior', type: 'string', allowNull: false, defaultValue: 'use_reserved' },
29
+ { name: 'contextOverflowBehavior', type: 'string', allowNull: false, defaultValue: 'reject' },
30
+ // Model access: empty lists mean "no narrowing" — the group inherits the full
31
+ // global configuration. Non-empty lists narrow what members may use.
32
+ {
33
+ name: 'allowedLlmServices',
34
+ type: 'json',
35
+ defaultValue: [],
36
+ comment: 'Empty means all globally enabled services; otherwise narrows to these services.',
37
+ },
38
+ { name: 'allowAllModels', type: 'boolean', defaultValue: true },
39
+ {
40
+ name: 'allowedModels',
41
+ type: 'json',
42
+ defaultValue: [],
43
+ comment: 'Array of "serviceName/modelId" members may use (when allowAllModels=false).',
44
+ },
45
+ ],
46
+ indexes: [
47
+ {
48
+ fields: ['isDefault'],
49
+ unique: true,
50
+ where: { isDefault: true },
51
+ },
52
+ ],
53
+ });
@@ -38,6 +38,7 @@ export default defineCollection({
38
38
  { name: 'costStatus', type: 'string', allowNull: true, index: true },
39
39
  { name: 'modelPriceId', type: 'bigInt', allowNull: true, index: true },
40
40
  { name: 'quotaPolicyId', type: 'bigInt', allowNull: true, index: true },
41
+ { name: 'groupId', type: 'bigInt', allowNull: true, index: true },
41
42
  { name: 'inputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
42
43
  { name: 'outputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
43
44
  { name: 'fixedCostPerRequest', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
@@ -10,14 +10,14 @@
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { RateLimiter } from '../utils/rate-limiter';
12
12
  import { toOpenAIError } from '../utils/openai-format';
13
+ import { resolveRequestUserGroup } from '../utils/request-cache';
13
14
 
14
15
  /**
15
16
  * Creates a rate-limiting check function for use in the AI API router.
16
17
  *
17
18
  * Must be called AFTER authenticateBearer() so ctx.state.currentUser is set.
18
- * Reads rateLimitPerMinute from aiApiConfig on each request (cheap single-row read,
19
- * allows config changes to take effect immediately without restart).
20
- * Falls back to 60 req/min if the config record is missing or the field is 0/null.
19
+ * Resolves the user's group and reads rateLimitPerMinute from that group.
20
+ * Falls back to 60 req/min if the default group is missing or misconfigured.
21
21
  *
22
22
  * Returns false (and writes the 429 response) when the rate limit is exceeded.
23
23
  * Returns true when the request is allowed.
@@ -35,21 +35,19 @@ export function createRateLimitMiddleware(limiter: RateLimiter) {
35
35
 
36
36
  let limit = 60;
37
37
  try {
38
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
39
- const configLimit = config?.rateLimitPerMinute;
40
- if (configLimit && configLimit > 0) {
41
- limit = configLimit;
38
+ const group = await resolveRequestUserGroup(ctx, userId);
39
+ const groupLimit = group?.rateLimitPerMinute;
40
+ if (groupLimit && groupLimit > 0) {
41
+ limit = groupLimit;
42
42
  }
43
- } catch (configErr) {
44
- // Config read failure: fail open don't block legitimate requests
45
- // Log at WARN so admins can detect DB connectivity issues
46
- ctx.app?.logger?.warn('[ai-api] Rate limit config read failed, using default (60/min)', configErr);
43
+ } catch (err) {
44
+ ctx.app?.logger?.warn('[ai-api] Rate limit group resolution failed, using default (60/min)', err);
47
45
  }
48
46
 
49
47
  const result = limiter.check(userId, limit);
50
48
 
51
49
  if (!result.allowed) {
52
- const retryAfterSec = Math.ceil((result as any).retryAfterMs / 1000);
50
+ const retryAfterSec = Math.ceil(result.retryAfterMs / 1000);
53
51
  ctx.set('Retry-After', String(retryAfterSec));
54
52
  ctx.set('X-RateLimit-Limit', String(limit));
55
53
  ctx.set('X-RateLimit-Remaining', '0');
@@ -0,0 +1,147 @@
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
+ export default class AddUsageGroups extends Migration {
13
+ // Must run afterSync: plugin collections are only registered once the app has
14
+ // loaded, and the new group tables must exist before seeding.
15
+ on = 'afterSync' as const;
16
+
17
+ async up() {
18
+ const groupCollection = this.getGroupCollection();
19
+
20
+ if (!groupCollection) {
21
+ throw new Error('AI API usage groups migration could not resolve the aiApiUsageGroups collection.');
22
+ }
23
+
24
+ const configCollection = this.db.getCollection('aiApiConfig');
25
+ let rateLimitPerMinute = 60;
26
+ let quotaEnabled = false;
27
+ if (configCollection && (await configCollection.existsInDb())) {
28
+ // rateLimitPerMinute is no longer part of the collection definition, so
29
+ // read the legacy column directly before dropping it.
30
+ const tableName = configCollection.getTableNameWithSchema();
31
+ const columns = await this.queryInterface.describeTable(tableName);
32
+ if (columns.rateLimitPerMinute) {
33
+ const legacyRateLimit = await this.readLegacyRateLimit(configCollection.quotedTableName());
34
+ if (legacyRateLimit && legacyRateLimit > 0) {
35
+ rateLimitPerMinute = legacyRateLimit;
36
+ }
37
+ }
38
+ const config = await this.db.getRepository('aiApiConfig').findOne();
39
+ if (config) {
40
+ quotaEnabled = !!config.get('quotaEnabled');
41
+ }
42
+ }
43
+
44
+ const existingDefault = await this.db.getRepository('aiApiUsageGroups').findOne({
45
+ filter: { isDefault: true },
46
+ });
47
+
48
+ if (!existingDefault) {
49
+ await this.db.getRepository('aiApiUsageGroups').create({
50
+ values: {
51
+ name: 'Default',
52
+ isDefault: true,
53
+ quotaMode: 'per_user',
54
+ rateLimitPerMinute,
55
+ enabled: quotaEnabled,
56
+ periodType: 'monthly',
57
+ timezone: 'UTC',
58
+ requestLimit: null,
59
+ totalTokenLimit: null,
60
+ costLimit: null,
61
+ currency: 'USD',
62
+ rejectUnpricedModel: true,
63
+ missingUsageBehavior: 'use_reserved',
64
+ contextOverflowBehavior: 'reject',
65
+ },
66
+ });
67
+ }
68
+
69
+ if (configCollection && (await configCollection.existsInDb())) {
70
+ const tableName = configCollection.getTableNameWithSchema();
71
+ const columns = await this.queryInterface.describeTable(tableName);
72
+ if (columns.rateLimitPerMinute) {
73
+ await this.queryInterface.removeColumn(tableName, 'rateLimitPerMinute');
74
+ }
75
+ }
76
+
77
+ await this.migrateLegacyQuotaPolicies();
78
+ }
79
+
80
+ async down() {
81
+ // Recreate the rateLimitPerMinute column on aiApiConfig with a sensible default.
82
+ const configCollection = this.db.getCollection('aiApiConfig');
83
+ if (configCollection && (await configCollection.existsInDb())) {
84
+ const tableName = configCollection.getTableNameWithSchema();
85
+ const columns = await this.queryInterface.describeTable(tableName);
86
+ if (!columns.rateLimitPerMinute) {
87
+ await this.queryInterface.addColumn(tableName, 'rateLimitPerMinute', {
88
+ type: 'INTEGER',
89
+ allowNull: false,
90
+ defaultValue: 60,
91
+ });
92
+ }
93
+ }
94
+ }
95
+
96
+ private getGroupCollection() {
97
+ return this.db.getCollection('aiApiUsageGroups');
98
+ }
99
+
100
+ private async readLegacyRateLimit(quotedTable: string): Promise<number | undefined> {
101
+ const qi = this.db.sequelize.getQueryInterface();
102
+ const [rows] = await this.db.sequelize.query(
103
+ `SELECT ${qi.quoteIdentifier('rateLimitPerMinute')} FROM ${quotedTable} LIMIT 1`,
104
+ );
105
+ const first = (rows as Array<Record<string, unknown>> | undefined)?.[0];
106
+ const value = Number(first?.rateLimitPerMinute);
107
+ return Number.isFinite(value) ? value : undefined;
108
+ }
109
+
110
+ private async migrateLegacyQuotaPolicies() {
111
+ const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
112
+ filter: { isDefault: true },
113
+ });
114
+ if (!defaultGroup) return;
115
+
116
+ const policyCollection = this.db.getCollection('aiApiUserQuotaPolicies');
117
+ const memberCollection = this.db.getCollection('aiApiGroupMembers');
118
+ const bucketCollection = this.db.getCollection('aiApiGroupQuotaBuckets');
119
+ if (!policyCollection || !memberCollection || !bucketCollection) return;
120
+
121
+ const policies = (await this.db.getRepository('aiApiUserQuotaPolicies').find({
122
+ pageSize: 1000,
123
+ })) as unknown[];
124
+
125
+ for (const policy of policies) {
126
+ const userId = this.valueOf(policy, 'userId');
127
+ if (userId === undefined || userId === null) continue;
128
+
129
+ const existingMember = await this.db.getRepository('aiApiGroupMembers').findOne({
130
+ filter: { userId },
131
+ });
132
+ if (!existingMember) {
133
+ await this.db.getRepository('aiApiGroupMembers').create({
134
+ values: { groupId: this.valueOf(defaultGroup, 'id'), userId },
135
+ });
136
+ }
137
+ }
138
+ }
139
+
140
+ private valueOf(model: unknown, name: string): unknown {
141
+ if (!model) return undefined;
142
+ if (typeof (model as Record<string, unknown>).get === 'function') {
143
+ return (model as Record<string, unknown>).get(name);
144
+ }
145
+ return (model as Record<string, unknown>)[name];
146
+ }
147
+ }