plugin-ai-api 1.0.23 → 1.0.25

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 (64) hide show
  1. package/dist/client/757.56952e321dc399b7.js +10 -0
  2. package/dist/client/902.e74518750f1e4201.js +10 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
  5. package/dist/client-v2/902.c7c00a565085438a.js +10 -0
  6. package/dist/client-v2/index.js +1 -1
  7. package/dist/externalVersion.js +8 -8
  8. package/dist/locale/en-US.json +4 -0
  9. package/dist/locale/vi-VN.json +4 -0
  10. package/dist/locale/zh-CN.json +4 -0
  11. package/dist/server/billing.js +6 -1
  12. package/dist/server/collections/ai-api-config.js +6 -0
  13. package/dist/server/collections/ai-api-usage-records.js +1 -0
  14. package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
  15. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  16. package/dist/server/plugin.js +10 -0
  17. package/dist/server/resource/ai-api-config.js +5 -0
  18. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  19. package/dist/server/routes/chat-completions.js +110 -19
  20. package/dist/server/routes/completions.js +59 -24
  21. package/dist/server/services/file-processor.js +262 -0
  22. package/dist/server/usage.js +33 -3
  23. package/dist/server/utils/direct-llm-context.js +319 -0
  24. package/dist/server/utils/openai-format.js +21 -2
  25. package/dist/server/validation.js +3 -0
  26. package/dist/swagger.js +42 -3
  27. package/package.json +1 -1
  28. package/src/client-v2/pages/UsagePage.tsx +9 -0
  29. package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
  30. package/src/locale/en-US.json +4 -0
  31. package/src/locale/vi-VN.json +4 -0
  32. package/src/locale/zh-CN.json +4 -0
  33. package/src/server/__tests__/direct-llm-context.test.ts +206 -0
  34. package/src/server/__tests__/openai-format.test.ts +12 -2
  35. package/src/server/__tests__/request-body.test.ts +45 -2
  36. package/src/server/__tests__/usage-route.test.ts +173 -9
  37. package/src/server/__tests__/usage.test.ts +19 -0
  38. package/src/server/__tests__/validation.test.ts +36 -0
  39. package/src/server/billing.ts +6 -1
  40. package/src/server/collections/ai-api-config.ts +8 -0
  41. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  42. package/src/server/collections/ai-api-usage-records.ts +1 -0
  43. package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
  44. package/src/server/index.ts +10 -10
  45. package/src/server/middleware/rate-limit.ts +70 -70
  46. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  47. package/src/server/plugin.ts +20 -0
  48. package/src/server/resource/ai-api-config.ts +5 -0
  49. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  50. package/src/server/routes/chat-completions.ts +157 -22
  51. package/src/server/routes/completions.ts +61 -23
  52. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  53. package/src/server/services/file-processor.ts +323 -0
  54. package/src/server/usage.ts +47 -1
  55. package/src/server/utils/direct-llm-context.ts +394 -0
  56. package/src/server/utils/openai-format.ts +25 -2
  57. package/src/server/utils/rate-limiter.ts +83 -83
  58. package/src/server/utils/resolve-service.ts +82 -82
  59. package/src/server/validation.ts +3 -0
  60. package/src/swagger.ts +45 -3
  61. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  62. package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
  63. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  64. package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
