plugin-ai-api 1.0.15 → 1.0.21
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/client/286.01c0e3c5fff3cccb.js +10 -0
- package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
- package/dist/client/562.17a0a299d2e5152c.js +10 -0
- package/dist/client/757.a01403fb7a1bea01.js +10 -0
- package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
- package/dist/client/97.72979a11a067a7c9.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
- package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/97.29c663318eebbd57.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +36 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +105 -10
- package/dist/locale/vi-VN.json +105 -0
- package/dist/locale/zh-CN.json +105 -10
- package/dist/server/billing.js +331 -0
- package/dist/server/collections/ai-api-config.js +18 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-model-prices.js +55 -0
- package/dist/server/collections/ai-api-usage-records.js +9 -0
- package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
- package/dist/server/plugin.js +36 -3
- package/dist/server/resource/ai-api-config.js +25 -0
- package/dist/server/resource/ai-api-usage-monitor.js +86 -0
- package/dist/server/routes/agent-completions.js +62 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +157 -6
- package/dist/server/routes/completions.js +20 -3
- package/dist/server/routes/models.js +78 -20
- package/dist/server/routes/router.js +108 -23
- package/dist/server/usage.js +19 -2
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/validation.js +120 -0
- package/dist/swagger.js +32 -1
- package/package.json +1 -1
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +82 -48
- package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -0
- package/src/client-v2/pages/GeneralPage.tsx +183 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UsagePage.tsx +248 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
- package/src/client-v2/pages/api.ts +16 -0
- package/src/client-v2/plugin.tsx +62 -4
- package/src/constants.ts +21 -0
- package/src/locale/en-US.json +105 -10
- package/src/locale/vi-VN.json +105 -0
- package/src/locale/zh-CN.json +105 -10
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/billing-quota.test.ts +134 -0
- package/src/server/__tests__/billing.test.ts +33 -0
- package/src/server/__tests__/models.test.ts +74 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/__tests__/usage-monitor.test.ts +63 -0
- package/src/server/__tests__/usage-route.test.ts +4 -0
- package/src/server/billing.ts +387 -0
- package/src/server/collections/ai-api-config.ts +69 -51
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-model-prices.ts +25 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -0
- package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
- package/src/server/plugin.ts +47 -5
- package/src/server/resource/ai-api-config.ts +105 -74
- package/src/server/resource/ai-api-usage-monitor.ts +74 -0
- package/src/server/routes/agent-completions.ts +77 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +275 -6
- package/src/server/routes/completions.ts +27 -4
- package/src/server/routes/models.ts +290 -195
- package/src/server/routes/router.ts +152 -27
- package/src/server/usage.ts +19 -1
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/validation.ts +89 -0
- package/src/swagger.ts +38 -1
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client/950.83390c5f1d5a97fb.js +0 -10
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
|
@@ -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
|
+
});
|
package/src/server/plugin.ts
CHANGED
|
@@ -8,10 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Plugin } from '@nocobase/server';
|
|
11
|
-
import { createAiLlmRouter } from './routes/router';
|
|
11
|
+
import { createAiLlmRouter, AI_LLM_PREFIX } 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, validateModelMetadata, validateQuotaPolicy } from './validation';
|
|
17
|
+
import { AI_API_ACL_SNIPPET } from '../constants';
|
|
15
18
|
|
|
16
19
|
// Ensure dayjs timezone + utc plugins are loaded.
|
|
17
20
|
// Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
|
|
@@ -37,10 +40,35 @@ export class PluginAiApiServer extends Plugin {
|
|
|
37
40
|
|
|
38
41
|
async afterAdd() {}
|
|
39
42
|
|
|
40
|
-
async beforeLoad() {
|
|
43
|
+
async beforeLoad() {
|
|
44
|
+
this.app.db.on('aiApiModelPrices.beforeSave', async (model) => {
|
|
45
|
+
await validateModelPrice(this.db, model);
|
|
46
|
+
});
|
|
47
|
+
this.app.db.on('aiApiModelMetadata.beforeSave', (model) => {
|
|
48
|
+
validateModelMetadata(model);
|
|
49
|
+
});
|
|
50
|
+
this.app.db.on('aiApiUserQuotaPolicies.beforeSave', (model) => {
|
|
51
|
+
validateQuotaPolicy(model);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
41
54
|
|
|
42
55
|
async load() {
|
|
43
|
-
// 1.
|
|
56
|
+
// 1. Claim body parsing for our own routes before the core bodyParser runs.
|
|
57
|
+
// Core registers koa-bodyparser with a global REQUEST_BODY_LIMIT (10mb by
|
|
58
|
+
// default) much earlier in the stack, so without this the gateway's own
|
|
59
|
+
// configurable limit is unreachable: an oversized vision request would be
|
|
60
|
+
// rejected by core with a non-OpenAI error shape. `disableBodyParser` makes
|
|
61
|
+
// koa-bodyparser skip the request, leaving ctx.request.body undefined so
|
|
62
|
+
// createAiLlmRouter reads and caps the raw stream itself.
|
|
63
|
+
this.app.use(
|
|
64
|
+
async (ctx, next) => {
|
|
65
|
+
if (ctx.path.startsWith(AI_LLM_PREFIX)) ctx.disableBodyParser = true;
|
|
66
|
+
await next();
|
|
67
|
+
},
|
|
68
|
+
{ tag: 'aiApiDisableBodyParser', before: 'bodyParser' },
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// 2. Register raw Koa middleware for OpenAI-compatible endpoints
|
|
44
72
|
// Must run before 'resourcer' so URL paths match OpenAI convention
|
|
45
73
|
// OIDC access tokens must first pass through plugin-idp-oauth, which validates
|
|
46
74
|
// issuer/audience/scope and rewrites them to a NocoBase internal token.
|
|
@@ -48,6 +76,7 @@ export class PluginAiApiServer extends Plugin {
|
|
|
48
76
|
|
|
49
77
|
// 2. Register admin config resource
|
|
50
78
|
this.app.resourceManager.define(aiApiConfigResource);
|
|
79
|
+
this.app.resourceManager.define(aiApiUsageMonitorResource);
|
|
51
80
|
|
|
52
81
|
this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
|
|
53
82
|
invalidateRolePermissionCache(model.get('roleName'));
|
|
@@ -58,8 +87,19 @@ export class PluginAiApiServer extends Plugin {
|
|
|
58
87
|
|
|
59
88
|
// 3. Set ACL permissions for admin config + role permissions management
|
|
60
89
|
this.app.acl.registerSnippet({
|
|
61
|
-
name:
|
|
62
|
-
actions: [
|
|
90
|
+
name: AI_API_ACL_SNIPPET,
|
|
91
|
+
actions: [
|
|
92
|
+
'aiApiConfig:*',
|
|
93
|
+
'aiApiRolePermissions:*',
|
|
94
|
+
'aiApiModelPrices:*',
|
|
95
|
+
'aiApiModelMetadata:*',
|
|
96
|
+
'aiApiUserQuotaPolicies:*',
|
|
97
|
+
'aiApiUserQuotaBuckets:list',
|
|
98
|
+
'aiApiUserQuotaBuckets:get',
|
|
99
|
+
'aiApiUsageRecords:list',
|
|
100
|
+
'aiApiUsageRecords:get',
|
|
101
|
+
'aiApiUsageMonitor:summary',
|
|
102
|
+
],
|
|
63
103
|
});
|
|
64
104
|
|
|
65
105
|
// 4. GC the rate limiter every 5 minutes to evict stale user entries.
|
|
@@ -77,6 +117,8 @@ export class PluginAiApiServer extends Plugin {
|
|
|
77
117
|
defaultAiEmployee: '',
|
|
78
118
|
enabledLlmServices: [],
|
|
79
119
|
rateLimitPerMinute: 60,
|
|
120
|
+
quotaEnabled: false,
|
|
121
|
+
defaultReservationOutputTokens: 4096,
|
|
80
122
|
},
|
|
81
123
|
});
|
|
82
124
|
}
|
|
@@ -1,74 +1,105 @@
|
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
async
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
options:
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
import { MAX_REQUEST_BODY_MB_LIMIT } from '../routes/router';
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MAX_REQUEST_BODY_MB = 10;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The gateway buffers each request body in memory, so an out-of-range value
|
|
17
|
+
* here is a denial-of-service footgun rather than a harmless setting.
|
|
18
|
+
*/
|
|
19
|
+
function coerceMaxRequestBodyMb(value: unknown): number {
|
|
20
|
+
const mb = Number(value);
|
|
21
|
+
if (!Number.isSafeInteger(mb) || mb <= 0) {
|
|
22
|
+
throw new Error(`maxRequestBodyMb must be a positive integer (1-${MAX_REQUEST_BODY_MB_LIMIT}).`);
|
|
23
|
+
}
|
|
24
|
+
if (mb > MAX_REQUEST_BODY_MB_LIMIT) {
|
|
25
|
+
throw new Error(`maxRequestBodyMb cannot exceed ${MAX_REQUEST_BODY_MB_LIMIT}.`);
|
|
26
|
+
}
|
|
27
|
+
return mb;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resource for managing AI API configuration via NocoBase admin UI.
|
|
32
|
+
* Singleton config pattern (same as aiSettings in plugin-ai).
|
|
33
|
+
*
|
|
34
|
+
* Uses custom action name 'save' instead of 'update' because NocoBase's
|
|
35
|
+
* built-in middleware requires filter/filterByTk for the standard 'update' action.
|
|
36
|
+
*/
|
|
37
|
+
const aiApiConfigResource: ResourceOptions = {
|
|
38
|
+
name: 'aiApiConfig',
|
|
39
|
+
actions: {
|
|
40
|
+
async get(ctx, next) {
|
|
41
|
+
let config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
42
|
+
if (!config) {
|
|
43
|
+
config = await ctx.db.getRepository('aiApiConfig').create({
|
|
44
|
+
values: {
|
|
45
|
+
mode: 'llm',
|
|
46
|
+
defaultAiEmployee: '',
|
|
47
|
+
defaultLlmService: '',
|
|
48
|
+
enabledLlmServices: [],
|
|
49
|
+
rateLimitPerMinute: 60,
|
|
50
|
+
maxRequestBodyMb: 10,
|
|
51
|
+
quotaEnabled: false,
|
|
52
|
+
defaultReservationOutputTokens: 4096,
|
|
53
|
+
options: {},
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
ctx.body = config;
|
|
58
|
+
await next();
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
async save(ctx, next) {
|
|
62
|
+
const values = ctx.action.params.values || (ctx.request.body as any) || {};
|
|
63
|
+
const repo = ctx.db.getRepository('aiApiConfig');
|
|
64
|
+
let config = await repo.findOne();
|
|
65
|
+
|
|
66
|
+
if (!config) {
|
|
67
|
+
config = await repo.create({
|
|
68
|
+
values: {
|
|
69
|
+
mode: values.mode ?? 'llm',
|
|
70
|
+
defaultAiEmployee: values.defaultAiEmployee ?? '',
|
|
71
|
+
defaultLlmService: values.defaultLlmService ?? '',
|
|
72
|
+
enabledLlmServices: values.enabledLlmServices ?? [],
|
|
73
|
+
rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
|
|
74
|
+
maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
|
|
75
|
+
quotaEnabled: values.quotaEnabled ?? false,
|
|
76
|
+
defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
|
|
77
|
+
options: values.options ?? {},
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
const updateData: Record<string, any> = {};
|
|
82
|
+
if (values.mode !== undefined) updateData.mode = values.mode;
|
|
83
|
+
if (values.defaultAiEmployee !== undefined) updateData.defaultAiEmployee = values.defaultAiEmployee;
|
|
84
|
+
if (values.defaultLlmService !== undefined) updateData.defaultLlmService = values.defaultLlmService;
|
|
85
|
+
if (values.enabledLlmServices !== undefined) updateData.enabledLlmServices = values.enabledLlmServices;
|
|
86
|
+
if (values.rateLimitPerMinute !== undefined) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
|
|
87
|
+
if (values.maxRequestBodyMb !== undefined) {
|
|
88
|
+
updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
|
|
89
|
+
}
|
|
90
|
+
if (values.quotaEnabled !== undefined) updateData.quotaEnabled = values.quotaEnabled;
|
|
91
|
+
if (values.defaultReservationOutputTokens !== undefined) {
|
|
92
|
+
updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
|
|
93
|
+
}
|
|
94
|
+
if (values.options !== undefined) updateData.options = values.options;
|
|
95
|
+
|
|
96
|
+
await config.update(updateData);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ctx.body = config;
|
|
100
|
+
await next();
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
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;
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from '../utils/ai-employee-runtime';
|
|
28
28
|
import { setAiApiUsageUnavailable } from '../usage';
|
|
29
29
|
import type PluginAiApiServer from '../plugin';
|
|
30
|
+
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
33
|
* POST /api/ai-llm/v1/chat/completions (agent mode)
|
|
@@ -281,85 +282,97 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
281
282
|
// Don't actually end — we control this
|
|
282
283
|
};
|
|
283
284
|
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
285
|
+
// Translate one buffered NocoBase SSE frame → OpenAI SSE.
|
|
286
|
+
const processSseFrame = (frame: string): void => {
|
|
287
|
+
for (const line of frame.split('\n')) {
|
|
288
|
+
const trimmed = line.trim();
|
|
289
|
+
if (!trimmed.startsWith('data: ')) continue;
|
|
290
|
+
|
|
291
|
+
const jsonStr = trimmed.substring(6);
|
|
292
|
+
if (!jsonStr) continue;
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const event = JSON.parse(jsonStr);
|
|
296
|
+
|
|
297
|
+
if (event.type === 'content' && event.body) {
|
|
298
|
+
markAiApiFirstProviderOutput(ctx);
|
|
299
|
+
// Content chunk — forward as OpenAI delta
|
|
300
|
+
originalWrite(
|
|
301
|
+
formatSSE(
|
|
302
|
+
toOpenAIStreamChunk({
|
|
303
|
+
id: completionId,
|
|
304
|
+
model: body.model,
|
|
305
|
+
delta: { content: String(event.body) },
|
|
306
|
+
}),
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
} else if (event.type === 'tool_call_chunks' && Array.isArray(event.body)) {
|
|
310
|
+
const chunks = toOpenAIToolCallChunks(event.body);
|
|
311
|
+
if (chunks.length) {
|
|
312
|
+
markAiApiFirstProviderOutput(ctx);
|
|
313
|
+
sawToolCalls = true;
|
|
303
314
|
originalWrite(
|
|
304
315
|
formatSSE(
|
|
305
316
|
toOpenAIStreamChunk({
|
|
306
317
|
id: completionId,
|
|
307
318
|
model: body.model,
|
|
308
|
-
delta: {
|
|
319
|
+
delta: { tool_calls: chunks },
|
|
309
320
|
}),
|
|
310
321
|
),
|
|
311
322
|
);
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
toOpenAIStreamChunk({
|
|
319
|
-
id: completionId,
|
|
320
|
-
model: body.model,
|
|
321
|
-
delta: { tool_calls: chunks },
|
|
322
|
-
}),
|
|
323
|
-
),
|
|
324
|
-
);
|
|
325
|
-
}
|
|
326
|
-
} else if (!sawToolCalls && event.type === 'tool_calls' && Array.isArray(event.body?.toolCalls)) {
|
|
327
|
-
const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
|
|
328
|
-
if (chunks.length) {
|
|
329
|
-
sawToolCalls = true;
|
|
330
|
-
originalWrite(
|
|
331
|
-
formatSSE(
|
|
332
|
-
toOpenAIStreamChunk({
|
|
333
|
-
id: completionId,
|
|
334
|
-
model: body.model,
|
|
335
|
-
delta: { tool_calls: chunks },
|
|
336
|
-
}),
|
|
337
|
-
),
|
|
338
|
-
);
|
|
339
|
-
}
|
|
340
|
-
} else if (event.type === 'error' && event.body) {
|
|
341
|
-
// Error from the agent — surface as SSE error object
|
|
323
|
+
}
|
|
324
|
+
} else if (!sawToolCalls && event.type === 'tool_calls' && Array.isArray(event.body?.toolCalls)) {
|
|
325
|
+
const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
|
|
326
|
+
if (chunks.length) {
|
|
327
|
+
markAiApiFirstProviderOutput(ctx);
|
|
328
|
+
sawToolCalls = true;
|
|
342
329
|
originalWrite(
|
|
343
|
-
formatSSE(
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
},
|
|
349
|
-
|
|
330
|
+
formatSSE(
|
|
331
|
+
toOpenAIStreamChunk({
|
|
332
|
+
id: completionId,
|
|
333
|
+
model: body.model,
|
|
334
|
+
delta: { tool_calls: chunks },
|
|
335
|
+
}),
|
|
336
|
+
),
|
|
350
337
|
);
|
|
351
338
|
}
|
|
352
|
-
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
339
|
+
} else if (event.type === 'error' && event.body) {
|
|
340
|
+
// Error from the agent — surface as SSE error object
|
|
341
|
+
originalWrite(
|
|
342
|
+
formatSSE({
|
|
343
|
+
error: {
|
|
344
|
+
message: String(event.body),
|
|
345
|
+
type: 'server_error',
|
|
346
|
+
code: 'agent_error',
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
357
350
|
}
|
|
351
|
+
// stream_start, stream_end, tool_call_status, web_search,
|
|
352
|
+
// reasoning and new_message are NocoBase-only events and are ignored.
|
|
353
|
+
// These are NocoBase-internal events not part of the OpenAI protocol.
|
|
354
|
+
} catch {
|
|
355
|
+
// Non-JSON SSE line — ignore
|
|
358
356
|
}
|
|
359
357
|
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Intercept write() — buffer input and translate complete frames.
|
|
361
|
+
(ctx.res as any).write = (data: Buffer | string): boolean => {
|
|
362
|
+
pendingSse += typeof data === 'string' ? data : data.toString('utf8');
|
|
363
|
+
const frames = pendingSse.split('\n\n');
|
|
364
|
+
pendingSse = frames.pop() || '';
|
|
365
|
+
for (const frame of frames) processSseFrame(frame);
|
|
360
366
|
return true;
|
|
361
367
|
};
|
|
362
368
|
|
|
369
|
+
// Flush any trailing frame that was not terminated by '\n\n'.
|
|
370
|
+
const flushPendingSse = (): void => {
|
|
371
|
+
if (!pendingSse.trim()) return;
|
|
372
|
+
processSseFrame(pendingSse);
|
|
373
|
+
pendingSse = '';
|
|
374
|
+
};
|
|
375
|
+
|
|
363
376
|
try {
|
|
364
377
|
const aiEmployee = new AIEmployee(
|
|
365
378
|
createAIEmployeeOptions(ctx, employeeRecord, sessionId, {
|
|
@@ -385,6 +398,8 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
|
|
|
385
398
|
ctx.res.off('close', abortAgent);
|
|
386
399
|
|
|
387
400
|
if (streamSucceeded && !ctx.res.destroyed) {
|
|
401
|
+
// Emit any final frame the agent left unterminated before closing.
|
|
402
|
+
flushPendingSse();
|
|
388
403
|
originalWrite(
|
|
389
404
|
formatSSE(
|
|
390
405
|
toOpenAIStreamChunk({
|
|
@@ -47,7 +47,20 @@ export async function authenticateBearer(ctx: Context): Promise<boolean> {
|
|
|
47
47
|
const rolesRepository = ctx.db.getRepository('users.roles', ctx.state.currentUser.id);
|
|
48
48
|
const roles = await rolesRepository.find({ fields: ['name'] });
|
|
49
49
|
const roleNames = roles.map((role: { name: string }) => role.name);
|
|
50
|
-
|
|
50
|
+
// An explicit X-Role that the user does not hold must be rejected, not
|
|
51
|
+
// silently downgraded to the first role — otherwise a caller could probe
|
|
52
|
+
// for access under a role they were never granted.
|
|
53
|
+
if (requestedRole && !roleNames.includes(requestedRole)) {
|
|
54
|
+
ctx.status = 403;
|
|
55
|
+
ctx.body = toOpenAIError(
|
|
56
|
+
403,
|
|
57
|
+
`Requested role '${requestedRole}' is not assigned to this user`,
|
|
58
|
+
'permission_denied',
|
|
59
|
+
'role_not_permitted',
|
|
60
|
+
);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
ctx.state.currentRole = requestedRole || roleNames[0];
|
|
51
64
|
ctx.state.currentRoles = ctx.state.currentRole ? [ctx.state.currentRole] : roleNames;
|
|
52
65
|
}
|
|
53
66
|
return true;
|