plugin-ai-api 1.0.15 → 1.0.20

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 (60) hide show
  1. package/dist/client/302.25edd5d75460acbf.js +10 -0
  2. package/dist/client/757.71e30f2a1306562d.js +10 -0
  3. package/dist/client/902.4238b04ac667c30a.js +10 -0
  4. package/dist/client/97.37cda285d7da3a26.js +10 -0
  5. package/dist/client/index.js +1 -1
  6. package/dist/client-v2/302.9b27a263901d54d8.js +10 -0
  7. package/dist/client-v2/757.c377e2f2b054d89d.js +10 -0
  8. package/dist/client-v2/902.d40d7bda106124c8.js +10 -0
  9. package/dist/client-v2/97.fc922c37ced86831.js +10 -0
  10. package/dist/client-v2/index.js +1 -1
  11. package/dist/externalVersion.js +9 -9
  12. package/dist/locale/en-US.json +78 -2
  13. package/dist/locale/vi-VN.json +86 -0
  14. package/dist/locale/zh-CN.json +86 -10
  15. package/dist/server/billing.js +331 -0
  16. package/dist/server/collections/ai-api-config.js +12 -0
  17. package/dist/server/collections/ai-api-model-prices.js +55 -0
  18. package/dist/server/collections/ai-api-usage-records.js +9 -0
  19. package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
  20. package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
  21. package/dist/server/plugin.js +23 -2
  22. package/dist/server/resource/ai-api-config.js +8 -0
  23. package/dist/server/resource/ai-api-usage-monitor.js +86 -0
  24. package/dist/server/routes/chat-completions.js +12 -2
  25. package/dist/server/routes/completions.js +12 -2
  26. package/dist/server/routes/router.js +14 -1
  27. package/dist/server/usage.js +17 -2
  28. package/dist/server/validation.js +102 -0
  29. package/package.json +1 -1
  30. package/src/client/plugin.tsx +73 -48
  31. package/src/client-v2/locale.ts +1 -0
  32. package/src/client-v2/pages/GeneralPage.tsx +170 -0
  33. package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
  34. package/src/client-v2/pages/UsagePage.tsx +248 -0
  35. package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
  36. package/src/client-v2/pages/api.ts +16 -0
  37. package/src/client-v2/plugin.tsx +21 -3
  38. package/src/locale/en-US.json +78 -2
  39. package/src/locale/vi-VN.json +86 -0
  40. package/src/locale/zh-CN.json +86 -10
  41. package/src/server/__tests__/billing-quota.test.ts +134 -0
  42. package/src/server/__tests__/billing.test.ts +33 -0
  43. package/src/server/__tests__/usage-monitor.test.ts +63 -0
  44. package/src/server/__tests__/usage-route.test.ts +4 -0
  45. package/src/server/billing.ts +387 -0
  46. package/src/server/collections/ai-api-config.ts +63 -51
  47. package/src/server/collections/ai-api-model-prices.ts +25 -0
  48. package/src/server/collections/ai-api-usage-records.ts +9 -0
  49. package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
  50. package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
  51. package/src/server/plugin.ts +24 -2
  52. package/src/server/resource/ai-api-config.ts +82 -74
  53. package/src/server/resource/ai-api-usage-monitor.ts +74 -0
  54. package/src/server/routes/chat-completions.ts +13 -2
  55. package/src/server/routes/completions.ts +13 -2
  56. package/src/server/routes/router.ts +16 -1
  57. package/src/server/usage.ts +17 -1
  58. package/src/server/validation.ts +62 -0
  59. package/dist/client/950.83390c5f1d5a97fb.js +0 -10
  60. package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