@@ -1,41 +1,41 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
- import { defineCollection } from '@nocobase/database';
11
-
12
- export default defineCollection({
13
- name: 'aiApiRolePermissions',
14
- autoGenId: true,
15
- fields: [
16
- {
17
- name: 'roleName',
18
- type: 'string',
19
- unique: true,
20
- comment: 'Role name (links to roles.name)',
21
- },
22
- {
23
- name: 'enabled',
24
- type: 'boolean',
25
- defaultValue: false,
26
- comment: 'Whether this role can use the AI API at all',
27
- },
28
- {
29
- name: 'allowAllEmployees',
30
- type: 'boolean',
31
- defaultValue: true,
32
- comment: 'If true, the role may use any AI Employee. If false, only those in allowedEmployees.',
33
- },
34
- {
35
- name: 'allowedEmployees',
36
- type: 'json',
37
- defaultValue: [],
38
- comment: 'Array of AI Employee usernames this role is allowed to use (when allowAllEmployees=false)',
39
- },
40
- ],
41
- });
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import { defineCollection } from '@nocobase/database';
11
+
12
+ export default defineCollection({
13
+ name: 'aiApiRolePermissions',
14
+ autoGenId: true,
15
+ fields: [
16
+ {
17
+ name: 'roleName',
18
+ type: 'string',
19
+ unique: true,
20
+ comment: 'Role name (links to roles.name)',
21
+ },
22
+ {
23
+ name: 'enabled',
24
+ type: 'boolean',
25
+ defaultValue: false,
26
+ comment: 'Whether this role can use the AI API at all',
27
+ },
28
+ {
29
+ name: 'allowAllEmployees',
30
+ type: 'boolean',
31
+ defaultValue: true,
32
+ comment: 'If true, the role may use any AI Employee. If false, only those in allowedEmployees.',
33
+ },
34
+ {
35
+ name: 'allowedEmployees',
36
+ type: 'json',
37
+ defaultValue: [],
38
+ comment: 'Array of AI Employee usernames this role is allowed to use (when allowAllEmployees=false)',
39
+ },
40
+ ],
41
+ });
@@ -32,6 +32,7 @@ export default defineCollection({
32
32
  { name: 'inputTokens', type: 'integer', allowNull: true },
33
33
  { name: 'outputTokens', type: 'integer', allowNull: true },
34
34
  { name: 'totalTokens', type: 'integer', allowNull: true },
35
+ { name: 'promptCacheTokens', type: 'integer', allowNull: true },
35
36
  { name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
36
37
  { name: 'currency', type: 'string', allowNull: true },
37
38
  { name: 'costStatus', type: 'string', allowNull: true, index: true },
@@ -22,6 +22,7 @@ export default defineCollection({
22
22
  { name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
23
23
  { name: 'rejectUnpricedModel', type: 'boolean', defaultValue: true },
24
24
  { name: 'missingUsageBehavior', type: 'string', allowNull: false, defaultValue: 'use_reserved' },
25
+ { name: 'contextOverflowBehavior', type: 'string', allowNull: false, defaultValue: 'reject' },
25
26
  ],
26
27
  indexes: [
27
28
  {
@@ -1,10 +1,10 @@
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
- export { default } from './plugin';
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
+ export { default } from './plugin';
@@ -1,70 +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
- }
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,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
+ }
@@ -18,6 +18,12 @@ import { invalidateRolePermissionCache } from './middleware/role-permission';
18
18
  import { invalidateUserPermissionCache } from './utils/user-permissions';
19
19
  import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
20
20
  import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_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
@@ -39,6 +45,12 @@ export class PluginAiApiServer extends Plugin {
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() {}
@@ -56,6 +68,13 @@ export class PluginAiApiServer extends Plugin {
56
68
  }
57
69
 
58
70
  async load() {
71
+ // Register default file processors. Custom plugins can register additional
72
+ // processors by retrieving this plugin instance and calling
73
+ // `fileProcessorService.register(processor)`.
74
+ this.fileProcessorService.register(base64FileForwarder);
75
+ this.fileProcessorService.register(httpFileUrlFetcher);
76
+ this.fileProcessorService.register(pdfFileProcessor);
77
+
59
78
  // 1. Claim body parsing for our own routes before the core bodyParser runs.
60
79
  // Core registers koa-bodyparser with a global REQUEST_BODY_LIMIT (10mb by
61
80
  // default) much earlier in the stack, so without this the gateway's own
@@ -159,6 +178,7 @@ export class PluginAiApiServer extends Plugin {
159
178
  enabledLlmServices: [],
160
179
  rateLimitPerMinute: 60,
161
180
  quotaEnabled: false,
181
+ pdfRenderPagesAsImages: false,
162
182
  defaultReservationOutputTokens: 4096,
163
183
  },
164
184
  });
@@ -48,6 +48,7 @@ const aiApiConfigResource: ResourceOptions = {
48
48
  enabledLlmServices: [],
49
49
  rateLimitPerMinute: 60,
50
50
  maxRequestBodyMb: 10,
51
+ pdfRenderPagesAsImages: false,
51
52
  quotaEnabled: false,
52
53
  defaultReservationOutputTokens: 4096,
53
54
  options: {},
@@ -72,6 +73,7 @@ const aiApiConfigResource: ResourceOptions = {
72
73
  enabledLlmServices: values.enabledLlmServices ?? [],
73
74
  rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
74
75
  maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
76
+ pdfRenderPagesAsImages: values.pdfRenderPagesAsImages ?? false,
75
77
  quotaEnabled: values.quotaEnabled ?? false,
76
78
  defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
77
79
  options: values.options ?? {},
@@ -87,6 +89,9 @@ const aiApiConfigResource: ResourceOptions = {
87
89
  if (values.maxRequestBodyMb !== undefined) {
88
90
  updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
89
91
  }
92
+ if (values.pdfRenderPagesAsImages !== undefined) {
93
+ updateData.pdfRenderPagesAsImages = Boolean(values.pdfRenderPagesAsImages);
94
+ }
90
95
  if (values.quotaEnabled !== undefined) updateData.quotaEnabled = values.quotaEnabled;
91
96
  if (values.defaultReservationOutputTokens !== undefined) {
92
97
  updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
@@ -7,6 +7,7 @@ interface UsageSummaryRow {
7
7
  inputTokens?: string | number;
8
8
  outputTokens?: string | number;
9
9
  totalTokens?: string | number;
10
+ promptCacheTokens?: string | number;
10
11
  }
11
12
 
12
13
  interface CostSummaryRow {
@@ -45,6 +46,7 @@ const aiApiUsageMonitorResource: ResourceOptions = {
45
46
  [fn('COALESCE', fn('SUM', col('inputTokens')), 0), 'inputTokens'],
46
47
  [fn('COALESCE', fn('SUM', col('outputTokens')), 0), 'outputTokens'],
47
48
  [fn('COALESCE', fn('SUM', col('totalTokens')), 0), 'totalTokens'],
49
+ [fn('COALESCE', fn('SUM', col('promptCacheTokens')), 0), 'promptCacheTokens'],
48
50
  ],
49
51
  where,
50
52
  raw: true,
@@ -61,6 +63,7 @@ const aiApiUsageMonitorResource: ResourceOptions = {
61
63
  inputTokens: Number(totals?.inputTokens ?? 0),
62
64
  outputTokens: Number(totals?.outputTokens ?? 0),
63
65
  totalTokens: Number(totals?.totalTokens ?? 0),
66
+ promptCacheTokens: Number(totals?.promptCacheTokens ?? 0),
64
67
  costsByCurrency: costs.map((item) => ({
65
68
  currency: item.currency || 'USD',
66
69
  totalCost: String(item.totalCost ?? 0),