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
@@ -43,8 +43,11 @@ module.exports = __toCommonJS(plugin_exports);
43
43
  var import_server = require("@nocobase/server");
44
44
  var import_router = require("./routes/router");
45
45
  var import_ai_api_config = __toESM(require("./resource/ai-api-config"));
46
+ var import_ai_api_usage_monitor = __toESM(require("./resource/ai-api-usage-monitor"));
46
47
  var import_rate_limiter = require("./utils/rate-limiter");
47
48
  var import_role_permission = require("./middleware/role-permission");
49
+ var import_validation = require("./validation");
50
+ var import_constants = require("../constants");
48
51
  var import_dayjs = __toESM(require("dayjs"));
49
52
  var import_utc = __toESM(require("dayjs/plugin/utc"));
50
53
  var import_timezone = __toESM(require("dayjs/plugin/timezone"));
@@ -60,10 +63,27 @@ class PluginAiApiServer extends import_server.Plugin {
60
63
  async afterAdd() {
61
64
  }
62
65
  async beforeLoad() {
66
+ this.app.db.on("aiApiModelPrices.beforeSave", async (model) => {
67
+ await (0, import_validation.validateModelPrice)(this.db, model);
68
+ });
69
+ this.app.db.on("aiApiModelMetadata.beforeSave", (model) => {
70
+ (0, import_validation.validateModelMetadata)(model);
71
+ });
72
+ this.app.db.on("aiApiUserQuotaPolicies.beforeSave", (model) => {
73
+ (0, import_validation.validateQuotaPolicy)(model);
74
+ });
63
75
  }
64
76
  async load() {
77
+ this.app.use(
78
+ async (ctx, next) => {
79
+ if (ctx.path.startsWith(import_router.AI_LLM_PREFIX)) ctx.disableBodyParser = true;
80
+ await next();
81
+ },
82
+ { tag: "aiApiDisableBodyParser", before: "bodyParser" }
83
+ );
65
84
  this.app.use((0, import_router.createAiLlmRouter)(this), { after: "idp-oauth-resource-auth", before: "resourcer" });
66
85
  this.app.resourceManager.define(import_ai_api_config.default);
86
+ this.app.resourceManager.define(import_ai_api_usage_monitor.default);
67
87
  this.app.db.on("aiApiRolePermissions.afterSave", (model) => {
68
88
  (0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
69
89
  });
@@ -71,8 +91,19 @@ class PluginAiApiServer extends import_server.Plugin {
71
91
  (0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
72
92
  });
73
93
  this.app.acl.registerSnippet({
74
- name: `pm.${this.name}.configuration`,
75
- actions: ["aiApiConfig:*", "aiApiRolePermissions:*"]
94
+ name: import_constants.AI_API_ACL_SNIPPET,
95
+ actions: [
96
+ "aiApiConfig:*",
97
+ "aiApiRolePermissions:*",
98
+ "aiApiModelPrices:*",
99
+ "aiApiModelMetadata:*",
100
+ "aiApiUserQuotaPolicies:*",
101
+ "aiApiUserQuotaBuckets:list",
102
+ "aiApiUserQuotaBuckets:get",
103
+ "aiApiUsageRecords:list",
104
+ "aiApiUsageRecords:get",
105
+ "aiApiUsageMonitor:summary"
106
+ ]
76
107
  });
77
108
  this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1e3);
78
109
  this.gcInterval.unref();
@@ -84,7 +115,9 @@ class PluginAiApiServer extends import_server.Plugin {
84
115
  values: {
85
116
  defaultAiEmployee: "",
86
117
  enabledLlmServices: [],
87
- rateLimitPerMinute: 60
118
+ rateLimitPerMinute: 60,
119
+ quotaEnabled: false,
120
+ defaultReservationOutputTokens: 4096
88
121
  }
89
122
  });
90
123
  }
@@ -29,6 +29,18 @@ __export(ai_api_config_exports, {
29
29
  default: () => ai_api_config_default
30
30
  });
31
31
  module.exports = __toCommonJS(ai_api_config_exports);
