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
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Migration } from '@nocobase/server';
|
|
2
|
+
|
|
3
|
+
export default class AddPromptCacheTokensToUsageRecords extends Migration {
|
|
4
|
+
on = 'beforeLoad' as const;
|
|
5
|
+
|
|
6
|
+
async up() {
|
|
7
|
+
const collection = this.db.getCollection('aiApiUsageRecords');
|
|
8
|
+
if (!collection) return;
|
|
9
|
+
|
|
10
|
+
const field = collection.getField('promptCacheTokens');
|
|
11
|
+
if (!field) {
|
|
12
|
+
collection.addField('promptCacheTokens', { type: 'integer', allowNull: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (await collection.existsInDb()) {
|
|
16
|
+
const tableName = collection.getTableNameWithSchema();
|
|
17
|
+
const exists = await this.tableColumnExists(tableName, 'promptCacheTokens');
|
|
18
|
+
if (!exists) {
|
|
19
|
+
await this.queryInterface.addColumn(tableName, 'promptCacheTokens', {
|
|
20
|
+
type: this.db.sequelize.getDialect() === 'sqlite' ? 'INTEGER' : 'INTEGER',
|
|
21
|
+
allowNull: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async down() {
|
|
28
|
+
const collection = this.db.getCollection('aiApiUsageRecords');
|
|
29
|
+
if (!collection) return;
|
|
30
|
+
|
|
31
|
+
if (await collection.existsInDb()) {
|
|
32
|
+
const tableName = collection.getTableNameWithSchema();
|
|
33
|
+
const exists = await this.tableColumnExists(tableName, 'promptCacheTokens');
|
|
34
|
+
if (exists) {
|
|
35
|
+
await this.queryInterface.removeColumn(tableName, 'promptCacheTokens');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
collection.removeField('promptCacheTokens');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async tableColumnExists(tableName: string, columnName: string): Promise<boolean> {
|
|
43
|
+
const columns = await this.queryInterface.describeTable(tableName);
|
|
44
|
+
return Object.prototype.hasOwnProperty.call(columns, columnName);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Migration } from '@nocobase/server';
|
|
11
|
+
|
|
12
|
+
interface PermissionSignature {
|
|
13
|
+
allowedLlmServices: string[];
|
|
14
|
+
allowAllModels: boolean;
|
|
15
|
+
allowedModels: string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Model access moved from per-user rows (aiApiUserPermissions) to usage groups.
|
|
20
|
+
* Users with identical grants share one migrated group; quota settings are copied
|
|
21
|
+
* from the default group so migrated users keep the same quota behaviour.
|
|
22
|
+
*
|
|
23
|
+
* The old per-user semantics cannot be fully expressed on groups: an empty grant
|
|
24
|
+
* meant "deny all". Rows that denied everything (enabled=false, no services, or
|
|
25
|
+
* allowAllModels=false with no models) are left unmigrated — those users inherit
|
|
26
|
+
* the default group's open access and a warning is logged.
|
|
27
|
+
*/
|
|
28
|
+
export default class MigrateUserPermissionsToGroups extends Migration {
|
|
29
|
+
// Must run afterSync: the new allowedLlmServices/allowAllModels/allowedModels
|
|
30
|
+
// columns on aiApiUsageGroups only exist once collections have been synced.
|
|
31
|
+
on = 'afterSync' as const;
|
|
32
|
+
|
|
33
|
+
async up() {
|
|
34
|
+
const collection = this.db.getCollection('aiApiUserPermissions');
|
|
35
|
+
if (!collection || !(await collection.existsInDb())) return;
|
|
36
|
+
|
|
37
|
+
const repository = this.db.getRepository('aiApiUserPermissions');
|
|
38
|
+
const rows: unknown[] = [];
|
|
39
|
+
for (let page = 1; ; page += 1) {
|
|
40
|
+
const batch = (await repository.find({ page, pageSize: 1000, sort: 'id' })) as unknown[];
|
|
41
|
+
rows.push(...batch);
|
|
42
|
+
if (batch.length < 1000) break;
|
|
43
|
+
}
|
|
44
|
+
if (!rows.length) return;
|
|
45
|
+
|
|
46
|
+
const migratable: { userId: unknown; sig: PermissionSignature }[] = [];
|
|
47
|
+
const deniedUserIds: unknown[] = [];
|
|
48
|
+
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
const userId = this.valueOf(row, 'userId');
|
|
51
|
+
if (userId === undefined || userId === null) continue;
|
|
52
|
+
|
|
53
|
+
const enabled = this.valueOf(row, 'enabled') !== false;
|
|
54
|
+
const allowedLlmServices = this.stringArray(this.valueOf(row, 'allowedLlmServices'));
|
|
55
|
+
const allowAllModels = this.valueOf(row, 'allowAllModels') !== false;
|
|
56
|
+
const allowedModels = this.stringArray(this.valueOf(row, 'allowedModels'));
|
|
57
|
+
|
|
58
|
+
// Old semantics: no services = deny all; allowAllModels=false with no models = deny all.
|
|
59
|
+
const hasEffectiveGrant = allowedLlmServices.length > 0 && (allowAllModels || allowedModels.length > 0);
|
|
60
|
+
if (!enabled || !hasEffectiveGrant) {
|
|
61
|
+
deniedUserIds.push(userId);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
migratable.push({ userId, sig: { allowedLlmServices, allowAllModels, allowedModels } });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (deniedUserIds.length) {
|
|
68
|
+
this.app.logger.warn(
|
|
69
|
+
`[ai-api] ${deniedUserIds.length} aiApiUserPermissions row(s) denied all access ` +
|
|
70
|
+
`(enabled=false or empty grant) and cannot be migrated to usage groups. These users now ` +
|
|
71
|
+
`inherit the default group's access. Affected userIds: ${deniedUserIds.join(', ')}. ` +
|
|
72
|
+
`Use role permissions to block API access entirely.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!migratable.length) return;
|
|
77
|
+
|
|
78
|
+
const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
|
|
79
|
+
filter: { isDefault: true },
|
|
80
|
+
});
|
|
81
|
+
const quotaValues = this.quotaValuesFrom(defaultGroup);
|
|
82
|
+
|
|
83
|
+
// Group rows by access signature so users with identical grants share one group.
|
|
84
|
+
const bySignature = new Map<string, { sig: PermissionSignature; userIds: unknown[] }>();
|
|
85
|
+
for (const { userId, sig } of migratable) {
|
|
86
|
+
const key = JSON.stringify({
|
|
87
|
+
allowedLlmServices: [...sig.allowedLlmServices].sort(),
|
|
88
|
+
allowAllModels: sig.allowAllModels,
|
|
89
|
+
allowedModels: [...sig.allowedModels].sort(),
|
|
90
|
+
});
|
|
91
|
+
const entry = bySignature.get(key);
|
|
92
|
+
if (entry) {
|
|
93
|
+
entry.userIds.push(userId);
|
|
94
|
+
} else {
|
|
95
|
+
bySignature.set(key, { sig, userIds: [userId] });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let index = 0;
|
|
100
|
+
for (const { sig, userIds } of bySignature.values()) {
|
|
101
|
+
index += 1;
|
|
102
|
+
const group = await this.db.getRepository('aiApiUsageGroups').create({
|
|
103
|
+
values: {
|
|
104
|
+
...quotaValues,
|
|
105
|
+
name: `Migrated permissions ${index}`,
|
|
106
|
+
isDefault: false,
|
|
107
|
+
quotaMode: 'per_user',
|
|
108
|
+
allowedLlmServices: sig.allowedLlmServices,
|
|
109
|
+
allowAllModels: sig.allowAllModels,
|
|
110
|
+
allowedModels: sig.allowedModels,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const groupId = this.valueOf(group, 'id');
|
|
114
|
+
|
|
115
|
+
for (const userId of userIds) {
|
|
116
|
+
const existing = await this.db.getRepository('aiApiGroupMembers').findOne({ filter: { userId } });
|
|
117
|
+
if (existing) {
|
|
118
|
+
await this.db.getRepository('aiApiGroupMembers').update({
|
|
119
|
+
filterByTk: this.valueOf(existing, 'id'),
|
|
120
|
+
values: { groupId },
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
await this.db.getRepository('aiApiGroupMembers').create({ values: { groupId, userId } });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.app.logger.info(
|
|
129
|
+
`[ai-api] Migrated ${migratable.length} aiApiUserPermissions row(s) into ${bySignature.size} usage group(s).`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async down() {
|
|
134
|
+
// The legacy rows are never modified, so rolling back only means removing the
|
|
135
|
+
// migrated groups and their memberships.
|
|
136
|
+
const groups = (await this.db.getRepository('aiApiUsageGroups').find({
|
|
137
|
+
filter: { 'name.$startsWith': 'Migrated permissions' },
|
|
138
|
+
})) as unknown[];
|
|
139
|
+
for (const group of groups) {
|
|
140
|
+
const groupId = this.valueOf(group, 'id');
|
|
141
|
+
await this.db.getRepository('aiApiGroupMembers').destroy({ filter: { groupId } });
|
|
142
|
+
await this.db.getRepository('aiApiUsageGroups').destroy({ filterByTk: groupId });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private quotaValuesFrom(defaultGroup: unknown): Record<string, unknown> {
|
|
147
|
+
if (!defaultGroup) {
|
|
148
|
+
return {
|
|
149
|
+
rateLimitPerMinute: 60,
|
|
150
|
+
enabled: false,
|
|
151
|
+
periodType: 'monthly',
|
|
152
|
+
timezone: 'UTC',
|
|
153
|
+
requestLimit: null,
|
|
154
|
+
totalTokenLimit: null,
|
|
155
|
+
costLimit: null,
|
|
156
|
+
currency: 'USD',
|
|
157
|
+
rejectUnpricedModel: true,
|
|
158
|
+
missingUsageBehavior: 'use_reserved',
|
|
159
|
+
contextOverflowBehavior: 'reject',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
rateLimitPerMinute: Number(this.valueOf(defaultGroup, 'rateLimitPerMinute') ?? 60),
|
|
164
|
+
enabled: !!this.valueOf(defaultGroup, 'enabled'),
|
|
165
|
+
periodType: this.valueOf(defaultGroup, 'periodType') ?? 'monthly',
|
|
166
|
+
timezone: this.valueOf(defaultGroup, 'timezone') ?? 'UTC',
|
|
167
|
+
requestLimit: this.valueOf(defaultGroup, 'requestLimit') ?? null,
|
|
168
|
+
totalTokenLimit: this.valueOf(defaultGroup, 'totalTokenLimit') ?? null,
|
|
169
|
+
costLimit: this.valueOf(defaultGroup, 'costLimit') ?? null,
|
|
170
|
+
currency: this.valueOf(defaultGroup, 'currency') ?? 'USD',
|
|
171
|
+
rejectUnpricedModel: !!this.valueOf(defaultGroup, 'rejectUnpricedModel'),
|
|
172
|
+
missingUsageBehavior: this.valueOf(defaultGroup, 'missingUsageBehavior') ?? 'use_reserved',
|
|
173
|
+
contextOverflowBehavior: this.valueOf(defaultGroup, 'contextOverflowBehavior') ?? 'reject',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private stringArray(value: unknown): string[] {
|
|
178
|
+
return Array.isArray(value)
|
|
179
|
+
? value.filter((item): item is string => typeof item === 'string' && item.length > 0)
|
|
180
|
+
: [];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private valueOf(model: unknown, name: string): unknown {
|
|
184
|
+
if (!model) return undefined;
|
|
185
|
+
if (typeof (model as Record<string, unknown>).get === 'function') {
|
|
186
|
+
return (model as Record<string, unknown>).get(name);
|
|
187
|
+
}
|
|
188
|
+
return (model as Record<string, unknown>)[name];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Migration } from '@nocobase/server';
|
|
2
|
+
|
|
3
|
+
export default class AddSystemPromptToModelMetadata extends Migration {
|
|
4
|
+
on = 'beforeLoad' as const;
|
|
5
|
+
|
|
6
|
+
async up() {
|
|
7
|
+
const collection = this.db.getCollection('aiApiModelMetadata');
|
|
8
|
+
if (!collection) return;
|
|
9
|
+
|
|
10
|
+
const field = collection.getField('systemPrompt');
|
|
11
|
+
if (!field) {
|
|
12
|
+
collection.addField('systemPrompt', { type: 'text', allowNull: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (await collection.existsInDb()) {
|
|
16
|
+
const tableName = collection.getTableNameWithSchema();
|
|
17
|
+
const exists = await this.tableColumnExists(tableName, 'systemPrompt');
|
|
18
|
+
if (!exists) {
|
|
19
|
+
await this.queryInterface.addColumn(tableName, 'systemPrompt', {
|
|
20
|
+
type: 'TEXT',
|
|
21
|
+
allowNull: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async down() {
|
|
28
|
+
const collection = this.db.getCollection('aiApiModelMetadata');
|
|
29
|
+
if (!collection) return;
|
|
30
|
+
|
|
31
|
+
if (await collection.existsInDb()) {
|
|
32
|
+
const tableName = collection.getTableNameWithSchema();
|
|
33
|
+
const exists = await this.tableColumnExists(tableName, 'systemPrompt');
|
|
34
|
+
if (exists) {
|
|
35
|
+
await this.queryInterface.removeColumn(tableName, 'systemPrompt');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
collection.removeField('systemPrompt');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private async tableColumnExists(tableName: string, columnName: string): Promise<boolean> {
|
|
43
|
+
const columns = await this.queryInterface.describeTable(tableName);
|
|
44
|
+
return Object.prototype.hasOwnProperty.call(columns, columnName);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/server/plugin.ts
CHANGED
|
@@ -12,12 +12,18 @@ import type { Transactionable } from '@nocobase/database';
|
|
|
12
12
|
import { createAiLlmRouter, AI_LLM_PREFIX } from './routes/router';
|
|
13
13
|
import aiApiConfigResource from './resource/ai-api-config';
|
|
14
14
|
import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
|
|
15
|
-
import
|
|
15
|
+
import aiApiUsageGroupsResource from './resource/ai-api-usage-groups';
|
|
16
16
|
import { RateLimiter } from './utils/rate-limiter';
|
|
17
17
|
import { invalidateRolePermissionCache } from './middleware/role-permission';
|
|
18
|
-
import {
|
|
18
|
+
import { invalidateGroupAccessCache } from './utils/user-permissions';
|
|
19
19
|
import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
|
|
20
|
-
import { AI_API_ACL_SNIPPET
|
|
20
|
+
import { AI_API_ACL_SNIPPET } from '../constants';
|
|
21
|
+
import {
|
|
22
|
+
FileProcessorService,
|
|
23
|
+
base64FileForwarder,
|
|
24
|
+
httpFileUrlFetcher,
|
|
25
|
+
pdfFileProcessor,
|
|
26
|
+
} from './services/file-processor';
|
|
21
27
|
|
|
22
28
|
// Ensure dayjs timezone + utc plugins are loaded.
|
|
23
29
|
// Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
|
|
@@ -35,10 +41,16 @@ import timezonePlugin from 'dayjs/plugin/timezone';
|
|
|
35
41
|
export class PluginAiApiServer extends Plugin {
|
|
36
42
|
/**
|
|
37
43
|
* Singleton rate limiter — lives for the entire plugin lifetime, shared across all requests.
|
|
38
|
-
* Uses a 1-minute sliding window to enforce rateLimitPerMinute from
|
|
44
|
+
* Uses a 1-minute sliding window to enforce rateLimitPerMinute from the user's usage group.
|
|
39
45
|
*/
|
|
40
46
|
rateLimiter = new RateLimiter(60_000);
|
|
41
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Extensible file processor service. Other plugins can register custom processors
|
|
50
|
+
* to transform file/file_url content blocks before they reach the LLM.
|
|
51
|
+
*/
|
|
52
|
+
fileProcessorService = new FileProcessorService();
|
|
53
|
+
|
|
42
54
|
private gcInterval: NodeJS.Timeout | null = null;
|
|
43
55
|
|
|
44
56
|
async afterAdd() {}
|
|
@@ -50,12 +62,71 @@ export class PluginAiApiServer extends Plugin {
|
|
|
50
62
|
this.app.db.on('aiApiModelMetadata.beforeSave', (model) => {
|
|
51
63
|
validateModelMetadata(model);
|
|
52
64
|
});
|
|
53
|
-
this.app.db.on('
|
|
65
|
+
this.app.db.on('aiApiUsageGroups.beforeSave', async (model, options) => {
|
|
54
66
|
validateQuotaPolicy(model);
|
|
67
|
+
// The partial unique index on isDefault is not supported on MySQL,
|
|
68
|
+
// so enforce single-default-group here as well.
|
|
69
|
+
if (model.get('isDefault')) {
|
|
70
|
+
const filter: Record<string, unknown> = { isDefault: true };
|
|
71
|
+
if (model.get('id')) filter.id = { $ne: model.get('id') };
|
|
72
|
+
const other = await this.db.getRepository('aiApiUsageGroups').findOne({
|
|
73
|
+
filter,
|
|
74
|
+
transaction: options?.transaction,
|
|
75
|
+
});
|
|
76
|
+
if (other) {
|
|
77
|
+
throw new Error('Only one default usage group is allowed.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
this.app.db.on('aiApiGroupMembers.beforeSave', async (model, options) => {
|
|
83
|
+
const userId = model.get('userId');
|
|
84
|
+
if (!userId) return;
|
|
85
|
+
const existing = await this.db.getRepository('aiApiGroupMembers').findOne({
|
|
86
|
+
filter: { userId },
|
|
87
|
+
transaction: options?.transaction,
|
|
88
|
+
});
|
|
89
|
+
if (existing && existing.get('id') !== model.get('id')) {
|
|
90
|
+
throw new Error('User already belongs to another usage group.');
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.app.db.on('aiApiUsageGroups.beforeDestroy', async (model, options) => {
|
|
95
|
+
if (model.get('isDefault')) {
|
|
96
|
+
throw new Error('The default usage group cannot be deleted.');
|
|
97
|
+
}
|
|
98
|
+
const members = await this.db.getRepository('aiApiGroupMembers').find({
|
|
99
|
+
filter: { groupId: model.get('id') },
|
|
100
|
+
transaction: options?.transaction,
|
|
101
|
+
});
|
|
102
|
+
if (members.length === 0) return;
|
|
103
|
+
|
|
104
|
+
const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
|
|
105
|
+
filter: { isDefault: true },
|
|
106
|
+
transaction: options?.transaction,
|
|
107
|
+
});
|
|
108
|
+
if (!defaultGroup) {
|
|
109
|
+
throw new Error('Default usage group is missing; cannot reassign members.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
for (const member of members) {
|
|
113
|
+
await this.db.getRepository('aiApiGroupMembers').update({
|
|
114
|
+
filterByTk: member.get('id'),
|
|
115
|
+
values: { groupId: defaultGroup.get('id') },
|
|
116
|
+
transaction: options?.transaction,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
55
119
|
});
|
|
56
120
|
}
|
|
57
121
|
|
|
58
122
|
async load() {
|
|
123
|
+
// Register default file processors. Custom plugins can register additional
|
|
124
|
+
// processors by retrieving this plugin instance and calling
|
|
125
|
+
// `fileProcessorService.register(processor)`.
|
|
126
|
+
this.fileProcessorService.register(base64FileForwarder);
|
|
127
|
+
this.fileProcessorService.register(httpFileUrlFetcher);
|
|
128
|
+
this.fileProcessorService.register(pdfFileProcessor);
|
|
129
|
+
|
|
59
130
|
// 1. Claim body parsing for our own routes before the core bodyParser runs.
|
|
60
131
|
// Core registers koa-bodyparser with a global REQUEST_BODY_LIMIT (10mb by
|
|
61
132
|
// default) much earlier in the stack, so without this the gateway's own
|
|
@@ -80,7 +151,7 @@ export class PluginAiApiServer extends Plugin {
|
|
|
80
151
|
// 2. Register admin config resource
|
|
81
152
|
this.app.resourceManager.define(aiApiConfigResource);
|
|
82
153
|
this.app.resourceManager.define(aiApiUsageMonitorResource);
|
|
83
|
-
this.app.resourceManager.define(
|
|
154
|
+
this.app.resourceManager.define(aiApiUsageGroupsResource);
|
|
84
155
|
|
|
85
156
|
this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
|
|
86
157
|
invalidateRolePermissionCache(model.get('roleName'));
|
|
@@ -89,11 +160,11 @@ export class PluginAiApiServer extends Plugin {
|
|
|
89
160
|
invalidateRolePermissionCache(model.get('roleName'));
|
|
90
161
|
});
|
|
91
162
|
|
|
92
|
-
this.app.db.on('
|
|
93
|
-
this.
|
|
163
|
+
this.app.db.on('aiApiUsageGroups.afterSave', (model, options) => {
|
|
164
|
+
this.invalidateGroupAccess(model.get('id'), options?.transaction);
|
|
94
165
|
});
|
|
95
|
-
this.app.db.on('
|
|
96
|
-
this.
|
|
166
|
+
this.app.db.on('aiApiUsageGroups.afterDestroy', (model, options) => {
|
|
167
|
+
this.invalidateGroupAccess(model.get('id'), options?.transaction);
|
|
97
168
|
});
|
|
98
169
|
|
|
99
170
|
// 3. Set ACL permissions for admin config + role permissions management
|
|
@@ -104,24 +175,16 @@ export class PluginAiApiServer extends Plugin {
|
|
|
104
175
|
'aiApiRolePermissions:*',
|
|
105
176
|
'aiApiModelPrices:*',
|
|
106
177
|
'aiApiModelMetadata:*',
|
|
107
|
-
'
|
|
108
|
-
'
|
|
109
|
-
'
|
|
178
|
+
'aiApiUsageGroups:*',
|
|
179
|
+
'aiApiGroupMembers:*',
|
|
180
|
+
'aiApiGroupQuotaBuckets:list',
|
|
181
|
+
'aiApiGroupQuotaBuckets:get',
|
|
110
182
|
'aiApiUsageRecords:list',
|
|
111
183
|
'aiApiUsageRecords:get',
|
|
112
184
|
'aiApiUsageMonitor:summary',
|
|
113
185
|
],
|
|
114
186
|
});
|
|
115
187
|
|
|
116
|
-
// Per-user LLM grants are a separate child permission: handing out model access is a
|
|
117
|
-
// stronger capability than editing gateway settings, so it ticks independently.
|
|
118
|
-
// The wildcard also covers `listUsers`, which backs the page's user picker — without it
|
|
119
|
-
// the page would depend on `pm.plugin-users` and break for a role holding only this snippet.
|
|
120
|
-
this.app.acl.registerSnippet({
|
|
121
|
-
name: AI_API_USER_PERMISSIONS_SNIPPET,
|
|
122
|
-
actions: ['aiApiUserPermissions:*'],
|
|
123
|
-
});
|
|
124
|
-
|
|
125
188
|
// 4. GC the rate limiter every 5 minutes to evict stale user entries.
|
|
126
189
|
// .unref() prevents this timer from keeping the process alive on shutdown.
|
|
127
190
|
this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
|
|
@@ -129,23 +192,23 @@ export class PluginAiApiServer extends Plugin {
|
|
|
129
192
|
}
|
|
130
193
|
|
|
131
194
|
/**
|
|
132
|
-
* Drop a
|
|
195
|
+
* Drop a group's cached model-access scope on every node.
|
|
133
196
|
*
|
|
134
197
|
* The local call is not redundant: syncMessageManager hardcodes skipSelf, so the publishing
|
|
135
198
|
* node never receives its own message. Passing the transaction defers the broadcast until
|
|
136
199
|
* the write commits, so other nodes cannot re-read the old row and re-cache it.
|
|
137
200
|
*/
|
|
138
|
-
private
|
|
139
|
-
|
|
140
|
-
this.sendSyncMessage({ type: '
|
|
201
|
+
private invalidateGroupAccess(groupId: unknown, transaction?: Transactionable['transaction']) {
|
|
202
|
+
invalidateGroupAccessCache(groupId as string | number | bigint);
|
|
203
|
+
this.sendSyncMessage({ type: 'invalidateGroupAccess', groupId }, { transaction });
|
|
141
204
|
}
|
|
142
205
|
|
|
143
206
|
/**
|
|
144
207
|
* Received only on the *other* nodes (skipSelf), so this must not re-broadcast.
|
|
145
208
|
*/
|
|
146
|
-
async handleSyncMessage(message: { type?: string;
|
|
147
|
-
if (message?.type === '
|
|
148
|
-
|
|
209
|
+
async handleSyncMessage(message: { type?: string; groupId?: unknown }) {
|
|
210
|
+
if (message?.type === 'invalidateGroupAccess') {
|
|
211
|
+
invalidateGroupAccessCache(message.groupId as string | number | bigint);
|
|
149
212
|
}
|
|
150
213
|
}
|
|
151
214
|
|
|
@@ -157,12 +220,40 @@ export class PluginAiApiServer extends Plugin {
|
|
|
157
220
|
values: {
|
|
158
221
|
defaultAiEmployee: '',
|
|
159
222
|
enabledLlmServices: [],
|
|
160
|
-
rateLimitPerMinute: 60,
|
|
161
223
|
quotaEnabled: false,
|
|
224
|
+
pdfRenderPagesAsImages: false,
|
|
162
225
|
defaultReservationOutputTokens: 4096,
|
|
163
226
|
},
|
|
164
227
|
});
|
|
165
228
|
}
|
|
229
|
+
|
|
230
|
+
// Create default usage group on first install
|
|
231
|
+
const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
|
|
232
|
+
filter: { isDefault: true },
|
|
233
|
+
});
|
|
234
|
+
if (!defaultGroup) {
|
|
235
|
+
await this.db.getRepository('aiApiUsageGroups').create({
|
|
236
|
+
values: {
|
|
237
|
+
name: 'Default',
|
|
238
|
+
isDefault: true,
|
|
239
|
+
quotaMode: 'per_user',
|
|
240
|
+
rateLimitPerMinute: 60,
|
|
241
|
+
enabled: false,
|
|
242
|
+
periodType: 'monthly',
|
|
243
|
+
timezone: 'UTC',
|
|
244
|
+
requestLimit: null,
|
|
245
|
+
totalTokenLimit: null,
|
|
246
|
+
costLimit: null,
|
|
247
|
+
currency: 'USD',
|
|
248
|
+
rejectUnpricedModel: true,
|
|
249
|
+
missingUsageBehavior: 'use_reserved',
|
|
250
|
+
contextOverflowBehavior: 'reject',
|
|
251
|
+
allowedLlmServices: [],
|
|
252
|
+
allowAllModels: true,
|
|
253
|
+
allowedModels: [],
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
166
257
|
}
|
|
167
258
|
|
|
168
259
|
async afterEnable() {}
|