plugin-ai-api 1.0.3 → 1.0.6

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.
Files changed (43) hide show
  1. package/README.md +15 -1
  2. package/client-v2.d.ts +2 -0
  3. package/client-v2.js +1 -0
  4. package/dist/client/778.5c452944cb747975.js +10 -0
  5. package/dist/client/950.83390c5f1d5a97fb.js +10 -0
  6. package/dist/client/index.js +1 -1
  7. package/dist/client-v2/950.42b30b5cc9e32b8f.js +10 -0
  8. package/dist/client-v2/index.js +10 -0
  9. package/dist/externalVersion.js +9 -8
  10. package/package.json +32 -14
  11. package/src/client/AiApiConfigPage.tsx +309 -0
  12. package/src/client/client.d.ts +258 -0
  13. package/src/client/components/AiApiRolePermissions.tsx +169 -0
  14. package/src/client/index.tsx +10 -0
  15. package/src/client/locale.ts +21 -0
  16. package/src/client/models/index.ts +12 -0
  17. package/src/client/plugin.tsx +48 -0
  18. package/src/client-v2/index.tsx +1 -0
  19. package/src/client-v2/plugin.tsx +24 -0
  20. package/src/index.ts +11 -0
  21. package/src/locale/en-US.json +10 -0
  22. package/src/locale/zh-CN.json +10 -0
  23. package/src/server/collections/.gitkeep +0 -0
  24. package/src/server/collections/ai-api-config.ts +51 -0
  25. package/src/server/collections/ai-api-role-permissions.ts +41 -0
  26. package/src/server/index.ts +10 -0
  27. package/src/server/middleware/rate-limit.ts +70 -0
  28. package/src/server/middleware/role-permission.ts +66 -0
  29. package/src/server/plugin.ts +89 -0
  30. package/src/server/resource/ai-api-config.ts +74 -0
  31. package/src/server/routes/agent-completions.ts +428 -0
  32. package/src/server/routes/auth.ts +111 -0
  33. package/src/server/routes/chat-completions.ts +318 -0
  34. package/src/server/routes/completions.ts +299 -0
  35. package/src/server/routes/embeddings.ts +191 -0
  36. package/src/server/routes/models.ts +195 -0
  37. package/src/server/routes/router.ts +283 -0
  38. package/src/server/utils/openai-format.ts +142 -0
  39. package/src/server/utils/rate-limiter.ts +83 -0
  40. package/src/server/utils/resolve-service.ts +82 -0
  41. package/src/swagger.ts +325 -0
  42. package/dist/client/23.e96ecf13e6072dce.js +0 -10
  43. package/dist/client/503.29bcdb426b01e715.js +0 -10
