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.
Files changed (92) hide show
  1. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  2. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  3. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  4. package/dist/client/757.a01403fb7a1bea01.js +10 -0
  5. package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
  6. package/dist/client/97.72979a11a067a7c9.js +10 -0
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  9. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  10. package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
  11. package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
  12. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  13. package/dist/client-v2/97.29c663318eebbd57.js +10 -0
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +36 -0
  16. package/dist/externalVersion.js +9 -10
  17. package/dist/locale/en-US.json +105 -10
  18. package/dist/locale/vi-VN.json +105 -0
  19. package/dist/locale/zh-CN.json +105 -10
  20. package/dist/server/billing.js +331 -0
  21. package/dist/server/collections/ai-api-config.js +18 -0
  22. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  23. package/dist/server/collections/ai-api-model-prices.js +55 -0
  24. package/dist/server/collections/ai-api-usage-records.js +9 -0
  25. package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
  26. package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
  27. package/dist/server/plugin.js +36 -3
  28. package/dist/server/resource/ai-api-config.js +25 -0
  29. package/dist/server/resource/ai-api-usage-monitor.js +86 -0
  30. package/dist/server/routes/agent-completions.js +62 -51
  31. package/dist/server/routes/auth.js +11 -1
  32. package/dist/server/routes/chat-completions.js +157 -6
  33. package/dist/server/routes/completions.js +20 -3
  34. package/dist/server/routes/models.js +78 -20
  35. package/dist/server/routes/router.js +108 -23
  36. package/dist/server/usage.js +19 -2
  37. package/dist/server/utils/app-observability.js +110 -0
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/validation.js +120 -0
  40. package/dist/swagger.js +32 -1
  41. package/package.json +1 -1
  42. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  43. package/src/client/locale.ts +11 -21
  44. package/src/client/plugin.tsx +82 -48
  45. package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
  46. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  47. package/src/client-v2/locale.ts +21 -0
  48. package/src/client-v2/pages/GeneralPage.tsx +183 -0
  49. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  50. package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
  51. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  52. package/src/client-v2/pages/UsagePage.tsx +248 -0
  53. package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
  54. package/src/client-v2/pages/api.ts +16 -0
  55. package/src/client-v2/plugin.tsx +62 -4
  56. package/src/constants.ts +21 -0
  57. package/src/locale/en-US.json +105 -10
  58. package/src/locale/vi-VN.json +105 -0
  59. package/src/locale/zh-CN.json +105 -10
  60. package/src/server/__tests__/app-observability.test.ts +98 -0
  61. package/src/server/__tests__/billing-quota.test.ts +134 -0
  62. package/src/server/__tests__/billing.test.ts +33 -0
  63. package/src/server/__tests__/models.test.ts +74 -0
  64. package/src/server/__tests__/request-body.test.ts +310 -0
  65. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  66. package/src/server/__tests__/usage-monitor.test.ts +63 -0
  67. package/src/server/__tests__/usage-route.test.ts +4 -0
  68. package/src/server/billing.ts +387 -0
  69. package/src/server/collections/ai-api-config.ts +69 -51
  70. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  71. package/src/server/collections/ai-api-model-prices.ts +25 -0
  72. package/src/server/collections/ai-api-usage-records.ts +9 -0
  73. package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
  74. package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
  75. package/src/server/plugin.ts +47 -5
  76. package/src/server/resource/ai-api-config.ts +105 -74
  77. package/src/server/resource/ai-api-usage-monitor.ts +74 -0
  78. package/src/server/routes/agent-completions.ts +77 -62
  79. package/src/server/routes/auth.ts +14 -1
  80. package/src/server/routes/chat-completions.ts +275 -6
  81. package/src/server/routes/completions.ts +27 -4
  82. package/src/server/routes/models.ts +290 -195
  83. package/src/server/routes/router.ts +152 -27
  84. package/src/server/usage.ts +19 -1
  85. package/src/server/utils/app-observability.ts +105 -0
  86. package/src/server/utils/streaming.ts +13 -1
  87. package/src/server/validation.ts +89 -0
  88. package/src/swagger.ts +38 -1
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client/950.83390c5f1d5a97fb.js +0 -10
  91. package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
  92. 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
+ });
@@ -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. Register raw Koa middleware for OpenAI-compatible endpoints
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: `pm.${this.name}.configuration`,
62
- actions: ['aiApiConfig:*', 'aiApiRolePermissions:*'],
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
- * 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
+ 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
- // Intercept write() translate NocoBase SSE → OpenAI SSE
285
- (ctx.res as any).write = (data: Buffer | string): boolean => {
286
- pendingSse += typeof data === 'string' ? data : data.toString('utf8');
287
- const frames = pendingSse.split('\n\n');
288
- pendingSse = frames.pop() || '';
289
-
290
- for (const frame of frames) {
291
- for (const line of frame.split('\n')) {
292
- const trimmed = line.trim();
293
- if (!trimmed.startsWith('data: ')) continue;
294
-
295
- const jsonStr = trimmed.substring(6);
296
- if (!jsonStr) continue;
297
-
298
- try {
299
- const event = JSON.parse(jsonStr);
300
-
301
- if (event.type === 'content' && event.body) {
302
- // Content chunk — forward as OpenAI delta
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: { content: String(event.body) },
319
+ delta: { tool_calls: chunks },
309
320
  }),
310
321
  ),
311
322
  );
312
- } else if (event.type === 'tool_call_chunks' && Array.isArray(event.body)) {
313
- const chunks = toOpenAIToolCallChunks(event.body);
314
- if (chunks.length) {
315
- sawToolCalls = true;
316
- originalWrite(
317
- formatSSE(
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
- error: {
345
- message: String(event.body),
346
- type: 'server_error',
347
- code: 'agent_error',
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
- // stream_start, stream_end, tool_call_status, web_search,
353
- // reasoning and new_message are NocoBase-only events and are ignored.
354
- // These are NocoBase-internal events not part of the OpenAI protocol.
355
- } catch {
356
- // Non-JSON SSE line — ignore
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
- ctx.state.currentRole = roleNames.includes(requestedRole) ? requestedRole : roleNames[0];
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;