@@ -1,51 +1,63 @@
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: 'aiApiConfig',
14
- autoGenId: true,
15
- fields: [
16
- {
17
- name: 'mode',
18
- type: 'string',
19
- defaultValue: 'llm',
20
- comment: "API mode: 'llm' = direct LLM proxy, 'agent' = full AI Employee agent with tools/RAG",
21
- },
22
- {
23
- name: 'defaultAiEmployee',
24
- type: 'string',
25
- comment: 'Username of the default AI Employee for system prompt injection',
26
- },
27
- {
28
- name: 'defaultLlmService',
29
- type: 'string',
30
- comment: 'Name (UID) of the default LLM service. Clients can send just modelId without service prefix.',
31
- },
32
- {
33
- name: 'enabledLlmServices',
34
- type: 'json',
35
- defaultValue: [],
36
- comment: 'Array of llmService names to expose. Empty = expose all enabled services',
37
- },
38
- {
39
- name: 'rateLimitPerMinute',
40
- type: 'integer',
41
- defaultValue: 60,
42
- comment: 'Max requests per user per minute',
43
- },
44
- {
45
- name: 'options',
46
- type: 'jsonb',
47
- defaultValue: {},
48
- comment: 'Reserved for future extensibility',
49
- },
50
- ],
51
- });
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: 'aiApiConfig',
14
+ autoGenId: true,
15
+ fields: [
16
+ {
17
+ name: 'mode',
18
+ type: 'string',
19
+ defaultValue: 'llm',
20
+ comment: "API mode: 'llm' = direct LLM proxy, 'agent' = full AI Employee agent with tools/RAG",
21
+ },
22
+ {
23
+ name: 'defaultAiEmployee',
24
+ type: 'string',
25
+ comment: 'Username of the default AI Employee for system prompt injection',
26
+ },
27
+ {
28
+ name: 'defaultLlmService',
29
+ type: 'string',
30
+ comment: 'Name (UID) of the default LLM service. Clients can send just modelId without service prefix.',
31
+ },
32
+ {
33
+ name: 'enabledLlmServices',
34
+ type: 'json',
35
+ defaultValue: [],
36
+ comment: 'Array of llmService names to expose. Empty = expose all enabled services',
37
+ },
38
+ {
39
+ name: 'rateLimitPerMinute',
40
+ type: 'integer',
41
+ defaultValue: 60,
42
+ comment: 'Max requests per user per minute',
43
+ },
44
+ {
45
+ name: 'quotaEnabled',
46
+ type: 'boolean',
47
+ defaultValue: false,
48
+ comment: 'Enable per-user request, token, and cost quotas for direct LLM mode',
49
+ },
50
+ {
51
+ name: 'defaultReservationOutputTokens',
52
+ type: 'integer',
53
+ defaultValue: 4096,
54
+ comment: 'Output tokens reserved when a request does not specify a maximum',
55
+ },
56
+ {
57
+ name: 'options',
58
+ type: 'jsonb',
59
+ defaultValue: {},
60
+ comment: 'Reserved for future extensibility',
61
+ },
62
+ ],
63
+ });
@@ -0,0 +1,25 @@
1
+ import { defineCollection } from '@nocobase/database';
2
+
3
+ export default defineCollection({
4
+ name: 'aiApiModelPrices',
5
+ autoGenId: true,
6
+ fields: [
7
+ { name: 'llmService', type: 'string', allowNull: false, index: true },
8
+ { name: 'provider', type: 'string', allowNull: false, index: true },
9
+ { name: 'model', type: 'string', allowNull: false, index: true },
10
+ { name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
11
+ { name: 'inputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10, allowNull: false },
12
+ { name: 'outputPricePerMillionTokens', type: 'decimal', precision: 20, scale: 10, allowNull: false },
13
+ { name: 'fixedCostPerRequest', type: 'decimal', precision: 20, scale: 10, allowNull: false, defaultValue: 0 },
14
+ { name: 'effectiveFrom', type: 'datetimeTz', allowNull: false, index: true },
15
+ { name: 'effectiveTo', type: 'datetimeTz', allowNull: true, index: true },
16
+ { name: 'enabled', type: 'boolean', defaultValue: true, index: true },
17
+ { name: 'notes', type: 'text', allowNull: true },
18
+ ],
19
+ indexes: [
20
+ {
21
+ fields: ['llmService', 'model', 'effectiveFrom'],
22
+ unique: true,
23
+ },
24
+ ],
25
+ });
@@ -22,6 +22,9 @@ export default defineCollection({
22
22
  { name: 'endpoint', type: 'string' },
23
23
  { name: 'mode', type: 'string', allowNull: true },
24
24
  { name: 'model', type: 'string', allowNull: true, index: true },
25
+ { name: 'resolvedService', type: 'string', allowNull: true, index: true },
26
+ { name: 'resolvedProvider', type: 'string', allowNull: true, index: true },
27
+ { name: 'resolvedModel', type: 'string', allowNull: true, index: true },
25
28
  { name: 'status', type: 'string', index: true },
26
29
  { name: 'httpStatus', type: 'integer', allowNull: true },
27
30
  { name: 'errorCode', type: 'string', allowNull: true },
@@ -31,6 +34,12 @@ export default defineCollection({
31
34
  { name: 'totalTokens', type: 'integer', allowNull: true },
32
35
  { name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
33
36
  { name: 'currency', type: 'string', allowNull: true },
37
+ { name: 'costStatus', type: 'string', allowNull: true, index: true },
38
+ { name: 'modelPriceId', type: 'bigInt', allowNull: true, index: true },
39
+ { name: 'quotaPolicyId', type: 'bigInt', allowNull: true, index: true },
40
+ { name: 'inputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
41
+ { name: 'outputPricePerMillionTokens', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
42
+ { name: 'fixedCostPerRequest', type: 'decimal', allowNull: true, precision: 20, scale: 10 },
34
43
  { name: 'providerRequestId', type: 'string', allowNull: true },
35
44
  { name: 'requestMetadata', type: 'jsonb', defaultValue: {} },
36
45
  { name: 'responseMetadata', type: 'jsonb', defaultValue: {} },
@@ -0,0 +1,24 @@
1
+ import { defineCollection } from '@nocobase/database';
2
+
3
+ export default defineCollection({
4
+ name: 'aiApiUserQuotaBuckets',
5
+ autoGenId: true,
6
+ fields: [
7
+ { name: 'policyId', type: 'bigInt', allowNull: false, index: true },
8
+ { name: 'userId', type: 'bigInt', allowNull: false, index: true },
9
+ { name: 'periodStart', type: 'datetimeTz', allowNull: false, index: true },
10
+ { name: 'periodEnd', type: 'datetimeTz', allowNull: false, index: true },
11
+ { name: 'requestCount', type: 'bigInt', allowNull: false, defaultValue: 0 },
12
+ { name: 'totalTokens', type: 'bigInt', allowNull: false, defaultValue: 0 },
13
+ { name: 'cost', type: 'decimal', precision: 20, scale: 8, allowNull: false, defaultValue: 0 },
14
+ { name: 'reservedRequests', type: 'bigInt', allowNull: false, defaultValue: 0 },
15
+ { name: 'reservedTokens', type: 'bigInt', allowNull: false, defaultValue: 0 },
16
+ { name: 'reservedCost', type: 'decimal', precision: 20, scale: 8, allowNull: false, defaultValue: 0 },
17
+ ],
18
+ indexes: [
19
+ {
20
+ fields: ['policyId', 'periodStart'],
21
+ unique: true,
22
+ },
23
+ ],
24
+ });
@@ -0,0 +1,32 @@
1
+ import { defineCollection } from '@nocobase/database';
2
+
3
+ export default defineCollection({
4
+ name: 'aiApiUserQuotaPolicies',
5
+ autoGenId: true,
6
+ fields: [
7
+ { name: 'userId', type: 'bigInt', allowNull: false, index: true },
8
+ {
9
+ name: 'user',
10
+ type: 'belongsTo',
11
+ target: 'users',
12
+ targetKey: 'id',
13
+ foreignKey: 'userId',
14
+ constraints: false,
15
+ },
16
+ { name: 'enabled', type: 'boolean', defaultValue: true, index: true },
17
+ { name: 'periodType', type: 'string', allowNull: false, defaultValue: 'monthly' },
18
+ { name: 'timezone', type: 'string', allowNull: false, defaultValue: 'UTC' },
19
+ { name: 'requestLimit', type: 'bigInt', allowNull: true },
20
+ { name: 'totalTokenLimit', type: 'bigInt', allowNull: true },
21
+ { name: 'costLimit', type: 'decimal', precision: 20, scale: 8, allowNull: true },
22
+ { name: 'currency', type: 'string', allowNull: false, defaultValue: 'USD' },
23
+ { name: 'rejectUnpricedModel', type: 'boolean', defaultValue: true },
24
+ { name: 'missingUsageBehavior', type: 'string', allowNull: false, defaultValue: 'use_reserved' },
25
+ ],
26
+ indexes: [
27
+ {
28
+ fields: ['userId'],
29
+ unique: true,
30
+ },
31
+ ],
32
+ });
@@ -10,8 +10,10 @@
10
10
  import { Plugin } from '@nocobase/server';
11
11
  import { createAiLlmRouter } from './routes/router';
12
12
  import aiApiConfigResource from './resource/ai-api-config';
13
+ import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
13
14
  import { RateLimiter } from './utils/rate-limiter';
14
15
  import { invalidateRolePermissionCache } from './middleware/role-permission';
16
+ import { validateModelPrice, validateQuotaPolicy } from './validation';
15
17
 
16
18
  // Ensure dayjs timezone + utc plugins are loaded.
17
19
  // Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
@@ -37,7 +39,14 @@ export class PluginAiApiServer extends Plugin {
37
39
 
38
40
  async afterAdd() {}
39
41
 
40
- async beforeLoad() {}
42
+ async beforeLoad() {
43
+ this.app.db.on('aiApiModelPrices.beforeSave', async (model) => {
44
+ await validateModelPrice(this.db, model);
45
+ });
46
+ this.app.db.on('aiApiUserQuotaPolicies.beforeSave', (model) => {
47
+ validateQuotaPolicy(model);
48
+ });
49
+ }
41
50
 
42
51
  async load() {
43
52
  // 1. Register raw Koa middleware for OpenAI-compatible endpoints
@@ -48,6 +57,7 @@ export class PluginAiApiServer extends Plugin {
48
57
 
49
58
  // 2. Register admin config resource
50
59
  this.app.resourceManager.define(aiApiConfigResource);
60
+ this.app.resourceManager.define(aiApiUsageMonitorResource);
51
61
 
52
62
  this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
53
63
  invalidateRolePermissionCache(model.get('roleName'));
@@ -59,7 +69,17 @@ export class PluginAiApiServer extends Plugin {
59
69
  // 3. Set ACL permissions for admin config + role permissions management
60
70
  this.app.acl.registerSnippet({
61
71
  name: `pm.${this.name}.configuration`,
62
- actions: ['aiApiConfig:*', 'aiApiRolePermissions:*'],
72
+ actions: [
73
+ 'aiApiConfig:*',
74
+ 'aiApiRolePermissions:*',
75
+ 'aiApiModelPrices:*',
76
+ 'aiApiUserQuotaPolicies:*',
77
+ 'aiApiUserQuotaBuckets:list',
78
+ 'aiApiUserQuotaBuckets:get',
79
+ 'aiApiUsageRecords:list',
80
+ 'aiApiUsageRecords:get',
81
+ 'aiApiUsageMonitor:summary',
82
+ ],
63
83
  });
64
84
 
65
85
  // 4. GC the rate limiter every 5 minutes to evict stale user entries.
@@ -77,6 +97,8 @@ export class PluginAiApiServer extends Plugin {
77
97
  defaultAiEmployee: '',
78
98
  enabledLlmServices: [],
79
99
  rateLimitPerMinute: 60,
100
+ quotaEnabled: false,
101
+ defaultReservationOutputTokens: 4096,
80
102
  },
81
103
  });
82
104
  }
@@ -1,74 +1,82 @@
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;
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
+ quotaEnabled: false,
33
+ defaultReservationOutputTokens: 4096,
34
+ options: {},
35
+ },
36
+ });
37
+ }
38
+ ctx.body = config;
39
+ await next();
40
+ },
41
+
42
+ async save(ctx, next) {
43
+ const values = ctx.action.params.values || (ctx.request.body as any) || {};
44
+ const repo = ctx.db.getRepository('aiApiConfig');
45
+ let config = await repo.findOne();
46
+
47
+ if (!config) {
48
+ config = await repo.create({
49
+ values: {
50
+ mode: values.mode ?? 'llm',
51
+ defaultAiEmployee: values.defaultAiEmployee ?? '',
52
+ defaultLlmService: values.defaultLlmService ?? '',
53
+ enabledLlmServices: values.enabledLlmServices ?? [],
54
+ rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
55
+ quotaEnabled: values.quotaEnabled ?? false,
56
+ defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
57
+ options: values.options ?? {},
58
+ },
59
+ });
60
+ } else {
61
+ const updateData: Record<string, any> = {};
62
+ if (values.mode !== undefined) updateData.mode = values.mode;
63
+ if (values.defaultAiEmployee !== undefined) updateData.defaultAiEmployee = values.defaultAiEmployee;
64
+ if (values.defaultLlmService !== undefined) updateData.defaultLlmService = values.defaultLlmService;
65
+ if (values.enabledLlmServices !== undefined) updateData.enabledLlmServices = values.enabledLlmServices;
66
+ if (values.rateLimitPerMinute !== undefined) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
67
+ if (values.quotaEnabled !== undefined) updateData.quotaEnabled = values.quotaEnabled;
68
+ if (values.defaultReservationOutputTokens !== undefined) {
69
+ updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
70
+ }
71
+ if (values.options !== undefined) updateData.options = values.options;
72
+
73
+ await config.update(updateData);
74
+ }
75
+
76
+ ctx.body = config;
77
+ await next();
78
+ },
79
+ },
80
+ };
81
+
82
+ export default aiApiConfigResource;
@@ -0,0 +1,74 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import type { ResourceOptions } from '@nocobase/resourcer';
3
+ import { Op, col, fn } from 'sequelize';
4
+
5
+ interface UsageSummaryRow {
6
+ requestCount?: string | number;
7
+ inputTokens?: string | number;
8
+ outputTokens?: string | number;
9
+ totalTokens?: string | number;
10
+ }
11
+
12
+ interface CostSummaryRow {
13
+ currency?: string;
14
+ totalCost?: string | number;
15
+ }
16
+
17
+ function buildWhere(ctx: Context) {
18
+ const params = ctx.action.params;
19
+ const where: Record<string, unknown> = {};
20
+ const start = typeof params.start === 'string' ? new Date(params.start) : undefined;
21
+ const end = typeof params.end === 'string' ? new Date(params.end) : undefined;
22
+
23
+ if ((start && !Number.isNaN(start.getTime())) || (end && !Number.isNaN(end.getTime()))) {
24
+ const startedAt: Record<symbol, Date> = {};
25
+ if (start && !Number.isNaN(start.getTime())) startedAt[Op.gte] = start;
26
+ if (end && !Number.isNaN(end.getTime())) startedAt[Op.lte] = end;
27
+ where.startedAt = startedAt;
28
+ }
29
+ if (params.userId !== undefined && params.userId !== '') where.userId = params.userId;
30
+ if (params.resolvedService) where.resolvedService = params.resolvedService;
31
+ if (params.resolvedModel) where.resolvedModel = params.resolvedModel;
32
+ if (params.status) where.status = params.status;
33
+ return where;
34
+ }
35
+
36
+ const aiApiUsageMonitorResource: ResourceOptions = {
37
+ name: 'aiApiUsageMonitor',
38
+ actions: {
39
+ async summary(ctx, next) {
40
+ const model = ctx.db.getCollection('aiApiUsageRecords').model;
41
+ const where = buildWhere(ctx);
42
+ const totals = (await model.findOne({
43
+ attributes: [
44
+ [fn('COUNT', col('id')), 'requestCount'],
45
+ [fn('COALESCE', fn('SUM', col('inputTokens')), 0), 'inputTokens'],
46
+ [fn('COALESCE', fn('SUM', col('outputTokens')), 0), 'outputTokens'],
47
+ [fn('COALESCE', fn('SUM', col('totalTokens')), 0), 'totalTokens'],
48
+ ],
49
+ where,
50
+ raw: true,
51
+ })) as unknown as UsageSummaryRow;
52
+ const costs = (await model.findAll({
53
+ attributes: ['currency', [fn('COALESCE', fn('SUM', col('estimatedCost')), 0), 'totalCost']],
54
+ where: { ...where, estimatedCost: { [Op.ne]: null } },
55
+ group: ['currency'],
56
+ raw: true,
57
+ })) as unknown as CostSummaryRow[];
58
+
59
+ ctx.body = {
60
+ requestCount: Number(totals?.requestCount ?? 0),
61
+ inputTokens: Number(totals?.inputTokens ?? 0),
62
+ outputTokens: Number(totals?.outputTokens ?? 0),
63
+ totalTokens: Number(totals?.totalTokens ?? 0),
64
+ costsByCurrency: costs.map((item) => ({
65
+ currency: item.currency || 'USD',
66
+ totalCost: String(item.totalCost ?? 0),
67
+ })),
68
+ };
69
+ await next();
70
+ },
71
+ },
72
+ };
73
+
74
+ export default aiApiUsageMonitorResource;
@@ -23,6 +23,7 @@ import { createRequestAbortController, isStreamingRequested, writeResponse } fro
23
23
  import { checkEmployeeAccess } from '../middleware/role-permission';
24
24
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
25
25
  import type PluginAiApiServer from '../plugin';
26
+ import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
26
27
 
27
28
  /**
28
29
  * POST /api/ai-llm/v1/chat/completions
@@ -121,6 +122,8 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
121
122
  return;
122
123
  }
123
124
 
125
+ await prepareLlmBilling(ctx, resolved);
126
+
124
127
  const providerRequestParameters = getProviderRequestParameters(body);
125
128
  const modelOptions: Record<string, unknown> = {
126
129
  model: modelId,
@@ -197,6 +200,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
197
200
  const baseModel = provider.createModel();
198
201
  applyProviderRequestParameters(baseModel, providerRequestParameters);
199
202
  const chatModel = bindRequestTools(baseModel, body.tools, body.tool_choice, providerRequestParameters);
203
+ markLlmProviderAttempted(ctx);
200
204
 
201
205
  if (stream) {
202
206
  // ─── Streaming mode ───
@@ -222,8 +226,15 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
222
226
  } catch (err) {
223
227
  ctx.log.error('AI API chat completions error:', err);
224
228
  if (!ctx.res.headersSent) {
225
- ctx.status = 500;
226
- ctx.body = toOpenAIError(500, getErrorMessage(err, 'Internal server error'), 'server_error');
229
+ const isQuotaError = err instanceof AiApiQuotaError;
230
+ ctx.status = isQuotaError ? 429 : 500;
231
+ if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
232
+ ctx.body = toOpenAIError(
233
+ ctx.status,
234
+ getErrorMessage(err, 'Internal server error'),
235
+ isQuotaError ? 'quota_error' : 'server_error',
236
+ isQuotaError ? err.code : undefined,
237
+ );
227
238
  }
228
239
  }
229
240
  }
@@ -13,6 +13,7 @@ import { resolveModelString } from '../utils/resolve-service';
13
13
  import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
14
14
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
15
15
  import type PluginAiApiServer from '../plugin';
16
+ import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
16
17
 
17
18
  /**
18
19
  * POST /api/ai-llm/v1/completions
@@ -112,6 +113,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
112
113
  return;
113
114
  }
114
115
 
116
+ await prepareLlmBilling(ctx, resolved);
117
+
115
118
  const modelOptions: Record<string, any> = {
116
119
  model: modelId,
117
120
  llmService: service.name,
@@ -154,6 +157,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
154
157
 
155
158
  const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
156
159
  const chatModel = provider.createModel();
160
+ markLlmProviderAttempted(ctx);
157
161
 
158
162
  if (stream) {
159
163
  await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
@@ -163,8 +167,15 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
163
167
  } catch (err) {
164
168
  ctx.log.error('AI API completions error:', err);
165
169
  if (!ctx.res.headersSent) {
166
- ctx.status = 500;
167
- ctx.body = toOpenAIError(500, getErrorMessage(err, 'Internal server error'), 'server_error');
170
+ const isQuotaError = err instanceof AiApiQuotaError;
171
+ ctx.status = isQuotaError ? 429 : 500;
172
+ if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
173
+ ctx.body = toOpenAIError(
174
+ ctx.status,
175
+ getErrorMessage(err, 'Internal server error'),
176
+ isQuotaError ? 'quota_error' : 'server_error',
177
+ isQuotaError ? err.code : undefined,
178
+ );
168
179
  }
169
180
  }
170
181
  }
@@ -21,6 +21,7 @@ import { checkRolePermission } from '../middleware/role-permission';
21
21
  import { startUsageRecord, finishUsageRecord } from '../usage';
22
22
  import { isStreamingRequested } from '../utils/streaming';
23
23
  import type PluginAiApiServer from '../plugin';
24
+ import { finalizeLlmBilling } from '../billing';
24
25
 
25
26
  const API_PREFIX = '/api/ai-llm/v1';
26
27
 
@@ -73,7 +74,10 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
73
74
  ctx.set('Access-Control-Allow-Origin', '*');
74
75
  ctx.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
75
76
  ctx.set('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-AI-Mode, X-Timezone, X-Locale');
76
- ctx.set('Access-Control-Expose-Headers', 'X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After');
77
+ ctx.set(
78
+ 'Access-Control-Expose-Headers',
79
+ 'X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reason, Retry-After',
80
+ );
77
81
  ctx.set('Access-Control-Max-Age', '86400');
78
82
 
79
83
  // ─── OPTIONS preflight — return immediately after CORS headers ────────
@@ -259,6 +263,17 @@ export function createAiLlmRouter(plugin: PluginAiApiServer) {
259
263
  } catch (usageError) {
260
264
  ctx.log.error('AI API usage record could not be finalized:', usageError);
261
265
  }
266
+ } else if (ctx.state.aiApiLlmBilling) {
267
+ try {
268
+ const usageResult = ctx.state.aiApiUsageResult;
269
+ const providerUsage = usageResult?.source === 'provider' ? usageResult.usage : undefined;
270
+ const succeeded = ctx.state.aiApiStreamResult
271
+ ? ctx.state.aiApiStreamResult.succeeded
272
+ : ctx.status >= 200 && ctx.status < 400;
273
+ await finalizeLlmBilling(ctx, providerUsage, succeeded);
274
+ } catch (billingError) {
275
+ ctx.log.error('AI API quota reservation could not be finalized:', billingError);
276
+ }
262
277
  }
263
278
  }
264
279
  };