plugin-ai-api 1.0.8 → 1.0.9
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/externalVersion.js +1 -1
- package/dist/server/collections/ai-api-usage-records.js +63 -0
- package/dist/server/middleware/role-permission.js +15 -5
- package/dist/server/plugin.js +8 -1
- package/dist/server/routes/auth.js +37 -7
- package/dist/server/routes/router.js +22 -1
- package/dist/server/usage.js +80 -0
- package/package.json +1 -1
- package/src/server/collections/ai-api-usage-records.ts +33 -0
- package/src/server/middleware/role-permission.ts +79 -66
- package/src/server/plugin.ts +99 -89
- package/src/server/routes/auth.ts +142 -111
- package/src/server/routes/router.ts +304 -283
- package/src/server/usage.ts +52 -0
package/src/server/plugin.ts
CHANGED
|
@@ -1,89 +1,99 @@
|
|
|
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 { Plugin } from '@nocobase/server';
|
|
11
|
-
import { createAiLlmRouter } from './routes/router';
|
|
12
|
-
import aiApiConfigResource from './resource/ai-api-config';
|
|
13
|
-
import { RateLimiter } from './utils/rate-limiter';
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
|
|
26
|
-
(dayjsLib as any).extend(
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
*
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
//
|
|
47
|
-
this.app.
|
|
48
|
-
|
|
49
|
-
//
|
|
50
|
-
this.app.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
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 { Plugin } from '@nocobase/server';
|
|
11
|
+
import { createAiLlmRouter } from './routes/router';
|
|
12
|
+
import aiApiConfigResource from './resource/ai-api-config';
|
|
13
|
+
import { RateLimiter } from './utils/rate-limiter';
|
|
14
|
+
import { invalidateRolePermissionCache } from './middleware/role-permission';
|
|
15
|
+
|
|
16
|
+
// Ensure dayjs timezone + utc plugins are loaded.
|
|
17
|
+
// Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
|
|
18
|
+
// extend the 'timezone' plugin, causing utcOffset(value) to behave as a
|
|
19
|
+
// getter (returns a number) instead of a setter (returns a dayjs instance).
|
|
20
|
+
// That breaks parse-filter.js's utc2unit() → "m.startOf is not a function".
|
|
21
|
+
// Extending here patches the shared CommonJS dayjs module instance for the
|
|
22
|
+
// entire Node.js process before any AIEmployee call is made.
|
|
23
|
+
import dayjsLib from 'dayjs';
|
|
24
|
+
import utcPlugin from 'dayjs/plugin/utc';
|
|
25
|
+
import timezonePlugin from 'dayjs/plugin/timezone';
|
|
26
|
+
(dayjsLib as any).extend(utcPlugin);
|
|
27
|
+
(dayjsLib as any).extend(timezonePlugin);
|
|
28
|
+
|
|
29
|
+
export class PluginAiApiServer extends Plugin {
|
|
30
|
+
/**
|
|
31
|
+
* Singleton rate limiter — lives for the entire plugin lifetime, shared across all requests.
|
|
32
|
+
* Uses a 1-minute sliding window to enforce rateLimitPerMinute from aiApiConfig.
|
|
33
|
+
*/
|
|
34
|
+
rateLimiter = new RateLimiter(60_000);
|
|
35
|
+
|
|
36
|
+
private gcInterval: NodeJS.Timeout | null = null;
|
|
37
|
+
|
|
38
|
+
async afterAdd() {}
|
|
39
|
+
|
|
40
|
+
async beforeLoad() {}
|
|
41
|
+
|
|
42
|
+
async load() {
|
|
43
|
+
// 1. Register raw Koa middleware for OpenAI-compatible endpoints
|
|
44
|
+
// Must run before 'resourcer' so URL paths match OpenAI convention
|
|
45
|
+
// OIDC access tokens must first pass through plugin-idp-oauth, which validates
|
|
46
|
+
// issuer/audience/scope and rewrites them to a NocoBase internal token.
|
|
47
|
+
this.app.use(createAiLlmRouter(this), { after: 'idp-oauth-resource-auth', before: 'resourcer' });
|
|
48
|
+
|
|
49
|
+
// 2. Register admin config resource
|
|
50
|
+
this.app.resourceManager.define(aiApiConfigResource);
|
|
51
|
+
|
|
52
|
+
this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
|
|
53
|
+
invalidateRolePermissionCache(model.get('roleName'));
|
|
54
|
+
});
|
|
55
|
+
this.app.db.on('aiApiRolePermissions.afterDestroy', (model) => {
|
|
56
|
+
invalidateRolePermissionCache(model.get('roleName'));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// 3. Set ACL permissions for admin config + role permissions management
|
|
60
|
+
this.app.acl.registerSnippet({
|
|
61
|
+
name: `pm.${this.name}.configuration`,
|
|
62
|
+
actions: ['aiApiConfig:*', 'aiApiRolePermissions:*'],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// 4. GC the rate limiter every 5 minutes to evict stale user entries.
|
|
66
|
+
// .unref() prevents this timer from keeping the process alive on shutdown.
|
|
67
|
+
this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
|
|
68
|
+
this.gcInterval.unref();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async install() {
|
|
72
|
+
// Create default config record on first install
|
|
73
|
+
const existing = await this.db.getRepository('aiApiConfig').findOne();
|
|
74
|
+
if (!existing) {
|
|
75
|
+
await this.db.getRepository('aiApiConfig').create({
|
|
76
|
+
values: {
|
|
77
|
+
defaultAiEmployee: '',
|
|
78
|
+
enabledLlmServices: [],
|
|
79
|
+
rateLimitPerMinute: 60,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async afterEnable() {}
|
|
86
|
+
|
|
87
|
+
async afterDisable() {}
|
|
88
|
+
|
|
89
|
+
async remove() {
|
|
90
|
+
// Clean up the GC timer so we don't leak resources during hot-reload
|
|
91
|
+
if (this.gcInterval) {
|
|
92
|
+
clearInterval(this.gcInterval);
|
|
93
|
+
this.gcInterval = null;
|
|
94
|
+
}
|
|
95
|
+
this.rateLimiter.clear();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default PluginAiApiServer;
|
|
@@ -1,111 +1,142 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This file is part of the NocoBase (R) project.
|
|
3
|
-
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
-
* Authors: NocoBase Team.
|
|
5
|
-
*
|
|
6
|
-
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
-
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { Context
|
|
11
|
-
import { toOpenAIError } from '../utils/openai-format';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Authentication middleware for /api/ai-llm/v1/* routes.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
ctx.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context } from '@nocobase/actions';
|
|
11
|
+
import { toOpenAIError } from '../utils/openai-format';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Authentication middleware for /api/ai-llm/v1/* routes.
|
|
15
|
+
*
|
|
16
|
+
* Resolve the principal prepared by NocoBase auth/ACL middleware. OIDC resource
|
|
17
|
+
* authentication rewrites the external access token to an internal session token
|
|
18
|
+
* and sets currentUser before this gateway runs. API keys are the one case where
|
|
19
|
+
* we still need to decode the token locally because they are signed directly by
|
|
20
|
+
* NocoBase and carry a fixed roleName.
|
|
21
|
+
*/
|
|
22
|
+
export async function authenticateBearer(ctx: Context): Promise<boolean> {
|
|
23
|
+
const authHeader = ctx.get('Authorization') || '';
|
|
24
|
+
if (!authHeader.startsWith('Bearer ')) {
|
|
25
|
+
ctx.status = 401;
|
|
26
|
+
ctx.body = toOpenAIError(
|
|
27
|
+
401,
|
|
28
|
+
'Missing or invalid Authorization header. Expected: Bearer <access-token>',
|
|
29
|
+
'invalid_request_error',
|
|
30
|
+
'invalid_api_key',
|
|
31
|
+
);
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const token = authHeader.substring(7).trim();
|
|
36
|
+
if (!token) {
|
|
37
|
+
ctx.status = 401;
|
|
38
|
+
ctx.body = toOpenAIError(401, 'API key is empty', 'invalid_request_error', 'invalid_api_key');
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
if (ctx.state.currentUser) {
|
|
44
|
+
if (!ctx.state.currentRole) {
|
|
45
|
+
const requestedRole = ctx.get('X-Role');
|
|
46
|
+
const rolesRepository = ctx.db.getRepository('users.roles', ctx.state.currentUser.id);
|
|
47
|
+
const roles = await rolesRepository.find({ fields: ['name'] });
|
|
48
|
+
const roleNames = roles.map((role: { name: string }) => role.name);
|
|
49
|
+
ctx.state.currentRole = roleNames.includes(requestedRole) ? requestedRole : roleNames[0];
|
|
50
|
+
ctx.state.currentRoles = ctx.state.currentRole ? [ctx.state.currentRole] : roleNames;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Try to authenticate using the token as a NocoBase JWT/API token
|
|
56
|
+
// NocoBase API keys are essentially JWT tokens signed with the app secret
|
|
57
|
+
const jwt = ctx.app['authManager']?.jwt;
|
|
58
|
+
if (!jwt) {
|
|
59
|
+
ctx.status = 500;
|
|
60
|
+
ctx.body = toOpenAIError(500, 'JWT system not available', 'server_error');
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let decoded: { userId?: string | number; roleName?: string; temp?: boolean };
|
|
65
|
+
try {
|
|
66
|
+
decoded = await jwt.decode(token);
|
|
67
|
+
} catch (e) {
|
|
68
|
+
ctx.status = 401;
|
|
69
|
+
ctx.body = toOpenAIError(401, 'Incorrect API key provided', 'invalid_request_error', 'invalid_api_key');
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!decoded || !decoded.userId) {
|
|
74
|
+
ctx.status = 401;
|
|
75
|
+
ctx.body = toOpenAIError(401, 'Invalid or expired API key', 'invalid_request_error', 'invalid_api_key');
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// This fallback is for API keys only. A normal login/OIDC token should have
|
|
80
|
+
// been resolved by NocoBase auth middleware and must not be treated as the
|
|
81
|
+
// member role merely because it has no roleName claim.
|
|
82
|
+
if (!decoded.roleName) {
|
|
83
|
+
ctx.status = 401;
|
|
84
|
+
ctx.body = toOpenAIError(
|
|
85
|
+
401,
|
|
86
|
+
'Token was not resolved by the NocoBase auth middleware',
|
|
87
|
+
'invalid_request_error',
|
|
88
|
+
'invalid_api_key',
|
|
89
|
+
);
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const user = await ctx.db.getRepository('users').findOne({
|
|
94
|
+
filterByTk: decoded.userId,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!user) {
|
|
98
|
+
ctx.status = 401;
|
|
99
|
+
ctx.body = toOpenAIError(401, 'User not found for this API key', 'invalid_request_error', 'invalid_api_key');
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Set user context for downstream handlers
|
|
104
|
+
ctx.state.currentUser = user;
|
|
105
|
+
const rolesRepository = ctx.db.getRepository('users.roles', user.id);
|
|
106
|
+
const roles = await rolesRepository.find({ fields: ['name'] });
|
|
107
|
+
const roleNames = roles.map((role: { name: string }) => role.name);
|
|
108
|
+
if (!roleNames.includes(decoded.roleName)) {
|
|
109
|
+
ctx.status = 403;
|
|
110
|
+
ctx.body = toOpenAIError(
|
|
111
|
+
403,
|
|
112
|
+
'The API key role is no longer assigned to this user',
|
|
113
|
+
'permission_denied',
|
|
114
|
+
'role_not_permitted',
|
|
115
|
+
);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
ctx.state.currentRole = decoded.roleName;
|
|
119
|
+
ctx.state.currentRoles = [decoded.roleName];
|
|
120
|
+
ctx.state.aiApiAuthType = 'apiKey';
|
|
121
|
+
|
|
122
|
+
// Also set ctx.auth for AIEmployee compatibility.
|
|
123
|
+
// Only expose non-sensitive fields — exclude password, token, 2FA secrets, etc.
|
|
124
|
+
if (!ctx.auth) {
|
|
125
|
+
(ctx as any).auth = {};
|
|
126
|
+
}
|
|
127
|
+
const SAFE_USER_FIELDS = new Set(['id', 'username', 'nickname', 'email', 'createdAt', 'updatedAt']);
|
|
128
|
+
const rawUser = user.toJSON?.() ?? {};
|
|
129
|
+
const safeUser: Record<string, any> = { id: user.id };
|
|
130
|
+
for (const field of SAFE_USER_FIELDS) {
|
|
131
|
+
if (rawUser[field] !== undefined) safeUser[field] = rawUser[field];
|
|
132
|
+
}
|
|
133
|
+
(ctx as any).auth.user = safeUser;
|
|
134
|
+
|
|
135
|
+
return true;
|
|
136
|
+
} catch (err) {
|
|
137
|
+
ctx.log.error('AI API auth error:', err);
|
|
138
|
+
ctx.status = 401;
|
|
139
|
+
ctx.body = toOpenAIError(401, 'Authentication failed', 'invalid_request_error', 'invalid_api_key');
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|