plugin-ai-api 1.0.15 → 1.0.20
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/302.25edd5d75460acbf.js +10 -0
- package/dist/client/757.71e30f2a1306562d.js +10 -0
- package/dist/client/902.4238b04ac667c30a.js +10 -0
- package/dist/client/97.37cda285d7da3a26.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.9b27a263901d54d8.js +10 -0
- package/dist/client-v2/757.c377e2f2b054d89d.js +10 -0
- package/dist/client-v2/902.d40d7bda106124c8.js +10 -0
- package/dist/client-v2/97.fc922c37ced86831.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +9 -9
- package/dist/locale/en-US.json +78 -2
- package/dist/locale/vi-VN.json +86 -0
- package/dist/locale/zh-CN.json +86 -10
- package/dist/server/billing.js +331 -0
- package/dist/server/collections/ai-api-config.js +12 -0
- package/dist/server/collections/ai-api-model-prices.js +55 -0
- package/dist/server/collections/ai-api-usage-records.js +9 -0
- package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
- package/dist/server/plugin.js +23 -2
- package/dist/server/resource/ai-api-config.js +8 -0
- package/dist/server/resource/ai-api-usage-monitor.js +86 -0
- package/dist/server/routes/chat-completions.js +12 -2
- package/dist/server/routes/completions.js +12 -2
- package/dist/server/routes/router.js +14 -1
- package/dist/server/usage.js +17 -2
- package/dist/server/validation.js +102 -0
- package/package.json +1 -1
- package/src/client/plugin.tsx +73 -48
- package/src/client-v2/locale.ts +1 -0
- package/src/client-v2/pages/GeneralPage.tsx +170 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
- package/src/client-v2/pages/UsagePage.tsx +248 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
- package/src/client-v2/pages/api.ts +16 -0
- package/src/client-v2/plugin.tsx +21 -3
- package/src/locale/en-US.json +78 -2
- package/src/locale/vi-VN.json +86 -0
- package/src/locale/zh-CN.json +86 -10
- package/src/server/__tests__/billing-quota.test.ts +134 -0
- package/src/server/__tests__/billing.test.ts +33 -0
- package/src/server/__tests__/usage-monitor.test.ts +63 -0
- package/src/server/__tests__/usage-route.test.ts +4 -0
- package/src/server/billing.ts +387 -0
- package/src/server/collections/ai-api-config.ts +63 -51
- package/src/server/collections/ai-api-model-prices.ts +25 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -0
- package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
- package/src/server/plugin.ts +24 -2
- package/src/server/resource/ai-api-config.ts +82 -74
- package/src/server/resource/ai-api-usage-monitor.ts +74 -0
- package/src/server/routes/chat-completions.ts +13 -2
- package/src/server/routes/completions.ts +13 -2
- package/src/server/routes/router.ts +16 -1
- package/src/server/usage.ts +17 -1
- package/src/server/validation.ts +62 -0
- package/dist/client/950.83390c5f1d5a97fb.js +0 -10
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
|
@@ -1,51 +1,63 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This file is part of the NocoBase (R) project.
|
|
3
|
-
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
-
* Authors: NocoBase Team.
|
|
5
|
-
*
|
|
6
|
-
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
-
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { defineCollection } from '@nocobase/database';
|
|
11
|
-
|
|
12
|
-
export default defineCollection({
|
|
13
|
-
name: 'aiApiConfig',
|
|
14
|
-
autoGenId: true,
|
|
15
|
-
fields: [
|
|
16
|
-
{
|
|
17
|
-
name: 'mode',
|
|
18
|
-
type: 'string',
|
|
19
|
-
defaultValue: 'llm',
|
|
20
|
-
comment: "API mode: 'llm' = direct LLM proxy, 'agent' = full AI Employee agent with tools/RAG",
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
name: 'defaultAiEmployee',
|
|
24
|
-
type: 'string',
|
|
25
|
-
comment: 'Username of the default AI Employee for system prompt injection',
|
|
26
|
-
},
|
|
27
|
-
{
|
|
28
|
-
name: 'defaultLlmService',
|
|
29
|
-
type: 'string',
|
|
30
|
-
comment: 'Name (UID) of the default LLM service. Clients can send just modelId without service prefix.',
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
name: 'enabledLlmServices',
|
|
34
|
-
type: 'json',
|
|
35
|
-
defaultValue: [],
|
|
36
|
-
comment: 'Array of llmService names to expose. Empty = expose all enabled services',
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
name: 'rateLimitPerMinute',
|
|
40
|
-
type: 'integer',
|
|
41
|
-
defaultValue: 60,
|
|
42
|
-
comment: 'Max requests per user per minute',
|
|
43
|
-
},
|
|
44
|
-
{
|
|
45
|
-
name: '
|
|
46
|
-
type: '
|
|
47
|
-
defaultValue:
|
|
48
|
-
comment: '
|
|
49
|
-
},
|
|
50
|
-
|
|
51
|
-
|
|
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: 'aiApiConfig',
|
|
14
|
+
autoGenId: true,
|
|
15
|
+
fields: [
|
|
16
|
+
{
|
|
17
|
+
name: 'mode',
|
|
18
|
+
type: 'string',
|
|
19
|
+
defaultValue: 'llm',
|
|
20
|
+
comment: "API mode: 'llm' = direct LLM proxy, 'agent' = full AI Employee agent with tools/RAG",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'defaultAiEmployee',
|
|
24
|
+
type: 'string',
|
|
25
|
+
comment: 'Username of the default AI Employee for system prompt injection',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'defaultLlmService',
|
|
29
|
+
type: 'string',
|
|
30
|
+
comment: 'Name (UID) of the default LLM service. Clients can send just modelId without service prefix.',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'enabledLlmServices',
|
|
34
|
+
type: 'json',
|
|
35
|
+
defaultValue: [],
|
|
36
|
+
comment: 'Array of llmService names to expose. Empty = expose all enabled services',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'rateLimitPerMinute',
|
|
40
|
+
type: 'integer',
|
|
41
|
+
defaultValue: 60,
|
|
42
|
+
comment: 'Max requests per user per minute',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'quotaEnabled',
|
|
46
|
+
type: 'boolean',
|
|
47
|
+
defaultValue: false,
|
|
48
|
+
comment: 'Enable per-user request, token, and cost quotas for direct LLM mode',
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'defaultReservationOutputTokens',
|
|
52
|
+
type: 'integer',
|
|
53
|
+
defaultValue: 4096,
|
|
54
|
+
comment: 'Output tokens reserved when a request does not specify a maximum',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'options',
|
|
58
|
+
type: 'jsonb',
|
|
59
|
+
defaultValue: {},
|
|
60
|
+
comment: 'Reserved for future extensibility',
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { defineCollection } from '@nocobase/database';
|
|
2
|
+
|
|
3
|
+
export default defineCollection({
|
|
4
|
+
name: 'aiApiModelPrices',
|
|
5
|
+
autoGenId: true,
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: 'llmService', type: 'string', allowNull: false, index: true },
|
|
8
|
+
{ name: 'provider', type: 'string', allowNull: false, index: true },
|
|
9
|
+
{ name: 'model', type: 'string', allowNull: false, index: true },
|
|
10
|
+
{ name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
|
|
11
|
+
{ name: 'inputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10, allowNull: false },
|
|
12
|
+
{ name: 'outputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10, allowNull: false },
|
|
13
|
+
{ name: 'fixedCostPerRequest', type: 'decimal', precision: 20, scale: 10, allowNull: false, defaultValue: 0 },
|
|
14
|
+
{ name: 'effectiveFrom', type: 'datetimeTz', allowNull: false, index: true },
|
|
15
|
+
{ name: 'effectiveTo', type: 'datetimeTz', allowNull: true, index: true },
|
|
16
|
+
{ name: 'enabled', type: 'boolean', defaultValue: true, index: true },
|
|
17
|
+
{ name: 'notes', type: 'text', allowNull: true },
|
|
18
|
+
],
|
|
19
|
+
indexes: [
|
|
20
|
+
{
|
|
21
|
+
fields: ['llmService', 'model', 'effectiveFrom'],
|
|
22
|
+
unique: true,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
});
|
|
@@ -22,6 +22,9 @@ export default defineCollection({
|
|
|
22
22
|
{ name: 'endpoint', type: 'string' },
|
|
23
23
|
{ name: 'mode', type: 'string', allowNull: true },
|
|
24
24
|
{ name: 'model', type: 'string', allowNull: true, index: true },
|
|
25
|
+
{ name: 'resolvedService', type: 'string', allowNull: true, index: true },
|
|
26
|
+
{ name: 'resolvedProvider', type: 'string', allowNull: true, index: true },
|
|
27
|
+
{ name: 'resolvedModel', type: 'string', allowNull: true, index: true },
|
|
25
28
|
{ name: 'status', type: 'string', index: true },
|
|
26
29
|
{ name: 'httpStatus', type: 'integer', allowNull: true },
|
|
27
30
|
{ name: 'errorCode', type: 'string', allowNull: true },
|
|
@@ -31,6 +34,12 @@ export default defineCollection({
|
|
|
31
34
|
{ name: 'totalTokens', type: 'integer', allowNull: true },
|
|
32
35
|
{ name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
|
|
33
36
|
{ name: 'currency', type: 'string', allowNull: true },
|
|
37
|
+
{ name: 'costStatus', type: 'string', allowNull: true, index: true },
|
|
38
|
+
{ name: 'modelPriceId', type: 'bigInt', allowNull: true, index: true },
|
|
39
|
+
{ name: 'quotaPolicyId', type: 'bigInt', allowNull: true, index: true },
|
|
40
|
+
{ name: 'inputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
41
|
+
{ name: 'outputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
42
|
+
{ name: 'fixedCostPerRequest', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
|
|
34
43
|
{ name: 'providerRequestId', type: 'string', allowNull: true },
|
|
35
44
|
{ name: 'requestMetadata', type: 'jsonb', defaultValue: {} },
|
|
36
45
|
{ name: 'responseMetadata', type: 'jsonb', defaultValue: {} },
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { defineCollection } from '@nocobase/database';
|
|
2
|
+
|
|
3
|
+
export default defineCollection({
|
|
4
|
+
name: 'aiApiUserQuotaBuckets',
|
|
5
|
+
autoGenId: true,
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: 'policyId', type: 'bigInt', allowNull: false, index: true },
|
|
8
|
+
{ name: 'userId', type: 'bigInt', allowNull: false, index: true },
|
|
9
|
+
{ name: 'periodStart', type: 'datetimeTz', allowNull: false, index: true },
|
|
10
|
+
{ name: 'periodEnd', type: 'datetimeTz', allowNull: false, index: true },
|
|
11
|
+
{ name: 'requestCount', type: 'bigInt', allowNull: false, defaultValue: 0 },
|
|
12
|
+
{ name: 'totalTokens', type: 'bigInt', allowNull: false, defaultValue: 0 },
|
|
13
|
+
{ name: 'cost', type: 'decimal', precision: 20, scale: 8, allowNull: false, defaultValue: 0 },
|
|
14
|
+
{ name: 'reservedRequests', type: 'bigInt', allowNull: false, defaultValue: 0 },
|
|
15
|
+
{ name: 'reservedTokens', type: 'bigInt', allowNull: false, defaultValue: 0 },
|
|
16
|
+
{ name: 'reservedCost', type: 'decimal', precision: 20, scale: 8, allowNull: false, defaultValue: 0 },
|
|
17
|
+
],
|
|
18
|
+
indexes: [
|
|
19
|
+
{
|
|
20
|
+
fields: ['policyId', 'periodStart'],
|
|
21
|
+
unique: true,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineCollection } from '@nocobase/database';
|
|
2
|
+
|
|
3
|
+
export default defineCollection({
|
|
4
|
+
name: 'aiApiUserQuotaPolicies',
|
|
5
|
+
autoGenId: true,
|
|
6
|
+
fields: [
|
|
7
|
+
{ name: 'userId', type: 'bigInt', allowNull: false, index: true },
|
|
8
|
+
{
|
|
9
|
+
name: 'user',
|
|
10
|
+
type: 'belongsTo',
|
|
11
|
+
target: 'users',
|
|
12
|
+
targetKey: 'id',
|
|
13
|
+
foreignKey: 'userId',
|
|
14
|
+
constraints: false,
|
|
15
|
+
},
|
|
16
|
+
{ name: 'enabled', type: 'boolean', defaultValue: true, index: true },
|
|
17
|
+
{ name: 'periodType', type: 'string', allowNull: false, defaultValue: 'monthly' },
|
|
18
|
+
{ name: 'timezone', type: 'string', allowNull: false, defaultValue: 'UTC' },
|
|
19
|
+
{ name: 'requestLimit', type: 'bigInt', allowNull: true },
|
|
20
|
+
{ name: 'totalTokenLimit', type: 'bigInt', allowNull: true },
|
|
21
|
+
{ name: 'costLimit', type: 'decimal', precision: 20, scale: 8, allowNull: true },
|
|
22
|
+
{ name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
|
|
23
|
+
{ name: 'rejectUnpricedModel', type: 'boolean', defaultValue: true },
|
|
24
|
+
{ name: 'missingUsageBehavior', type: 'string', allowNull: false, defaultValue: 'use_reserved' },
|
|
25
|
+
],
|
|
26
|
+
indexes: [
|
|
27
|
+
{
|
|
28
|
+
fields: ['userId'],
|
|
29
|
+
unique: true,
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
});
|
package/src/server/plugin.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
import { Plugin } from '@nocobase/server';
|
|
11
11
|
import { createAiLlmRouter } from './routes/router';
|
|
12
12
|
import aiApiConfigResource from './resource/ai-api-config';
|
|
13
|
+
import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
|
|
13
14
|
import { RateLimiter } from './utils/rate-limiter';
|
|
14
15
|
import { invalidateRolePermissionCache } from './middleware/role-permission';
|
|
16
|
+
import { validateModelPrice, validateQuotaPolicy } from './validation';
|
|
15
17
|
|
|
16
18
|
// Ensure dayjs timezone + utc plugins are loaded.
|
|
17
19
|
// Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
|
|
@@ -37,7 +39,14 @@ export class PluginAiApiServer extends Plugin {
|
|
|
37
39
|
|
|
38
40
|
async afterAdd() {}
|
|
39
41
|
|
|
40
|
-
async beforeLoad() {
|
|
42
|
+
async beforeLoad() {
|
|
43
|
+
this.app.db.on('aiApiModelPrices.beforeSave', async (model) => {
|
|
44
|
+
await validateModelPrice(this.db, model);
|
|
45
|
+
});
|
|
46
|
+
this.app.db.on('aiApiUserQuotaPolicies.beforeSave', (model) => {
|
|
47
|
+
validateQuotaPolicy(model);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
41
50
|
|
|
42
51
|
async load() {
|
|
43
52
|
// 1. Register raw Koa middleware for OpenAI-compatible endpoints
|
|
@@ -48,6 +57,7 @@ export class PluginAiApiServer extends Plugin {
|
|
|
48
57
|
|
|
49
58
|
// 2. Register admin config resource
|
|
50
59
|
this.app.resourceManager.define(aiApiConfigResource);
|
|
60
|
+
this.app.resourceManager.define(aiApiUsageMonitorResource);
|
|
51
61
|
|
|
52
62
|
this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
|
|
53
63
|
invalidateRolePermissionCache(model.get('roleName'));
|
|
@@ -59,7 +69,17 @@ export class PluginAiApiServer extends Plugin {
|
|
|
59
69
|
// 3. Set ACL permissions for admin config + role permissions management
|
|
60
70
|
this.app.acl.registerSnippet({
|
|
61
71
|
name: `pm.${this.name}.configuration`,
|
|
62
|
-
actions: [
|
|
72
|
+
actions: [
|
|
73
|
+
'aiApiConfig:*',
|
|
74
|
+
'aiApiRolePermissions:*',
|
|
75
|
+
'aiApiModelPrices:*',
|
|
76
|
+
'aiApiUserQuotaPolicies:*',
|
|
77
|
+
'aiApiUserQuotaBuckets:list',
|
|
78
|
+
'aiApiUserQuotaBuckets:get',
|
|
79
|
+
'aiApiUsageRecords:list',
|
|
80
|
+
'aiApiUsageRecords:get',
|
|
81
|
+
'aiApiUsageMonitor:summary',
|
|
82
|
+
],
|
|
63
83
|
});
|
|
64
84
|
|
|
65
85
|
// 4. GC the rate limiter every 5 minutes to evict stale user entries.
|
|
@@ -77,6 +97,8 @@ export class PluginAiApiServer extends Plugin {
|
|
|
77
97
|
defaultAiEmployee: '',
|
|
78
98
|
enabledLlmServices: [],
|
|
79
99
|
rateLimitPerMinute: 60,
|
|
100
|
+
quotaEnabled: false,
|
|
101
|
+
defaultReservationOutputTokens: 4096,
|
|
80
102
|
},
|
|
81
103
|
});
|
|
82
104
|
}
|
|
@@ -1,74 +1,82 @@
|
|
|
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 { ResourceOptions } from '@nocobase/resourcer';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Resource for managing AI API configuration via NocoBase admin UI.
|
|
14
|
-
* Singleton config pattern (same as aiSettings in plugin-ai).
|
|
15
|
-
*
|
|
16
|
-
* Uses custom action name 'save' instead of 'update' because NocoBase's
|
|
17
|
-
* built-in middleware requires filter/filterByTk for the standard 'update' action.
|
|
18
|
-
*/
|
|
19
|
-
const aiApiConfigResource: ResourceOptions = {
|
|
20
|
-
name: 'aiApiConfig',
|
|
21
|
-
actions: {
|
|
22
|
-
async get(ctx, next) {
|
|
23
|
-
let config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
24
|
-
if (!config) {
|
|
25
|
-
config = await ctx.db.getRepository('aiApiConfig').create({
|
|
26
|
-
values: {
|
|
27
|
-
mode: 'llm',
|
|
28
|
-
defaultAiEmployee: '',
|
|
29
|
-
defaultLlmService: '',
|
|
30
|
-
enabledLlmServices: [],
|
|
31
|
-
rateLimitPerMinute: 60,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (values.
|
|
63
|
-
if (values.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 { ResourceOptions } from '@nocobase/resourcer';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resource for managing AI API configuration via NocoBase admin UI.
|
|
14
|
+
* Singleton config pattern (same as aiSettings in plugin-ai).
|
|
15
|
+
*
|
|
16
|
+
* Uses custom action name 'save' instead of 'update' because NocoBase's
|
|
17
|
+
* built-in middleware requires filter/filterByTk for the standard 'update' action.
|
|
18
|
+
*/
|
|
19
|
+
const aiApiConfigResource: ResourceOptions = {
|
|
20
|
+
name: 'aiApiConfig',
|
|
21
|
+
actions: {
|
|
22
|
+
async get(ctx, next) {
|
|
23
|
+
let config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
24
|
+
if (!config) {
|
|
25
|
+
config = await ctx.db.getRepository('aiApiConfig').create({
|
|
26
|
+
values: {
|
|
27
|
+
mode: 'llm',
|
|
28
|
+
defaultAiEmployee: '',
|
|
29
|
+
defaultLlmService: '',
|
|
30
|
+
enabledLlmServices: [],
|
|
31
|
+
rateLimitPerMinute: 60,
|
|
32
|
+
quotaEnabled: false,
|
|
33
|
+
defaultReservationOutputTokens: 4096,
|
|
34
|
+
options: {},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
ctx.body = config;
|
|
39
|
+
await next();
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
async save(ctx, next) {
|
|
43
|
+
const values = ctx.action.params.values || (ctx.request.body as any) || {};
|
|
44
|
+
const repo = ctx.db.getRepository('aiApiConfig');
|
|
45
|
+
let config = await repo.findOne();
|
|
46
|
+
|
|
47
|
+
if (!config) {
|
|
48
|
+
config = await repo.create({
|
|
49
|
+
values: {
|
|
50
|
+
mode: values.mode ?? 'llm',
|
|
51
|
+
defaultAiEmployee: values.defaultAiEmployee ?? '',
|
|
52
|
+
defaultLlmService: values.defaultLlmService ?? '',
|
|
53
|
+
enabledLlmServices: values.enabledLlmServices ?? [],
|
|
54
|
+
rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
|
|
55
|
+
quotaEnabled: values.quotaEnabled ?? false,
|
|
56
|
+
defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
|
|
57
|
+
options: values.options ?? {},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
const updateData: Record<string, any> = {};
|
|
62
|
+
if (values.mode !== undefined) updateData.mode = values.mode;
|
|
63
|
+
if (values.defaultAiEmployee !== undefined) updateData.defaultAiEmployee = values.defaultAiEmployee;
|
|
64
|
+
if (values.defaultLlmService !== undefined) updateData.defaultLlmService = values.defaultLlmService;
|
|
65
|
+
if (values.enabledLlmServices !== undefined) updateData.enabledLlmServices = values.enabledLlmServices;
|
|
66
|
+
if (values.rateLimitPerMinute !== undefined) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
|
|
67
|
+
if (values.quotaEnabled !== undefined) updateData.quotaEnabled = values.quotaEnabled;
|
|
68
|
+
if (values.defaultReservationOutputTokens !== undefined) {
|
|
69
|
+
updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
|
|
70
|
+
}
|
|
71
|
+
if (values.options !== undefined) updateData.options = values.options;
|
|
72
|
+
|
|
73
|
+
await config.update(updateData);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ctx.body = config;
|
|
77
|
+
await next();
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default aiApiConfigResource;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Context } from '@nocobase/actions';
|
|
2
|
+
import type { ResourceOptions } from '@nocobase/resourcer';
|
|
3
|
+
import { Op, col, fn } from 'sequelize';
|
|
4
|
+
|
|
5
|
+
interface UsageSummaryRow {
|
|
6
|
+
requestCount?: string | number;
|
|
7
|
+
inputTokens?: string | number;
|
|
8
|
+
outputTokens?: string | number;
|
|
9
|
+
totalTokens?: string | number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CostSummaryRow {
|
|
13
|
+
currency?: string;
|
|
14
|
+
totalCost?: string | number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildWhere(ctx: Context) {
|
|
18
|
+
const params = ctx.action.params;
|
|
19
|
+
const where: Record<string, unknown> = {};
|
|
20
|
+
const start = typeof params.start === 'string' ? new Date(params.start) : undefined;
|
|
21
|
+
const end = typeof params.end === 'string' ? new Date(params.end) : undefined;
|
|
22
|
+
|
|
23
|
+
if ((start && !Number.isNaN(start.getTime())) || (end && !Number.isNaN(end.getTime()))) {
|
|
24
|
+
const startedAt: Record<symbol, Date> = {};
|
|
25
|
+
if (start && !Number.isNaN(start.getTime())) startedAt[Op.gte] = start;
|
|
26
|
+
if (end && !Number.isNaN(end.getTime())) startedAt[Op.lte] = end;
|
|
27
|
+
where.startedAt = startedAt;
|
|
28
|
+
}
|
|
29
|
+
if (params.userId !== undefined && params.userId !== '') where.userId = params.userId;
|
|
30
|
+
if (params.resolvedService) where.resolvedService = params.resolvedService;
|
|
31
|
+
if (params.resolvedModel) where.resolvedModel = params.resolvedModel;
|
|
32
|
+
if (params.status) where.status = params.status;
|
|
33
|
+
return where;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const aiApiUsageMonitorResource: ResourceOptions = {
|
|
37
|
+
name: 'aiApiUsageMonitor',
|
|
38
|
+
actions: {
|
|
39
|
+
async summary(ctx, next) {
|
|
40
|
+
const model = ctx.db.getCollection('aiApiUsageRecords').model;
|
|
41
|
+
const where = buildWhere(ctx);
|
|
42
|
+
const totals = (await model.findOne({
|
|
43
|
+
attributes: [
|
|
44
|
+
[fn('COUNT', col('id')), 'requestCount'],
|
|
45
|
+
[fn('COALESCE', fn('SUM', col('inputTokens')), 0), 'inputTokens'],
|
|
46
|
+
[fn('COALESCE', fn('SUM', col('outputTokens')), 0), 'outputTokens'],
|
|
47
|
+
[fn('COALESCE', fn('SUM', col('totalTokens')), 0), 'totalTokens'],
|
|
48
|
+
],
|
|
49
|
+
where,
|
|
50
|
+
raw: true,
|
|
51
|
+
})) as unknown as UsageSummaryRow;
|
|
52
|
+
const costs = (await model.findAll({
|
|
53
|
+
attributes: ['currency', [fn('COALESCE', fn('SUM', col('estimatedCost')), 0), 'totalCost']],
|
|
54
|
+
where: { ...where, estimatedCost: { [Op.ne]: null } },
|
|
55
|
+
group: ['currency'],
|
|
56
|
+
raw: true,
|
|
57
|
+
})) as unknown as CostSummaryRow[];
|
|
58
|
+
|
|
59
|
+
ctx.body = {
|
|
60
|
+
requestCount: Number(totals?.requestCount ?? 0),
|
|
61
|
+
inputTokens: Number(totals?.inputTokens ?? 0),
|
|
62
|
+
outputTokens: Number(totals?.outputTokens ?? 0),
|
|
63
|
+
totalTokens: Number(totals?.totalTokens ?? 0),
|
|
64
|
+
costsByCurrency: costs.map((item) => ({
|
|
65
|
+
currency: item.currency || 'USD',
|
|
66
|
+
totalCost: String(item.totalCost ?? 0),
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
await next();
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default aiApiUsageMonitorResource;
|
|
@@ -23,6 +23,7 @@ import { createRequestAbortController, isStreamingRequested, writeResponse } fro
|
|
|
23
23
|
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
24
24
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
25
25
|
import type PluginAiApiServer from '../plugin';
|
|
26
|
+
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* POST /api/ai-llm/v1/chat/completions
|
|
@@ -121,6 +122,8 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
121
122
|
return;
|
|
122
123
|
}
|
|
123
124
|
|
|
125
|
+
await prepareLlmBilling(ctx, resolved);
|
|
126
|
+
|
|
124
127
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
125
128
|
const modelOptions: Record<string, unknown> = {
|
|
126
129
|
model: modelId,
|
|
@@ -197,6 +200,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
197
200
|
const baseModel = provider.createModel();
|
|
198
201
|
applyProviderRequestParameters(baseModel, providerRequestParameters);
|
|
199
202
|
const chatModel = bindRequestTools(baseModel, body.tools, body.tool_choice, providerRequestParameters);
|
|
203
|
+
markLlmProviderAttempted(ctx);
|
|
200
204
|
|
|
201
205
|
if (stream) {
|
|
202
206
|
// ─── Streaming mode ───
|
|
@@ -222,8 +226,15 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
222
226
|
} catch (err) {
|
|
223
227
|
ctx.log.error('AI API chat completions error:', err);
|
|
224
228
|
if (!ctx.res.headersSent) {
|
|
225
|
-
|
|
226
|
-
ctx.
|
|
229
|
+
const isQuotaError = err instanceof AiApiQuotaError;
|
|
230
|
+
ctx.status = isQuotaError ? 429 : 500;
|
|
231
|
+
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
232
|
+
ctx.body = toOpenAIError(
|
|
233
|
+
ctx.status,
|
|
234
|
+
getErrorMessage(err, 'Internal server error'),
|
|
235
|
+
isQuotaError ? 'quota_error' : 'server_error',
|
|
236
|
+
isQuotaError ? err.code : undefined,
|
|
237
|
+
);
|
|
227
238
|
}
|
|
228
239
|
}
|
|
229
240
|
}
|
|
@@ -13,6 +13,7 @@ import { resolveModelString } from '../utils/resolve-service';
|
|
|
13
13
|
import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
|
|
14
14
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
15
15
|
import type PluginAiApiServer from '../plugin';
|
|
16
|
+
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* POST /api/ai-llm/v1/completions
|
|
@@ -112,6 +113,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
await prepareLlmBilling(ctx, resolved);
|
|
117
|
+
|
|
115
118
|
const modelOptions: Record<string, any> = {
|
|
116
119
|
model: modelId,
|
|
117
120
|
llmService: service.name,
|
|
@@ -154,6 +157,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
154
157
|
|
|
155
158
|
const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
|
|
156
159
|
const chatModel = provider.createModel();
|
|
160
|
+
markLlmProviderAttempted(ctx);
|
|
157
161
|
|
|
158
162
|
if (stream) {
|
|
159
163
|
await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
@@ -163,8 +167,15 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
163
167
|
} catch (err) {
|
|
164
168
|
ctx.log.error('AI API completions error:', err);
|
|
165
169
|
if (!ctx.res.headersSent) {
|
|
166
|
-
|
|
167
|
-
ctx.
|
|
170
|
+
const isQuotaError = err instanceof AiApiQuotaError;
|
|
171
|
+
ctx.status = isQuotaError ? 429 : 500;
|
|
172
|
+
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
173
|
+
ctx.body = toOpenAIError(
|
|
174
|
+
ctx.status,
|
|
175
|
+
getErrorMessage(err, 'Internal server error'),
|
|
176
|
+
isQuotaError ? 'quota_error' : 'server_error',
|
|
177
|
+
isQuotaError ? err.code : undefined,
|
|
178
|
+
);
|
|
168
179
|
}
|
|
169
180
|
}
|
|
170
181
|
}
|
|
@@ -21,6 +21,7 @@ import { checkRolePermission } from '../middleware/role-permission';
|
|
|
21
21
|
import { startUsageRecord, finishUsageRecord } from '../usage';
|
|
22
22
|
import { isStreamingRequested } from '../utils/streaming';
|
|
23
23
|
import type PluginAiApiServer from '../plugin';
|
|
24
|
+
import { finalizeLlmBilling } from '../billing';
|
|
24
25
|
|
|
25
26
|
const API_PREFIX = '/api/ai-llm/v1';
|
|
26
27
|
|
|
@@ -73,7 +74,10 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
73
74
|
ctx.set('Access-Control-Allow-Origin', '*');
|
|
74
75
|
ctx.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
75
76
|
ctx.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-AI-Mode, X-Timezone, X-Locale');
|
|
76
|
-
ctx.set(
|
|
77
|
+
ctx.set(
|
|
78
|
+
'Access-Control-Expose-Headers',
|
|
79
|
+
'X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reason, Retry-After',
|
|
80
|
+
);
|
|
77
81
|
ctx.set('Access-Control-Max-Age', '86400');
|
|
78
82
|
|
|
79
83
|
// ─── OPTIONS preflight — return immediately after CORS headers ────────
|
|
@@ -259,6 +263,17 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
|
|
|
259
263
|
} catch (usageError) {
|
|
260
264
|
ctx.log.error('AI API usage record could not be finalized:', usageError);
|
|
261
265
|
}
|
|
266
|
+
} else if (ctx.state.aiApiLlmBilling) {
|
|
267
|
+
try {
|
|
268
|
+
const usageResult = ctx.state.aiApiUsageResult;
|
|
269
|
+
const providerUsage = usageResult?.source === 'provider' ? usageResult.usage : undefined;
|
|
270
|
+
const succeeded = ctx.state.aiApiStreamResult
|
|
271
|
+
? ctx.state.aiApiStreamResult.succeeded
|
|
272
|
+
: ctx.status >= 200 && ctx.status < 400;
|
|
273
|
+
await finalizeLlmBilling(ctx, providerUsage, succeeded);
|
|
274
|
+
} catch (billingError) {
|
|
275
|
+
ctx.log.error('AI API quota reservation could not be finalized:', billingError);
|
|
276
|
+
}
|
|
262
277
|
}
|
|
263
278
|
}
|
|
264
279
|
};
|