plugin-ai-api 1.0.24 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
- package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
- package/dist/client/562.44b16aad4718b4c7.js +10 -0
- package/dist/client/685.ae483e17b6b49c98.js +10 -0
- package/dist/client/757.6568d3504ad29352.js +10 -0
- package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.3971233415999b2c.js +10 -0
- package/dist/client-v2/562.45d5c504433be38b.js +10 -0
- package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
- package/dist/client-v2/757.f2bc9cfba07004b0.js +10 -0
- package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
- package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +2 -5
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +27 -8
- package/dist/locale/vi-VN.json +27 -8
- package/dist/locale/zh-CN.json +27 -8
- package/dist/server/billing.js +31 -33
- package/dist/server/collections/ai-api-config.js +7 -7
- package/dist/server/collections/ai-api-group-members.js +62 -0
- package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
- package/dist/server/collections/ai-api-model-metadata.js +6 -0
- package/dist/server/collections/ai-api-usage-groups.js +74 -0
- package/dist/server/collections/ai-api-usage-records.js +2 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
- package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
- package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
- package/dist/server/plugin.js +100 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +5 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +121 -42
- package/dist/server/routes/completions.js +48 -29
- package/dist/server/routes/embeddings.js +2 -1
- package/dist/server/routes/models.js +2 -1
- package/dist/server/routes/router.js +3 -2
- package/dist/server/services/file-processor.js +426 -0
- package/dist/server/usage.js +37 -3
- package/dist/server/utils/direct-llm-context.js +163 -26
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/server/utils/rate-limiter.js +1 -1
- package/dist/server/utils/request-cache.js +61 -0
- package/dist/server/utils/resolve-service.js +2 -1
- package/dist/server/utils/user-permissions.js +25 -39
- package/dist/server/validation.js +7 -0
- package/dist/swagger.js +48 -10
- package/package.json +1 -1
- package/src/client/__tests__/settings-registration.test.tsx +6 -29
- package/src/client/plugin.tsx +5 -16
- package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
- package/src/client-v2/locale.ts +3 -1
- package/src/client-v2/pages/GeneralPage.tsx +0 -5
- package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
- package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +27 -8
- package/src/locale/vi-VN.json +27 -8
- package/src/locale/zh-CN.json +27 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +209 -10
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/request-body.test.ts +45 -2
- package/src/server/__tests__/usage-groups.test.ts +160 -0
- package/src/server/__tests__/usage-monitor.test.ts +2 -0
- package/src/server/__tests__/usage-route.test.ts +382 -5
- package/src/server/__tests__/usage.test.ts +57 -0
- package/src/server/__tests__/user-permissions.test.ts +214 -133
- package/src/server/__tests__/validation.test.ts +11 -0
- package/src/server/billing.ts +36 -39
- package/src/server/collections/ai-api-config.ts +9 -7
- package/src/server/collections/ai-api-group-members.ts +41 -0
- package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
- package/src/server/collections/ai-api-model-metadata.ts +7 -0
- package/src/server/collections/ai-api-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +2 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +68 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
- package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
- package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
- package/src/server/plugin.ts +121 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +5 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +173 -47
- package/src/server/routes/completions.ts +50 -27
- package/src/server/routes/embeddings.ts +2 -1
- package/src/server/routes/models.ts +4 -3
- package/src/server/routes/router.ts +4 -3
- package/src/server/services/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +513 -0
- package/src/server/usage.ts +51 -1
- package/src/server/utils/direct-llm-context.ts +218 -31
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +83 -82
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +52 -11
- package/dist/client/123.e6fe04c856ce6417.js +0 -10
- package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
- package/dist/client/562.17a0a299d2e5152c.js +0 -10
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client/902.e74518750f1e4201.js +0 -10
- package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
- package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
- package/dist/client-v2/902.c7c00a565085438a.js +0 -10
- package/dist/server/resource/ai-api-user-permissions.js +0 -75
- package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
- package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
- package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
- package/src/server/resource/ai-api-user-permissions.ts +0 -76
package/src/server/billing.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
203
|
+
const config = await getAiApiConfig(ctx);
|
|
199
204
|
if (!valueOf<boolean | undefined>(config, 'quotaEnabled') || userId === undefined || userId === null) return;
|
|
200
205
|
|
|
201
|
-
const
|
|
202
|
-
|
|
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 =
|
|
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
|
-
|
|
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 '${
|
|
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(
|
|
229
|
-
const
|
|
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: {
|
|
235
|
+
where: { groupId: group.id, userId: bucketUserId, periodStart: period.start },
|
|
234
236
|
defaults: {
|
|
235
|
-
|
|
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,
|
|
251
|
-
throw new AiApiQuotaError('request_quota_exceeded', 'The request quota for this
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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;
|
|
@@ -373,13 +364,19 @@ export async function finalizeLlmBilling(
|
|
|
373
364
|
|
|
374
365
|
return {
|
|
375
366
|
usage: numbers
|
|
376
|
-
? {
|
|
367
|
+
? {
|
|
368
|
+
prompt_tokens: numbers.input,
|
|
369
|
+
completion_tokens: numbers.output,
|
|
370
|
+
total_tokens: numbers.total,
|
|
371
|
+
prompt_cache_tokens: providerUsage?.prompt_cache_tokens ?? null,
|
|
372
|
+
}
|
|
377
373
|
: providerUsage,
|
|
378
374
|
estimatedCost: cost,
|
|
379
375
|
currency: billing.price?.currency,
|
|
380
376
|
costStatus,
|
|
381
377
|
modelPriceId: billing.price?.id,
|
|
382
|
-
|
|
378
|
+
groupId: reservation?.groupId,
|
|
379
|
+
quotaMode: reservation?.quotaMode,
|
|
383
380
|
inputPricePerMillionTokens: billing.price?.inputPricePerMillionTokens,
|
|
384
381
|
outputPricePerMillionTokens: billing.price?.outputPricePerMillionTokens,
|
|
385
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
|
|
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,18 +35,20 @@ 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',
|
|
47
41
|
defaultValue: 10,
|
|
48
42
|
comment: 'Max request body size in MB. Raise this to accept inline base64 images in vision requests.',
|
|
49
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: 'pdfRenderPagesAsImages',
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
defaultValue: false,
|
|
48
|
+
comment:
|
|
49
|
+
'When true, PDF file/file_url blocks are rendered to per-page PNG images and sent as image_url blocks. ' +
|
|
50
|
+
'Requires a registered PdfToImageRenderer. When false or no renderer is available, PDFs are forwarded as file blocks.',
|
|
51
|
+
},
|
|
50
52
|
{
|
|
51
53
|
name: 'quotaEnabled',
|
|
52
54
|
type: 'boolean',
|
|
@@ -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',
|
|
@@ -1,41 +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: 'aiApiRolePermissions',
|
|
14
|
-
autoGenId: true,
|
|
15
|
-
fields: [
|
|
16
|
-
{
|
|
17
|
-
name: 'roleName',
|
|
18
|
-
type: 'string',
|
|
19
|
-
unique: true,
|
|
20
|
-
comment: 'Role name (links to roles.name)',
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
name: 'enabled',
|
|
24
|
-
type: 'boolean',
|
|
25
|
-
defaultValue: false,
|
|
26
|
-
comment: 'Whether this role can use the AI API at all',
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
name: 'allowAllEmployees',
|
|
30
|
-
type: 'boolean',
|
|
31
|
-
defaultValue: true,
|
|
32
|
-
comment: 'If true, the role may use any AI Employee. If false, only those in allowedEmployees.',
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
name: 'allowedEmployees',
|
|
36
|
-
type: 'json',
|
|
37
|
-
defaultValue: [],
|
|
38
|
-
comment: 'Array of AI Employee usernames this role is allowed to use (when allowAllEmployees=false)',
|
|
39
|
-
},
|
|
40
|
-
],
|
|
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: 'aiApiRolePermissions',
|
|
14
|
+
autoGenId: true,
|
|
15
|
+
fields: [
|
|
16
|
+
{
|
|
17
|
+
name: 'roleName',
|
|
18
|
+
type: 'string',
|
|
19
|
+
unique: true,
|
|
20
|
+
comment: 'Role name (links to roles.name)',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'enabled',
|
|
24
|
+
type: 'boolean',
|
|
25
|
+
defaultValue: false,
|
|
26
|
+
comment: 'Whether this role can use the AI API at all',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'allowAllEmployees',
|
|
30
|
+
type: 'boolean',
|
|
31
|
+
defaultValue: true,
|
|
32
|
+
comment: 'If true, the role may use any AI Employee. If false, only those in allowedEmployees.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'allowedEmployees',
|
|
36
|
+
type: 'json',
|
|
37
|
+
defaultValue: [],
|
|
38
|
+
comment: 'Array of AI Employee usernames this role is allowed to use (when allowAllEmployees=false)',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -32,11 +32,13 @@ export default defineCollection({
|
|
|
32
32
|
{ name: 'inputTokens', type: 'integer', allowNull: true },
|
|
33
33
|
{ name: 'outputTokens', type: 'integer', allowNull: true },
|
|
34
34
|
{ name: 'totalTokens', type: 'integer', allowNull: true },
|
|
35
|
+
{ name: 'promptCacheTokens', type: 'integer', allowNull: true },
|
|
35
36
|
{ name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
|
|
36
37
|
{ name: 'currency', type: 'string', allowNull: true },
|
|
37
38
|
{ name: 'costStatus', type: 'string', allowNull: true, index: true },
|
|
38
39
|
{ name: 'modelPriceId', type: 'bigInt', allowNull: true, index: true },
|
|
39
40
|
{ name: 'quotaPolicyId', type: 'bigInt', allowNull: true, index: true },
|
|
41
|
+
{ name: 'groupId', type: 'bigInt', allowNull: true, index: true },
|
|
40
42
|
{ name: 'inputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
41
43
|
{ name: 'outputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
42
44
|
{ name: 'fixedCostPerRequest', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
package/src/server/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
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
|
-
export { default } from './plugin';
|
|
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
|
+
export { default } from './plugin';
|
|
@@ -1,70 +1,68 @@
|
|
|
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 { RateLimiter } from '../utils/rate-limiter';
|
|
12
|
-
import { toOpenAIError } from '../utils/openai-format';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* Falls back to 60 req/min if the
|
|
21
|
-
*
|
|
22
|
-
* Returns false (and writes the 429 response) when the rate limit is exceeded.
|
|
23
|
-
* Returns true when the request is allowed.
|
|
24
|
-
*
|
|
25
|
-
* Sets OpenAI-compatible rate limit response headers on every request:
|
|
26
|
-
* X-RateLimit-Limit: <limit>
|
|
27
|
-
* X-RateLimit-Remaining: <remaining> (on 429: 0)
|
|
28
|
-
* Retry-After: <seconds> (on 429 only)
|
|
29
|
-
*/
|
|
30
|
-
export function createRateLimitMiddleware(limiter: RateLimiter) {
|
|
31
|
-
return async (ctx: Context): Promise<boolean> => {
|
|
32
|
-
const userId = ctx.state.currentUser?.id;
|
|
33
|
-
// Auth runs before this; if somehow missing, fail open (don't block the request).
|
|
34
|
-
if (userId === undefined || userId === null) return true;
|
|
35
|
-
|
|
36
|
-
let limit = 60;
|
|
37
|
-
try {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
limit =
|
|
42
|
-
}
|
|
43
|
-
} catch (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
ctx.set('
|
|
54
|
-
ctx.
|
|
55
|
-
ctx.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
};
|
|
70
|
-
}
|
|
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 { RateLimiter } from '../utils/rate-limiter';
|
|
12
|
+
import { toOpenAIError } from '../utils/openai-format';
|
|
13
|
+
import { resolveRequestUserGroup } from '../utils/request-cache';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Creates a rate-limiting check function for use in the AI API router.
|
|
17
|
+
*
|
|
18
|
+
* Must be called AFTER authenticateBearer() so ctx.state.currentUser is set.
|
|
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
|
+
*
|
|
22
|
+
* Returns false (and writes the 429 response) when the rate limit is exceeded.
|
|
23
|
+
* Returns true when the request is allowed.
|
|
24
|
+
*
|
|
25
|
+
* Sets OpenAI-compatible rate limit response headers on every request:
|
|
26
|
+
* X-RateLimit-Limit: <limit>
|
|
27
|
+
* X-RateLimit-Remaining: <remaining> (on 429: 0)
|
|
28
|
+
* Retry-After: <seconds> (on 429 only)
|
|
29
|
+
*/
|
|
30
|
+
export function createRateLimitMiddleware(limiter: RateLimiter) {
|
|
31
|
+
return async (ctx: Context): Promise<boolean> => {
|
|
32
|
+
const userId = ctx.state.currentUser?.id;
|
|
33
|
+
// Auth runs before this; if somehow missing, fail open (don't block the request).
|
|
34
|
+
if (userId === undefined || userId === null) return true;
|
|
35
|
+
|
|
36
|
+
let limit = 60;
|
|
37
|
+
try {
|
|
38
|
+
const group = await resolveRequestUserGroup(ctx, userId);
|
|
39
|
+
const groupLimit = group?.rateLimitPerMinute;
|
|
40
|
+
if (groupLimit && groupLimit > 0) {
|
|
41
|
+
limit = groupLimit;
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
ctx.app?.logger?.warn('[ai-api] Rate limit group resolution failed, using default (60/min)', err);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const result = limiter.check(userId, limit);
|
|
48
|
+
|
|
49
|
+
if (!result.allowed) {
|
|
50
|
+
const retryAfterSec = Math.ceil(result.retryAfterMs / 1000);
|
|
51
|
+
ctx.set('Retry-After', String(retryAfterSec));
|
|
52
|
+
ctx.set('X-RateLimit-Limit', String(limit));
|
|
53
|
+
ctx.set('X-RateLimit-Remaining', '0');
|
|
54
|
+
ctx.status = 429;
|
|
55
|
+
ctx.body = toOpenAIError(
|
|
56
|
+
429,
|
|
57
|
+
`Rate limit exceeded. You have used all ${limit} requests allowed per minute. ` +
|
|
58
|
+
`Please wait ${retryAfterSec} second${retryAfterSec !== 1 ? 's' : ''} before retrying.`,
|
|
59
|
+
'requests',
|
|
60
|
+
'rate_limit_exceeded',
|
|
61
|
+
);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ctx.set('X-RateLimit-Limit', String(limit));
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
}
|