@@ -0,0 +1,70 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { Context } from '@nocobase/actions';
11
+ import { RateLimiter } from '../utils/rate-limiter';
12
+ import { toOpenAIError } from '../utils/openai-format';
13
+
14
+ /**
15
+ * Creates a rate-limiting check function for use in the AI API router.
16
+ *
17
+ * Must be called AFTER authenticateBearer() so ctx.state.currentUser is set.
18
+ * Reads rateLimitPerMinute from aiApiConfig on each request (cheap single-row read,
19
+ * allows config changes to take effect immediately without restart).
20
+ * Falls back to 60 req/min if the config record is missing or the field is 0/null.
21
+ *
22
+ * Returns false (and writes the 429 response) when the rate limit is exceeded.
23
+ * Returns true when the request is allowed.
24
+ *
25
+ * Sets OpenAI-compatible rate limit response headers on every request:
26
+ * X-RateLimit-Limit: <limit>
27
+ * X-RateLimit-Remaining: <remaining> (on 429: 0)
28
+ * Retry-After: <seconds> (on 429 only)
29
+ */
30
+ export function createRateLimitMiddleware(limiter: RateLimiter) {
31
+ return async (ctx: Context): Promise<boolean> => {
32
+ const userId = ctx.state.currentUser?.id;
33
+ // Auth runs before this; if somehow missing, fail open (don't block the request).
34
+ if (userId === undefined || userId === null) return true;
35
+
36
+ let limit = 60;
37
+ try {
38
+ const config = await ctx.db.getRepository('aiApiConfig').findOne();
39
+ const configLimit = config?.rateLimitPerMinute;
40
+ if (configLimit && configLimit > 0) {
41
+ limit = configLimit;
42
+ }
43
+ } catch (configErr) {
44
+ // Config read failure: fail open — don't block legitimate requests
45
+ // Log at WARN so admins can detect DB connectivity issues
46
+ ctx.app?.logger?.warn('[ai-api] Rate limit config read failed, using default (60/min)', configErr);
47
+ }
48
+
49
+ const result = limiter.check(userId, limit);
50
+
51
+ if (!result.allowed) {
52
+ const retryAfterSec = Math.ceil((result as any).retryAfterMs / 1000);
53
+ ctx.set('Retry-After', String(retryAfterSec));
54
+ ctx.set('X-RateLimit-Limit', String(limit));
55
+ ctx.set('X-RateLimit-Remaining', '0');
56
+ ctx.status = 429;
57
+ ctx.body = toOpenAIError(
58
+ 429,
59
+ `Rate limit exceeded. You have used all ${limit} requests allowed per minute. ` +
60
+ `Please wait ${retryAfterSec} second${retryAfterSec !== 1 ? 's' : ''} before retrying.`,
61
+ 'requests',
62
+ 'rate_limit_exceeded',
63
+ );
64
+ return false;
65
+ }
66
+
67
+ ctx.set('X-RateLimit-Limit', String(limit));
68
+ return true;
69
+ };
70
+ }
@@ -0,0 +1,66 @@
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
+ * Check whether the authenticated role is allowed to use the AI API.
15
+ * Loads the permission record and stores it in ctx.state.aiApiRolePermission.
16
+ *
17
+ * Returns true if access is allowed (caller may proceed).
18
+ * Returns false if access is denied (403 already written to ctx, caller must return).
19
+ *
20
+ * The 'root' and 'admin' roles always bypass the check.
21
+ */
22
+ export async function checkRolePermission(ctx: Context): Promise<boolean> {
23
+ const roleName = ctx.state.currentRoles?.[0] || 'member';
24
+
25
+ // root / admin always allowed
26
+ if (roleName === 'root' || roleName === 'admin') {
27
+ return true;
28
+ }
29
+
30
+ const record = await ctx.db.getRepository('aiApiRolePermissions').findOne({
31
+ filter: { roleName },
32
+ });
33
+
34
+ if (!record?.enabled) {
35
+ ctx.status = 403;
36
+ ctx.body = toOpenAIError(
37
+ 403,
38
+ `Role '${roleName}' is not authorized to use the AI API. ` +
39
+ `An admin must enable access in Settings → Users & Permissions → [Role] → AI API.`,
40
+ 'permission_denied',
41
+ 'role_not_permitted',
42
+ );
43
+ return false;
44
+ }
45
+
46
+ // Store for downstream handlers
47
+ ctx.state.aiApiRolePermission = record;
48
+ return true;
49
+ }
50
+
51
+ /**
52
+ * Check whether the current role is allowed to use a specific AI Employee.
53
+ * Must be called after checkRolePermission (so ctx.state.aiApiRolePermission is set).
54
+ *
55
+ * Returns true when:
56
+ * - Role is admin/root (no permission record stored)
57
+ * - allowAllEmployees is true
58
+ * - The employeeUsername is in the allowedEmployees list
59
+ */
60
+ export function checkEmployeeAccess(ctx: Context, employeeUsername: string): boolean {
61
+ const perm = ctx.state.aiApiRolePermission;
62
+ // admin/root paths have no record stored → always allowed
63
+ if (!perm) return true;
64
+ if (perm.allowAllEmployees) return true;
65
+ return ((perm.allowedEmployees as string[]) || []).includes(employeeUsername);
66
+ }
@@ -0,0 +1,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
+
15
+ // Ensure dayjs timezone + utc plugins are loaded.
16
+ // Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
17
+ // extend the 'timezone' plugin, causing utcOffset(value) to behave as a
18
+ // getter (returns a number) instead of a setter (returns a dayjs instance).
19
+ // That breaks parse-filter.js's utc2unit() → "m.startOf is not a function".
20
+ // Extending here patches the shared CommonJS dayjs module instance for the
21
+ // entire Node.js process before any AIEmployee call is made.
22
+ import dayjsLib from 'dayjs';
23
+ import utcPlugin from 'dayjs/plugin/utc';
24
+ import timezonePlugin from 'dayjs/plugin/timezone';
25
+ (dayjsLib as any).extend(utcPlugin);
26
+ (dayjsLib as any).extend(timezonePlugin);
27
+
28
+ export class PluginAiApiServer extends Plugin {
29
+ /**
30
+ * Singleton rate limiter — lives for the entire plugin lifetime, shared across all requests.
31
+ * Uses a 1-minute sliding window to enforce rateLimitPerMinute from aiApiConfig.
32
+ */
33
+ rateLimiter = new RateLimiter(60_000);
34
+
35
+ private gcInterval: NodeJS.Timeout | null = null;
36
+
37
+ async afterAdd() {}
38
+
39
+ async beforeLoad() {}
40
+
41
+ async load() {
42
+ // 1. Register raw Koa middleware for OpenAI-compatible endpoints
43
+ // Must run before 'resourcer' so URL paths match OpenAI convention
44
+ this.app.use(createAiLlmRouter(this), { before: 'resourcer' });
45
+
46
+ // 2. Register admin config resource
47
+ this.app.resourceManager.define(aiApiConfigResource);
48
+
49
+ // 3. Set ACL permissions for admin config + role permissions management
50
+ this.app.acl.registerSnippet({
51
+ name: `pm.${this.name}.configuration`,
52
+ actions: ['aiApiConfig:*', 'aiApiRolePermissions:*'],
53
+ });
54
+
55
+ // 4. GC the rate limiter every 5 minutes to evict stale user entries.
56
+ // .unref() prevents this timer from keeping the process alive on shutdown.
57
+ this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
58
+ this.gcInterval.unref();
59
+ }
60
+
61
+ async install() {
62
+ // Create default config record on first install
63
+ const existing = await this.db.getRepository('aiApiConfig').findOne();
64
+ if (!existing) {
65
+ await this.db.getRepository('aiApiConfig').create({
66
+ values: {
67
+ defaultAiEmployee: '',
68
+ enabledLlmServices: [],
69
+ rateLimitPerMinute: 60,
70
+ },
71
+ });
72
+ }
73
+ }
74
+
75
+ async afterEnable() {}
76
+
77
+ async afterDisable() {}
78
+
79
+ async remove() {
80
+ // Clean up the GC timer so we don't leak resources during hot-reload
81
+ if (this.gcInterval) {
82
+ clearInterval(this.gcInterval);
83
+ this.gcInterval = null;
84
+ }
85
+ this.rateLimiter.clear();
86
+ }
87
+ }
88
+
89
+ export default PluginAiApiServer;
@@ -0,0 +1,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
+ options: {},
33
+ },
34
+ });
35
+ }
36
+ ctx.body = config;
37
+ await next();
38
+ },
39
+
40
+ async save(ctx, next) {
41
+ const values = ctx.action.params.values || (ctx.request.body as any) || {};
42
+ const repo = ctx.db.getRepository('aiApiConfig');
43
+ let config = await repo.findOne();
44
+
45
+ if (!config) {
46
+ config = await repo.create({
47
+ values: {
48
+ mode: values.mode ?? 'llm',
49
+ defaultAiEmployee: values.defaultAiEmployee ?? '',
50
+ defaultLlmService: values.defaultLlmService ?? '',
51
+ enabledLlmServices: values.enabledLlmServices ?? [],
52
+ rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
53
+ options: values.options ?? {},
54
+ },
55
+ });
56
+ } else {
57
+ const updateData: Record<string, any> = {};
58
+ if (values.mode !== undefined) updateData.mode = values.mode;
59
+ if (values.defaultAiEmployee !== undefined) updateData.defaultAiEmployee = values.defaultAiEmployee;
60
+ if (values.defaultLlmService !== undefined) updateData.defaultLlmService = values.defaultLlmService;
61
+ if (values.enabledLlmServices !== undefined) updateData.enabledLlmServices = values.enabledLlmServices;
62
+ if (values.rateLimitPerMinute !== undefined) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
63
+ if (values.options !== undefined) updateData.options = values.options;
64
+
65
+ await config.update(updateData);
66
+ }
67
+
68
+ ctx.body = config;
69
+ await next();
70
+ },
71
+ },
72
+ };
73
+
74
+ export default aiApiConfigResource;