32
+ var import_router = require("../routes/router");
33
+ const DEFAULT_MAX_REQUEST_BODY_MB = 10;
34
+ function coerceMaxRequestBodyMb(value) {
35
+ const mb = Number(value);
36
+ if (!Number.isSafeInteger(mb) || mb <= 0) {
37
+ throw new Error(`maxRequestBodyMb must be a positive integer (1-${import_router.MAX_REQUEST_BODY_MB_LIMIT}).`);
38
+ }
39
+ if (mb > import_router.MAX_REQUEST_BODY_MB_LIMIT) {
40
+ throw new Error(`maxRequestBodyMb cannot exceed ${import_router.MAX_REQUEST_BODY_MB_LIMIT}.`);
41
+ }
42
+ return mb;
43
+ }
32
44
  const aiApiConfigResource = {
33
45
  name: "aiApiConfig",
34
46
  actions: {
@@ -42,6 +54,9 @@ const aiApiConfigResource = {
42
54
  defaultLlmService: "",
43
55
  enabledLlmServices: [],
44
56
  rateLimitPerMinute: 60,
57
+ maxRequestBodyMb: 10,
58
+ quotaEnabled: false,
59
+ defaultReservationOutputTokens: 4096,
45
60
  options: {}
46
61
  }
47
62
  });
@@ -61,6 +76,9 @@ const aiApiConfigResource = {
61
76
  defaultLlmService: values.defaultLlmService ?? "",
62
77
  enabledLlmServices: values.enabledLlmServices ?? [],
63
78
  rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
79
+ maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
80
+ quotaEnabled: values.quotaEnabled ?? false,
81
+ defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
64
82
  options: values.options ?? {}
65
83
  }
66
84
  });
@@ -71,6 +89,13 @@ const aiApiConfigResource = {
71
89
  if (values.defaultLlmService !== void 0) updateData.defaultLlmService = values.defaultLlmService;
72
90
  if (values.enabledLlmServices !== void 0) updateData.enabledLlmServices = values.enabledLlmServices;
73
91
  if (values.rateLimitPerMinute !== void 0) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
92
+ if (values.maxRequestBodyMb !== void 0) {
93
+ updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
94
+ }
95
+ if (values.quotaEnabled !== void 0) updateData.quotaEnabled = values.quotaEnabled;
96
+ if (values.defaultReservationOutputTokens !== void 0) {
97
+ updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
98
+ }
74
99
  if (values.options !== void 0) updateData.options = values.options;
75
100
  await config.update(updateData);
76
101
  }
@@ -0,0 +1,86 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var ai_api_usage_monitor_exports = {};
28
+ __export(ai_api_usage_monitor_exports, {
29
+ default: () => ai_api_usage_monitor_default
30
+ });
31
+ module.exports = __toCommonJS(ai_api_usage_monitor_exports);
32
+ var import_sequelize = require("sequelize");
33
+ function buildWhere(ctx) {
34
+ const params = ctx.action.params;
35
+ const where = {};
36
+ const start = typeof params.start === "string" ? new Date(params.start) : void 0;
37
+ const end = typeof params.end === "string" ? new Date(params.end) : void 0;
38
+ if (start && !Number.isNaN(start.getTime()) || end && !Number.isNaN(end.getTime())) {
39
+ const startedAt = {};
40
+ if (start && !Number.isNaN(start.getTime())) startedAt[import_sequelize.Op.gte] = start;
41
+ if (end && !Number.isNaN(end.getTime())) startedAt[import_sequelize.Op.lte] = end;
42
+ where.startedAt = startedAt;
43
+ }
44
+ if (params.userId !== void 0 && params.userId !== "") where.userId = params.userId;
45
+ if (params.resolvedService) where.resolvedService = params.resolvedService;
46
+ if (params.resolvedModel) where.resolvedModel = params.resolvedModel;
47
+ if (params.status) where.status = params.status;
48
+ return where;
49
+ }
50
+ const aiApiUsageMonitorResource = {
51
+ name: "aiApiUsageMonitor",
52
+ actions: {
53
+ async summary(ctx, next) {
54
+ const model = ctx.db.getCollection("aiApiUsageRecords").model;
55
+ const where = buildWhere(ctx);
56
+ const totals = await model.findOne({
57
+ attributes: [
58
+ [(0, import_sequelize.fn)("COUNT", (0, import_sequelize.col)("id")), "requestCount"],
59
+ [(0, import_sequelize.fn)("COALESCE", (0, import_sequelize.fn)("SUM", (0, import_sequelize.col)("inputTokens")), 0), "inputTokens"],
60
+ [(0, import_sequelize.fn)("COALESCE", (0, import_sequelize.fn)("SUM", (0, import_sequelize.col)("outputTokens")), 0), "outputTokens"],
61
+ [(0, import_sequelize.fn)("COALESCE", (0, import_sequelize.fn)("SUM", (0, import_sequelize.col)("totalTokens")), 0), "totalTokens"]
62
+ ],
63
+ where,
64
+ raw: true
65
+ });
66
+ const costs = await model.findAll({
67
+ attributes: ["currency", [(0, import_sequelize.fn)("COALESCE", (0, import_sequelize.fn)("SUM", (0, import_sequelize.col)("estimatedCost")), 0), "totalCost"]],
68
+ where: { ...where, estimatedCost: { [import_sequelize.Op.ne]: null } },
69
+ group: ["currency"],
70
+ raw: true
71
+ });
72
+ ctx.body = {
73
+ requestCount: Number((totals == null ? void 0 : totals.requestCount) ?? 0),
74
+ inputTokens: Number((totals == null ? void 0 : totals.inputTokens) ?? 0),
75
+ outputTokens: Number((totals == null ? void 0 : totals.outputTokens) ?? 0),
76
+ totalTokens: Number((totals == null ? void 0 : totals.totalTokens) ?? 0),
77
+ costsByCurrency: costs.map((item) => ({
78
+ currency: item.currency || "USD",
79
+ totalCost: String(item.totalCost ?? 0)
80
+ }))
81
+ };
82
+ await next();
83
+ }
84
+ }
85
+ };
86
+ var ai_api_usage_monitor_default = aiApiUsageMonitorResource;
@@ -35,6 +35,7 @@ var import_role_permission = require("../middleware/role-permission");
35
35
  var import_streaming = require("../utils/streaming");
