plugin-ai-api 1.1.1 → 1.1.2
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/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +175 -139
- package/dist/locale/vi-VN.json +40 -2
- package/dist/locale/zh-CN.json +40 -2
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +25 -6
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +25 -4
- package/dist/server/utils/direct-llm-context.js +1 -1
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +44 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/locale/en-US.json +175 -139
- package/src/locale/vi-VN.json +40 -2
- package/src/locale/zh-CN.json +40 -2
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +14 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +32 -10
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +29 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +2 -1
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +49 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- package/src/server/resource/ai-api-usage-groups.ts +0 -171
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* Virtual model aliases (e.g. "auto") that the gateway resolves to a concrete
|
|
14
|
+
* LLM model based on the request shape and the caller's access scope.
|
|
15
|
+
*
|
|
16
|
+
* The per-capability lists are ordered: the resolver walks them top-down and
|
|
17
|
+
* picks the FIRST model the current user is permitted to use. A list left empty
|
|
18
|
+
* is derived from aiApiModelMetadata (capability flags + sortOrder). When no
|
|
19
|
+
* candidate is usable, `fallbackModel` is used when it is also permitted for
|
|
20
|
+
* the caller. An alias never widens the caller's model access.
|
|
21
|
+
*/
|
|
22
|
+
export default defineCollection({
|
|
23
|
+
name: 'aiApiVirtualModels',
|
|
24
|
+
autoGenId: true,
|
|
25
|
+
fields: [
|
|
26
|
+
{
|
|
27
|
+
name: 'name',
|
|
28
|
+
type: 'string',
|
|
29
|
+
unique: true,
|
|
30
|
+
allowNull: false,
|
|
31
|
+
comment: 'Alias the client passes as the model id, e.g. "auto".',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'mode',
|
|
35
|
+
type: 'string',
|
|
36
|
+
defaultValue: 'chat',
|
|
37
|
+
comment: 'Endpoint family this alias serves: chat | embedding.',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'fallbackModel',
|
|
41
|
+
type: 'string',
|
|
42
|
+
allowNull: false,
|
|
43
|
+
comment:
|
|
44
|
+
'Concrete "service/modelId" used when no capability bucket candidate is usable and fallback is permitted.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'visionModels',
|
|
48
|
+
type: 'json',
|
|
49
|
+
defaultValue: [],
|
|
50
|
+
comment: 'Ordered "service/modelId" list for vision requests.',
|
|
51
|
+
},
|
|
52
|
+
{ name: 'toolModels', type: 'json', defaultValue: [], comment: 'Ordered list for tool-calling requests.' },
|
|
53
|
+
{ name: 'reasoningModels', type: 'json', defaultValue: [], comment: 'Ordered list for reasoning-tier requests.' },
|
|
54
|
+
{ name: 'cheapModels', type: 'json', defaultValue: [], comment: 'Ordered list for cheap-tier requests.' },
|
|
55
|
+
{ name: 'generalModels', type: 'json', defaultValue: [], comment: 'Ordered list for general requests.' },
|
|
56
|
+
{ name: 'enabled', type: 'boolean', defaultValue: true, index: true },
|
|
57
|
+
],
|
|
58
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
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 type { Context, Next } from '@nocobase/actions';
|
|
11
|
+
|
|
12
|
+
export const RESPONSE_RECORD_COLLECTION = 'aiApiResponseRecords';
|
|
13
|
+
|
|
14
|
+
type RepositoryAwareContext = Context & {
|
|
15
|
+
getCurrentRepository?: () => { targetCollection?: { name?: string } } | null;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function routeCandidates(ctx: Context): string[] {
|
|
19
|
+
const params = ctx.action?.params;
|
|
20
|
+
const candidates = [ctx.action?.resourceName, params?.resourceName, params?.associatedName, params?.targetCollection];
|
|
21
|
+
return candidates.filter((value): value is string => typeof value === 'string').flatMap((value) => value.split('.'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function targetsResponseRecordCollection(ctx: Context): boolean {
|
|
25
|
+
const repositoryContext = ctx as RepositoryAwareContext;
|
|
26
|
+
if (repositoryContext.getCurrentRepository) {
|
|
27
|
+
try {
|
|
28
|
+
if (repositoryContext.getCurrentRepository()?.targetCollection?.name === RESPONSE_RECORD_COLLECTION) return true;
|
|
29
|
+
} catch {
|
|
30
|
+
// Some custom resources have no repository. Fall back to the resolved route fields.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return routeCandidates(ctx).includes(RESPONSE_RECORD_COLLECTION);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function blockResponseRecordResource() {
|
|
37
|
+
return async (ctx: Context, next: Next): Promise<void> => {
|
|
38
|
+
if (targetsResponseRecordCollection(ctx)) {
|
|
39
|
+
ctx.throw(404, 'Not Found');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
await next();
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -11,44 +11,80 @@ import { Context } from '@nocobase/actions';
|
|
|
11
11
|
import { toOpenAIError } from '../utils/openai-format';
|
|
12
12
|
|
|
13
13
|
const PERMISSION_TTL_MS = 15_000;
|
|
14
|
-
|
|
14
|
+
|
|
15
|
+
export interface RolePermissionRecord {
|
|
16
|
+
roleName?: string;
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
allowAllEmployees?: boolean;
|
|
19
|
+
allowedEmployees?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const permissionCache = new Map<string, { record: RolePermissionRecord | null; expiresAt: number }>();
|
|
15
23
|
|
|
16
24
|
export function invalidateRolePermissionCache(roleName?: string): void {
|
|
17
|
-
if (roleName)
|
|
18
|
-
|
|
25
|
+
if (!roleName) {
|
|
26
|
+
permissionCache.clear();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const suffix = `:role:${roleName}`;
|
|
30
|
+
for (const key of permissionCache.keys()) {
|
|
31
|
+
if (key.endsWith(suffix)) permissionCache.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function unwrapRecord(raw: unknown): RolePermissionRecord | null {
|
|
36
|
+
if (!raw) return null;
|
|
37
|
+
if (typeof (raw as { get?: unknown }).get === 'function') {
|
|
38
|
+
const model = raw as { get: (key: string) => unknown };
|
|
39
|
+
return {
|
|
40
|
+
roleName: model.get('roleName') as string | undefined,
|
|
41
|
+
enabled: model.get('enabled') as boolean | undefined,
|
|
42
|
+
allowAllEmployees: model.get('allowAllEmployees') as boolean | undefined,
|
|
43
|
+
allowedEmployees: model.get('allowedEmployees') as string[] | undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return raw as RolePermissionRecord;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function loadRolePermission(ctx: Context, roleName: string): Promise<RolePermissionRecord | null> {
|
|
50
|
+
const cacheKey = `${ctx.app?.name ?? 'main'}:role:${roleName}`;
|
|
51
|
+
const cached = permissionCache.get(cacheKey);
|
|
52
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
53
|
+
return cached.record;
|
|
54
|
+
}
|
|
55
|
+
const record = unwrapRecord(await ctx.db.getRepository('aiApiRolePermissions').findOne({ filter: { roleName } }));
|
|
56
|
+
permissionCache.set(cacheKey, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
|
|
57
|
+
return record;
|
|
19
58
|
}
|
|
20
59
|
|
|
21
60
|
/**
|
|
22
|
-
* Check whether the authenticated
|
|
23
|
-
*
|
|
61
|
+
* Check whether the authenticated roles are allowed to use the AI API.
|
|
62
|
+
*
|
|
63
|
+
* Every role — including root — must hold an enabled aiApiRolePermissions row;
|
|
64
|
+
* there is no special-case bypass, so a leaked API key stays scoped to whatever
|
|
65
|
+
* its role was granted in Settings → Users & Permissions → [Role] → AI API.
|
|
66
|
+
*
|
|
67
|
+
* Multi-role callers are evaluated with union semantics: the request is allowed
|
|
68
|
+
* when ANY assigned role is enabled, and the enabled records are stored in
|
|
69
|
+
* ctx.state.aiApiRolePermissions for downstream handlers (see checkEmployeeAccess).
|
|
24
70
|
*
|
|
25
71
|
* Returns true if access is allowed (caller may proceed).
|
|
26
72
|
* Returns false if access is denied (403 already written to ctx, caller must return).
|
|
27
|
-
*
|
|
28
|
-
* The 'root' and 'admin' roles always bypass the check.
|
|
29
73
|
*/
|
|
30
74
|
export async function checkRolePermission(ctx: Context): Promise<boolean> {
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (roleName === 'root' || roleName === 'admin') {
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
75
|
+
const roleNames = ctx.state.currentRoles?.length
|
|
76
|
+
? (ctx.state.currentRoles as string[])
|
|
77
|
+
: [ctx.state.currentRole || 'member'];
|
|
37
78
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
? cached.record
|
|
42
|
-
: await ctx.db.getRepository('aiApiRolePermissions').findOne({ filter: { roleName } });
|
|
43
|
-
if (!cached || cached.expiresAt <= Date.now()) {
|
|
44
|
-
permissionCache.set(roleName, { record, expiresAt: Date.now() + PERMISSION_TTL_MS });
|
|
45
|
-
}
|
|
79
|
+
const records = (await Promise.all(roleNames.map((name) => loadRolePermission(ctx, name)))).filter(
|
|
80
|
+
(record): record is RolePermissionRecord => !!record?.enabled,
|
|
81
|
+
);
|
|
46
82
|
|
|
47
|
-
if (!
|
|
83
|
+
if (!records.length) {
|
|
48
84
|
ctx.status = 403;
|
|
49
85
|
ctx.body = toOpenAIError(
|
|
50
86
|
403,
|
|
51
|
-
`
|
|
87
|
+
`None of the roles [${roleNames.join(', ')}] is authorized to use the AI API. ` +
|
|
52
88
|
`An admin must enable access in Settings → Users & Permissions → [Role] → AI API.`,
|
|
53
89
|
'permission_denied',
|
|
54
90
|
'role_not_permitted',
|
|
@@ -56,24 +92,22 @@ export async function checkRolePermission(ctx: Context): Promise<boolean> {
|
|
|
56
92
|
return false;
|
|
57
93
|
}
|
|
58
94
|
|
|
59
|
-
// Store for downstream handlers
|
|
60
|
-
ctx.state.
|
|
95
|
+
// Store for downstream handlers (employee scoping, usage attribution).
|
|
96
|
+
ctx.state.aiApiRolePermissions = records;
|
|
61
97
|
return true;
|
|
62
98
|
}
|
|
63
99
|
|
|
64
100
|
/**
|
|
65
|
-
* Check whether the current
|
|
66
|
-
* Must be called after checkRolePermission (so ctx.state.
|
|
101
|
+
* Check whether the current roles may use a specific AI Employee.
|
|
102
|
+
* Must be called after checkRolePermission (so ctx.state.aiApiRolePermissions is set).
|
|
67
103
|
*
|
|
68
104
|
* Returns true when:
|
|
69
|
-
* -
|
|
70
|
-
* -
|
|
71
|
-
* - The employeeUsername is in the allowedEmployees list
|
|
105
|
+
* - Any enabled role has allowAllEmployees=true, or
|
|
106
|
+
* - The employeeUsername appears in the union of the enabled roles' allowedEmployees lists
|
|
72
107
|
*/
|
|
73
108
|
export function checkEmployeeAccess(ctx: Context, employeeUsername: string): boolean {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return ((perm.allowedEmployees as string[]) || []).includes(employeeUsername);
|
|
109
|
+
const perms = (ctx.state.aiApiRolePermissions as RolePermissionRecord[] | undefined) || [];
|
|
110
|
+
return perms.some(
|
|
111
|
+
(perm) => perm.allowAllEmployees || ((perm.allowedEmployees as string[]) || []).includes(employeeUsername),
|
|
112
|
+
);
|
|
79
113
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* The default usage group is a pure fallback: users with no aiApiGroupMembers row
|
|
14
|
+
* resolve to it automatically at runtime. Earlier migrations (legacy quota-policy
|
|
15
|
+
* backfill and group-delete reassignment) wrote explicit member rows pointing at
|
|
16
|
+
* the default group. Those rows are redundant, hide users from the "unassigned"
|
|
17
|
+
* filter, and can no longer be created through the guarded create path. Drop them
|
|
18
|
+
* so the default group holds no explicit members.
|
|
19
|
+
*/
|
|
20
|
+
export default class RemoveDefaultGroupMembers extends Migration {
|
|
21
|
+
// afterSync: aiApiGroupMembers / aiApiUsageGroups must be registered and synced.
|
|
22
|
+
on = 'afterSync' as const;
|
|
23
|
+
|
|
24
|
+
async up() {
|
|
25
|
+
const groupCollection = this.db.getCollection('aiApiUsageGroups');
|
|
26
|
+
const memberCollection = this.db.getCollection('aiApiGroupMembers');
|
|
27
|
+
if (!groupCollection || !memberCollection) return;
|
|
28
|
+
if (!(await groupCollection.existsInDb()) || !(await memberCollection.existsInDb())) return;
|
|
29
|
+
|
|
30
|
+
const defaultGroup = await this.db.getRepository('aiApiUsageGroups').findOne({
|
|
31
|
+
filter: { isDefault: true },
|
|
32
|
+
});
|
|
33
|
+
if (!defaultGroup) return;
|
|
34
|
+
|
|
35
|
+
const groupId = defaultGroup.get('id');
|
|
36
|
+
// Destroy one by one so per-row destroy events fire; member counts here are small.
|
|
37
|
+
const members = await this.db.getRepository('aiApiGroupMembers').find({
|
|
38
|
+
filter: { groupId },
|
|
39
|
+
});
|
|
40
|
+
for (const member of members) {
|
|
41
|
+
await this.db.getRepository('aiApiGroupMembers').destroy({ filterByTk: member.get('id') });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (members.length) {
|
|
45
|
+
this.app.logger.info(
|
|
46
|
+
`[ai-api] Removed ${members.length} explicit member row(s) from the default usage group; those users now fall back to it implicitly.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async down() {
|
|
52
|
+
// Irreversible: the dropped rows carried no extra data, and re-adding them would
|
|
53
|
+
// re-introduce the inconsistency this migration removes. Users still resolve to
|
|
54
|
+
// the default group implicitly, so behaviour is unchanged.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
/**
|
|
13
|
+
* The AI API gateway no longer bypasses permission checks for any role, root and admin
|
|
14
|
+
* included: every role needs an enabled aiApiRolePermissions row. Existing deployments
|
|
15
|
+
* rely on root's and admin's implicit access, so seed both rows here — otherwise every
|
|
16
|
+
* root/admin API key would start failing with 403 right after the upgrade.
|
|
17
|
+
* Rows an admin already created are left untouched.
|
|
18
|
+
*/
|
|
19
|
+
export default class SeedDefaultRolePermissions extends Migration {
|
|
20
|
+
// afterSync: aiApiRolePermissions must be registered and synced before we write to it.
|
|
21
|
+
on = 'afterSync' as const;
|
|
22
|
+
|
|
23
|
+
async up() {
|
|
24
|
+
const collection = this.db.getCollection('aiApiRolePermissions');
|
|
25
|
+
if (!collection || !(await collection.existsInDb())) return;
|
|
26
|
+
|
|
27
|
+
const repository = this.db.getRepository('aiApiRolePermissions');
|
|
28
|
+
const rolesToSeed = [
|
|
29
|
+
{ roleName: 'root', enabled: true, allowAllEmployees: true, allowedEmployees: [] },
|
|
30
|
+
{ roleName: 'admin', enabled: true, allowAllEmployees: true, allowedEmployees: [] },
|
|
31
|
+
];
|
|
32
|
+
for (const values of rolesToSeed) {
|
|
33
|
+
const existing = await repository.findOne({ filter: { roleName: values.roleName } });
|
|
34
|
+
if (existing) continue;
|
|
35
|
+
await repository.create({ values });
|
|
36
|
+
this.app.logger.info(
|
|
37
|
+
`[ai-api] Seeded aiApiRolePermissions for role ${values.roleName} (the built-in bypass was removed).`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async down() {
|
|
43
|
+
// Keep the rows on rollback: deleting them would lock root/admin out of the gateway,
|
|
44
|
+
// and the rows are harmless for older code paths that bypass the check anyway.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
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
|
+
const SAMPLE_IDS = ['resp_sample_welcome', 'resp_sample_followup'];
|
|
13
|
+
|
|
14
|
+
export default class SeedSampleResponseRecord extends Migration {
|
|
15
|
+
on = 'afterSync' as const;
|
|
16
|
+
|
|
17
|
+
async up() {
|
|
18
|
+
const collection = this.db.getCollection('aiApiResponseRecords');
|
|
19
|
+
if (!collection || !(await collection.existsInDb())) return;
|
|
20
|
+
|
|
21
|
+
// Never seed demo content in production unless the operator opts in explicitly.
|
|
22
|
+
if (process.env.NODE_ENV === 'production' && process.env.AI_API_SEED_SAMPLE_RESPONSES !== 'true') {
|
|
23
|
+
this.app.logger.info(
|
|
24
|
+
'[ai-api] Skipped sample response records in production (set AI_API_SEED_SAMPLE_RESPONSES=true to enable).',
|
|
25
|
+
);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rootUser = await this.db.getRepository('users').findOne({ filter: { 'roles.name': 'root' } });
|
|
30
|
+
if (!rootUser) {
|
|
31
|
+
this.app.logger.warn('[ai-api] Skipped sample response records because no root user exists.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const repository = this.db.getRepository('aiApiResponseRecords');
|
|
35
|
+
|
|
36
|
+
const now = Math.floor(Date.now() / 1000);
|
|
37
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
38
|
+
const firstOutput = {
|
|
39
|
+
id: SAMPLE_IDS[0],
|
|
40
|
+
object: 'response',
|
|
41
|
+
created_at: now,
|
|
42
|
+
completed_at: now,
|
|
43
|
+
status: 'completed',
|
|
44
|
+
error: null,
|
|
45
|
+
incomplete_details: null,
|
|
46
|
+
instructions: null,
|
|
47
|
+
max_output_tokens: null,
|
|
48
|
+
model: 'sample/gpt-4',
|
|
49
|
+
output: [
|
|
50
|
+
{
|
|
51
|
+
id: 'msg_sample_welcome',
|
|
52
|
+
type: 'message',
|
|
53
|
+
status: 'completed',
|
|
54
|
+
role: 'assistant',
|
|
55
|
+
content: [
|
|
56
|
+
{
|
|
57
|
+
type: 'output_text',
|
|
58
|
+
text: 'Hello! Welcome to the NocoBase AI API. How can I help you today?',
|
|
59
|
+
annotations: [],
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
output_text: 'Hello! Welcome to the NocoBase AI API. How can I help you today?',
|
|
65
|
+
parallel_tool_calls: true,
|
|
66
|
+
previous_response_id: null,
|
|
67
|
+
reasoning: null,
|
|
68
|
+
service_tier: 'default',
|
|
69
|
+
temperature: null,
|
|
70
|
+
text: null,
|
|
71
|
+
tool_choice: 'auto',
|
|
72
|
+
tools: [],
|
|
73
|
+
top_p: null,
|
|
74
|
+
truncation: 'disabled',
|
|
75
|
+
usage: {
|
|
76
|
+
input_tokens: 15,
|
|
77
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
78
|
+
output_tokens: 20,
|
|
79
|
+
output_tokens_details: { reasoning_tokens: 0 },
|
|
80
|
+
total_tokens: 35,
|
|
81
|
+
},
|
|
82
|
+
metadata: { sample: 'true', purpose: 'documentation' },
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const firstValues = {
|
|
86
|
+
responseId: SAMPLE_IDS[0],
|
|
87
|
+
userId: rootUser.get('id'),
|
|
88
|
+
model: firstOutput.model,
|
|
89
|
+
input: 'Hello! I am testing the Responses API.',
|
|
90
|
+
output: firstOutput,
|
|
91
|
+
previousResponseId: null,
|
|
92
|
+
metadata: firstOutput.metadata,
|
|
93
|
+
expiresAt,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const secondOutput = {
|
|
97
|
+
...firstOutput,
|
|
98
|
+
id: SAMPLE_IDS[1],
|
|
99
|
+
created_at: now + 60,
|
|
100
|
+
completed_at: now + 60,
|
|
101
|
+
output: [
|
|
102
|
+
{
|
|
103
|
+
id: 'msg_sample_followup',
|
|
104
|
+
type: 'message',
|
|
105
|
+
status: 'completed',
|
|
106
|
+
role: 'assistant',
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: 'output_text',
|
|
110
|
+
text: 'I can help with questions, code, data analysis, and more.',
|
|
111
|
+
annotations: [],
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
output_text: 'I can help with questions, code, data analysis, and more.',
|
|
117
|
+
previous_response_id: SAMPLE_IDS[0],
|
|
118
|
+
usage: {
|
|
119
|
+
input_tokens: 25,
|
|
120
|
+
input_tokens_details: { cached_tokens: 0 },
|
|
121
|
+
output_tokens: 15,
|
|
122
|
+
output_tokens_details: { reasoning_tokens: 0 },
|
|
123
|
+
total_tokens: 40,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const secondValues = {
|
|
127
|
+
responseId: SAMPLE_IDS[1],
|
|
128
|
+
userId: rootUser.get('id'),
|
|
129
|
+
model: secondOutput.model,
|
|
130
|
+
input: 'What can you help me with?',
|
|
131
|
+
output: secondOutput,
|
|
132
|
+
previousResponseId: SAMPLE_IDS[0],
|
|
133
|
+
metadata: secondOutput.metadata,
|
|
134
|
+
expiresAt,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Create each missing sample independently inside one transaction, so a rerun after a
|
|
138
|
+
// partial failure still seeds the remaining record without duplicating the existing one.
|
|
139
|
+
const candidates = [firstValues, secondValues];
|
|
140
|
+
await this.db.sequelize.transaction(async (transaction) => {
|
|
141
|
+
for (const values of candidates) {
|
|
142
|
+
const exists = await repository.findOne({ filter: { responseId: values.responseId }, transaction });
|
|
143
|
+
if (!exists) {
|
|
144
|
+
await repository.create({ values, transaction });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
this.app.logger.info('[ai-api] Seeded sample Responses API records for the root user.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async down() {
|
|
152
|
+
const repository = this.db.getRepository('aiApiResponseRecords');
|
|
153
|
+
const records = await repository.find({ filter: { responseId: { $in: SAMPLE_IDS } } });
|
|
154
|
+
for (const record of records) {
|
|
155
|
+
const metadata = record.get('metadata');
|
|
156
|
+
// Only remove records we actually seeded — a user-owned record may reuse a sample id.
|
|
157
|
+
if (metadata && typeof metadata === 'object' && (metadata as Record<string, unknown>).sample === 'true') {
|
|
158
|
+
await repository.destroy({ filterByTk: record.get('id') });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|