36
36
  var import_ai_employee_runtime = require("../utils/ai-employee-runtime");
37
37
  var import_usage = require("../usage");
38
+ var import_app_observability = require("../utils/app-observability");
38
39
  async function handleAgentCompletions(ctx, plugin) {
39
40
  var _a;
40
41
  const body = ctx.request.body;
@@ -201,74 +202,83 @@ async function handleAgentCompletions(ctx, plugin) {
201
202
  ctx.res.write(args[0]);
202
203
  }
203
204
  };
204
- ctx.res.write = (data) => {
205
+ const processSseFrame = (frame) => {
205
206
  var _a2;
206
- pendingSse += typeof data === "string" ? data : data.toString("utf8");
207
- const frames = pendingSse.split("\n\n");
208
- pendingSse = frames.pop() || "";
209
- for (const frame of frames) {
210
- for (const line of frame.split("\n")) {
211
- const trimmed = line.trim();
212
- if (!trimmed.startsWith("data: ")) continue;
213
- const jsonStr = trimmed.substring(6);
214
- if (!jsonStr) continue;
215
- try {
216
- const event = JSON.parse(jsonStr);
217
- if (event.type === "content" && event.body) {
207
+ for (const line of frame.split("\n")) {
208
+ const trimmed = line.trim();
209
+ if (!trimmed.startsWith("data: ")) continue;
210
+ const jsonStr = trimmed.substring(6);
211
+ if (!jsonStr) continue;
212
+ try {
213
+ const event = JSON.parse(jsonStr);
214
+ if (event.type === "content" && event.body) {
215
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
216
+ originalWrite(
217
+ (0, import_openai_format.formatSSE)(
218
+ (0, import_openai_format.toOpenAIStreamChunk)({
219
+ id: completionId,
220
+ model: body.model,
221
+ delta: { content: String(event.body) }
222
+ })
223
+ )
224
+ );
225
+ } else if (event.type === "tool_call_chunks" && Array.isArray(event.body)) {
226
+ const chunks = toOpenAIToolCallChunks(event.body);
227
+ if (chunks.length) {
228
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
229
+ sawToolCalls = true;
218
230
  originalWrite(
219
231
  (0, import_openai_format.formatSSE)(
220
232
  (0, import_openai_format.toOpenAIStreamChunk)({
221
233
  id: completionId,
222
234
  model: body.model,
223
- delta: { content: String(event.body) }
235
+ delta: { tool_calls: chunks }
224
236
  })
225
237
  )
226
238
  );
227
- } else if (event.type === "tool_call_chunks" && Array.isArray(event.body)) {
228
- const chunks = toOpenAIToolCallChunks(event.body);
229
- if (chunks.length) {
230
- sawToolCalls = true;
231
- originalWrite(
232
- (0, import_openai_format.formatSSE)(
233
- (0, import_openai_format.toOpenAIStreamChunk)({
234
- id: completionId,
235
- model: body.model,
236
- delta: { tool_calls: chunks }
237
- })
238
- )
239
- );
240
- }
241
- } else if (!sawToolCalls && event.type === "tool_calls" && Array.isArray((_a2 = event.body) == null ? void 0 : _a2.toolCalls)) {
242
- const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
243
- if (chunks.length) {
244
- sawToolCalls = true;
245
- originalWrite(
246
- (0, import_openai_format.formatSSE)(
247
- (0, import_openai_format.toOpenAIStreamChunk)({
248
- id: completionId,
249
- model: body.model,
250
- delta: { tool_calls: chunks }
251
- })
252
- )
253
- );
254
- }
255
- } else if (event.type === "error" && event.body) {
239
+ }
240
+ } else if (!sawToolCalls && event.type === "tool_calls" && Array.isArray((_a2 = event.body) == null ? void 0 : _a2.toolCalls)) {
241
+ const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
242
+ if (chunks.length) {
243
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
244
+ sawToolCalls = true;
256
245
  originalWrite(
257
- (0, import_openai_format.formatSSE)({
258
- error: {
259
- message: String(event.body),
260
- type: "server_error",
261
- code: "agent_error"
262
- }
263
- })
246
+ (0, import_openai_format.formatSSE)(
247
+ (0, import_openai_format.toOpenAIStreamChunk)({
248
+ id: completionId,
249
+ model: body.model,
250
+ delta: { tool_calls: chunks }
251
+ })
252
+ )
264
253
  );
265
254
  }
266
- } catch {
255
+ } else if (event.type === "error" && event.body) {
256
+ originalWrite(
257
+ (0, import_openai_format.formatSSE)({
258
+ error: {
259
+ message: String(event.body),
260
+ type: "server_error",
261
+ code: "agent_error"
262
+ }
263
+ })
264
+ );
267
265
  }
266
+ } catch {
268
267
  }
269
268
  }
269
+ };
270
+ ctx.res.write = (data) => {
271
+ pendingSse += typeof data === "string" ? data : data.toString("utf8");
272
+ const frames = pendingSse.split("\n\n");
273
+ pendingSse = frames.pop() || "";
274
+ for (const frame of frames) processSseFrame(frame);
270
275
  return true;
271
276
  };
277
+ const flushPendingSse = () => {
278
+ if (!pendingSse.trim()) return;
279
+ processSseFrame(pendingSse);
280
+ pendingSse = "";
281
+ };
272
282
  try {
273
283
  const aiEmployee = new AIEmployee(
274
284
  (0, import_ai_employee_runtime.createAIEmployeeOptions)(ctx, employeeRecord, sessionId, {
@@ -291,6 +301,7 @@ async function handleAgentCompletions(ctx, plugin) {
291
301
  ctx.req.off("aborted", abortAgent);
292
302
  ctx.res.off("close", abortAgent);
293
303
  if (streamSucceeded && !ctx.res.destroyed) {
304
+ flushPendingSse();
294
305
  originalWrite(
295
306
  (0, import_openai_format.formatSSE)(
296
307
  (0, import_openai_format.toOpenAIStreamChunk)({
@@ -57,7 +57,17 @@ async function authenticateBearer(ctx) {
57
57
  const rolesRepository2 = ctx.db.getRepository("users.roles", ctx.state.currentUser.id);
58
58
  const roles2 = await rolesRepository2.find({ fields: ["name"] });
59
59
  const roleNames2 = roles2.map((role) => role.name);
60
- ctx.state.currentRole = roleNames2.includes(requestedRole) ? requestedRole : roleNames2[0];
60
+ if (requestedRole && !roleNames2.includes(requestedRole)) {
61
+ ctx.status = 403;
62
+ ctx.body = (0, import_openai_format.toOpenAIError)(
63
+ 403,
64
+ `Requested role '${requestedRole}' is not assigned to this user`,
65
+ "permission_denied",
66
+ "role_not_permitted"
67
+ );
68
+ return false;
69
+ }
70
+ ctx.state.currentRole = requestedRole || roleNames2[0];
61
71
  ctx.state.currentRoles = ctx.state.currentRole ? [ctx.state.currentRole] : roleNames2;
62
72
  }
63
73
  return true;
@@ -27,8 +27,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
27
27
  var chat_completions_exports = {};
28
28
  __export(chat_completions_exports, {
29
29
  applyProviderRequestParameters: () => applyProviderRequestParameters,
30
+ findContentBlockProblem: () => findContentBlockProblem,
31
+ findMessageProblem: () => findMessageProblem,
30
32
  getProviderRequestParameters: () => getProviderRequestParameters,
31
- handleChatCompletions: () => handleChatCompletions
33
+ handleChatCompletions: () => handleChatCompletions,
34
+ normalizeMessageContent: () => normalizeMessageContent
32
35
  });
33
36
  module.exports = __toCommonJS(chat_completions_exports);
34
37
  var import_openai_format = require("../utils/openai-format");
@@ -36,6 +39,8 @@ var import_resolve_service = require("../utils/resolve-service");
36
39
  var import_streaming = require("../utils/streaming");
37
40
  var import_role_permission = require("../middleware/role-permission");
38
41
  var import_usage = require("../usage");
42
+ var import_billing = require("../billing");
43
+ var import_app_observability = require("../utils/app-observability");
39
44
  async function handleChatCompletions(ctx, plugin) {
40
45
  var _a;
41
46
  const body = ctx.request.body;
@@ -49,6 +54,28 @@ async function handleChatCompletions(ctx, plugin) {
49
54
  ctx.body = (0, import_openai_format.toOpenAIError)(400, "'messages' must be a non-empty array", "invalid_request_error", "missing_messages");
50
55
  return;
51
56
  }
57
+ const messageProblem = findMessageProblem(body.messages);
58
+ if (messageProblem) {
59
+ ctx.status = 400;
60
+ ctx.body = (0, import_openai_format.toOpenAIError)(
61
+ 400,
62
+ `Invalid messages[${messageProblem.index}]: ${messageProblem.reason}.`,
63
+ "invalid_request_error",
64
+ "invalid_message"
65
+ );
66
+ return;
67
+ }
68
+ const blockProblem = findContentBlockProblem(body.messages);
69
+ if (blockProblem) {
70
+ ctx.status = 400;
71
+ ctx.body = (0, import_openai_format.toOpenAIError)(
72
+ 400,
73
+ `Invalid content block in messages[${blockProblem.index}]: ${blockProblem.reason}.`,
74
+ "invalid_request_error",
75
+ "invalid_content_block"
76
+ );
77
+ return;
78
+ }
52
79
  if (body.n !== void 0 && body.n !== null && body.n !== 1) {
53
80
  ctx.status = 400;
54
81
  ctx.body = (0, import_openai_format.toOpenAIError)(
@@ -111,6 +138,7 @@ async function handleChatCompletions(ctx, plugin) {
111
138
  ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
112
139
  return;
113
140
  }
141
+ await (0, import_billing.prepareLlmBilling)(ctx, resolved);
114
142
  const providerRequestParameters = getProviderRequestParameters(body);
115
143
  const modelOptions = {
116
144
  model: modelId,
@@ -155,7 +183,7 @@ async function handleChatCompletions(ctx, plugin) {
155
183
  }
156
184
  const langchainMessages = messages.map((msg) => {
157
185
  const role = msg.role === "assistant" ? "ai" : msg.role;
158
- const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
186
+ const content = normalizeMessageContent(msg.content);
159
187
  if (msg.role === "assistant" && msg.tool_calls) {
160
188
  return {
161
189
  role,
@@ -173,6 +201,7 @@ async function handleChatCompletions(ctx, plugin) {
173
201
  const baseModel = provider.createModel();
174
202
  applyProviderRequestParameters(baseModel, providerRequestParameters);
175
203
  const chatModel = bindRequestTools(baseModel, body.tools, body.tool_choice, providerRequestParameters);
204
+ (0, import_billing.markLlmProviderAttempted)(ctx);
176
205
  if (stream) {
177
206
  await handleStreamingCompletion(
178
207
  ctx,
@@ -195,8 +224,15 @@ async function handleChatCompletions(ctx, plugin) {
195
224
  } catch (err) {
196
225
  ctx.log.error("AI API chat completions error:", err);
197
226
  if (!ctx.res.headersSent) {
198
- ctx.status = 500;
199
- ctx.body = (0, import_openai_format.toOpenAIError)(500, getErrorMessage(err, "Internal server error"), "server_error");
227
+ const isQuotaError = err instanceof import_billing.AiApiQuotaError;
228
+ ctx.status = isQuotaError ? 429 : 500;
229
+ if (isQuotaError) ctx.set("X-RateLimit-Reason", err.code);
230
+ ctx.body = (0, import_openai_format.toOpenAIError)(
231
+ ctx.status,
232
+ getErrorMessage(err, "Internal server error"),
233
+ isQuotaError ? "quota_error" : "server_error",
234
+ isQuotaError ? err.code : void 0
235
+ );
200
236
  }
201
237
  }
202
238
  }
@@ -258,6 +294,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
258
294
  content = (textPart == null ? void 0 : textPart.text) || "";
259
295
  }
260
296
  if (content) {
297
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
261
298
  await (0, import_streaming.writeResponse)(
262
299
  ctx,
263
300
  (0, import_openai_format.formatSSE)(
@@ -271,6 +308,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
271
308
  }
272
309
  const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
273
310
  if (toolCallChunks.length) {
311
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
274
312
  finishReason = "tool_calls";
275
313
  await (0, import_streaming.writeResponse)(
276
314
  ctx,
@@ -297,6 +335,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
297
335
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
298
336
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
299
337
  } catch (err) {
338
+ const cancelled = (0, import_streaming.isClientDisconnected)(ctx, err);
300
339
  ctx.log.error("AI API streaming error:", err);
301
340
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
302
341
  await (0, import_streaming.writeResponse)(
@@ -310,7 +349,11 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
310
349
  );
311
350
  }
312
351
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
313
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: "stream_error" };
352
+ ctx.state.aiApiStreamResult = {
353
+ succeeded: false,
354
+ id: completionId,
355
+ errorCode: cancelled ? "client_disconnected" : "stream_error"
356
+ };
314
357
  } finally {
315
358
  requestAbort.dispose();
316
359
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -319,6 +362,111 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
319
362
  function getErrorMessage(error, fallback) {
320
363
  return error instanceof Error && error.message ? error.message : fallback;
321
364
  }
365
+ const SUPPORTED_CONTENT_BLOCK_TYPES = /* @__PURE__ */ new Set(["text", "image_url"]);
366
+ const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
367
+ function isDecodableBase64(payload) {
368
+ try {
369
+ return Buffer.from(payload, "base64").toString("base64") === payload;
370
+ } catch {
371
+ return false;
372
+ }
373
+ }
374
+ const SUPPORTED_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "developer", "user", "human", "assistant", "ai", "tool"]);
375
+ function findMessageProblem(messages) {
376
+ for (const [index, message] of messages.entries()) {
377
+ if (!isRecord(message)) return { index, reason: "each message must be an object" };
378
+ const role = typeof message.role === "string" ? message.role : void 0;
379
+ if (!role) return { index, reason: "each message requires a string 'role' field" };
380
+ if (!SUPPORTED_MESSAGE_ROLES.has(role)) {
381
+ return {
382
+ index,
383
+ reason: `role '${role}' is not supported \u2014 use one of ${[...SUPPORTED_MESSAGE_ROLES].join(", ")}`
384
+ };
385
+ }
386
+ if (role === "tool" && typeof message.tool_call_id !== "string") {
387
+ return { index, reason: "a 'tool' message requires a string 'tool_call_id' field" };
388
+ }
389
+ const { content } = message;
390
+ const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
391
+ if (content === void 0 || content === null) {
392
+ if ((role === "assistant" || role === "ai") && hasToolCalls) continue;
393
+ return { index, reason: "each message requires a 'content' field" };
394
+ }
395
+ if (typeof content !== "string" && !Array.isArray(content)) {
396
+ return { index, reason: "'content' must be a string or an array of content blocks" };
397
+ }
398
+ }
399
+ return void 0;
400
+ }
401
+ function findContentBlockProblem(messages) {
402
+ for (const [index, message] of messages.entries()) {
403
+ const content = isRecord(message) ? message.content : void 0;
404
+ if (!Array.isArray(content)) continue;
405
+ for (const block of content) {
406
+ if (typeof block === "string") continue;
407
+ const reason = describeContentBlockProblem(block);
408
+ if (reason) return { index, reason };
409
+ }
410
+ }
411
+ return void 0;
412
+ }
413
+ function describeContentBlockProblem(block) {
414
+ if (!isRecord(block)) return "each content block must be an object";
415
+ const type = typeof block.type === "string" ? block.type : void 0;
416
+ if (!type) return "each content block requires a 'type' field";
417
+ if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
418
+ return `content block type '${type}' is not supported \u2014 this gateway forwards 'text' and 'image_url' only. Send documents as text, or inline them as an 'image_url' data URL if the model reads images`;
419
+ }
420
+ if (type === "text") {
421
+ return typeof block.text === "string" ? void 0 : "a 'text' block requires a string 'text' field";
422
+ }
423
+ return describeImageUrlProblem(block.image_url);
424
+ }
425
+ function describeImageUrlProblem(imageUrl) {
426
+ const url = typeof imageUrl === "string" ? imageUrl : isRecord(imageUrl) ? imageUrl.url : void 0;
427
+ if (typeof url !== "string" || url === "") {
428
+ return "an 'image_url' block requires a non-empty 'image_url.url' string";
429
+ }
430
+ if (url.startsWith("data:")) {
431
+ const match = BASE64_DATA_URL_PATTERN.exec(url);
432
+ if (!match) {
433
+ return `malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' with standard base64 (no whitespace or URL-safe characters)`;
434
+ }
435
+ const mimeType = match[1].toLowerCase();
436
+ if (!mimeType.startsWith("image/")) {
437
+ return `data URL MIME type '${mimeType}' is not an image. Only 'image/*' data URLs are forwarded, because providers reject or ignore other types on an 'image_url' block`;
438
+ }
439
+ if (!isDecodableBase64(match[2])) {
440
+ return `base64 payload is not decodable. Check the padding and length \u2014 the data must be a multiple of 4 characters with at most two trailing '='`;
441
+ }
442
+ return void 0;
443
+ }
444
+ let protocol;
445
+ try {
446
+ protocol = new URL(url).protocol;
447
+ } catch {
448
+ return `'${url}' is not a valid URL. Use an http(s) URL or a base64 data URL`;
449
+ }
450
+ if (protocol !== "http:" && protocol !== "https:") {
451
+ return `URL protocol '${protocol}' is not supported. Use an http(s) URL or a base64 data URL`;
452
+ }
453
+ return void 0;
454
+ }
455
+ function normalizeMessageContent(content) {
456
+ if (typeof content === "string") return content;
457
+ if (Array.isArray(content)) {
458
+ return content.map((block) => {
459
+ if (typeof block === "string") return { type: "text", text: block };
460
+ const record = block;
461
+ if ((record == null ? void 0 : record.type) === "image_url" && typeof record.image_url === "string") {
462
+ return { ...record, image_url: { url: record.image_url } };
463
+ }
464
+ return record;
465
+ });
466
+ }
467
+ if (content === null || content === void 0) return "";
468
+ return JSON.stringify(content);
469
+ }
322
470
  const GATEWAY_MANAGED_PARAMETERS = /* @__PURE__ */ new Set(["model", "messages", "tools", "tool_choice", "stream", "n"]);
323
471
  function getProviderRequestParameters(body) {
324
472
  return Object.fromEntries(
@@ -389,6 +537,9 @@ function serializeToolArguments(value) {
389
537
  // Annotate the CommonJS export names for ESM import in node:
390
538
  0 && (module.exports = {
391
539
  applyProviderRequestParameters,
540
+ findContentBlockProblem,
541
+ findMessageProblem,
392
542
  getProviderRequestParameters,
393
- handleChatCompletions
543
+ handleChatCompletions,
544
+ normalizeMessageContent
394
545
  });