plugin-ai-api 1.0.20 → 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 (72) 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.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  5. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  6. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  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.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  11. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  12. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  13. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  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 +28 -9
  18. package/dist/locale/vi-VN.json +20 -1
  19. package/dist/locale/zh-CN.json +20 -1
  20. package/dist/server/collections/ai-api-config.js +6 -0
  21. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  22. package/dist/server/plugin.js +13 -1
  23. package/dist/server/resource/ai-api-config.js +17 -0
  24. package/dist/server/routes/agent-completions.js +62 -51
  25. package/dist/server/routes/auth.js +11 -1
  26. package/dist/server/routes/chat-completions.js +145 -4
  27. package/dist/server/routes/completions.js +8 -1
  28. package/dist/server/routes/models.js +78 -20
  29. package/dist/server/routes/router.js +94 -22
  30. package/dist/server/usage.js +2 -0
  31. package/dist/server/utils/app-observability.js +110 -0
  32. package/dist/server/utils/streaming.js +15 -1
  33. package/dist/server/validation.js +18 -0
  34. package/dist/swagger.js +32 -1
  35. package/package.json +1 -1
  36. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  37. package/src/client/locale.ts +11 -21
  38. package/src/client/plugin.tsx +17 -8
  39. package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
  40. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  41. package/src/client-v2/locale.ts +21 -1
  42. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  43. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  44. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  45. package/src/client-v2/plugin.tsx +41 -1
  46. package/src/constants.ts +21 -0
  47. package/src/locale/en-US.json +28 -9
  48. package/src/locale/vi-VN.json +20 -1
  49. package/src/locale/zh-CN.json +20 -1
  50. package/src/server/__tests__/app-observability.test.ts +98 -0
  51. package/src/server/__tests__/models.test.ts +74 -0
  52. package/src/server/__tests__/request-body.test.ts +310 -0
  53. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  54. package/src/server/collections/ai-api-config.ts +6 -0
  55. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  56. package/src/server/plugin.ts +24 -4
  57. package/src/server/resource/ai-api-config.ts +23 -0
  58. package/src/server/routes/agent-completions.ts +77 -62
  59. package/src/server/routes/auth.ts +14 -1
  60. package/src/server/routes/chat-completions.ts +262 -4
  61. package/src/server/routes/completions.ts +14 -2
  62. package/src/server/routes/models.ts +290 -195
  63. package/src/server/routes/router.ts +136 -26
  64. package/src/server/usage.ts +2 -0
  65. package/src/server/utils/app-observability.ts +105 -0
  66. package/src/server/utils/streaming.ts +13 -1
  67. package/src/server/validation.ts +27 -0
  68. package/src/swagger.ts +38 -1
  69. package/dist/client/302.25edd5d75460acbf.js +0 -10
  70. package/dist/client/778.5c452944cb747975.js +0 -10
  71. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  72. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -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");
@@ -37,6 +40,7 @@ var import_streaming = require("../utils/streaming");
37
40
  var import_role_permission = require("../middleware/role-permission");
38
41
  var import_usage = require("../usage");
39
42
  var import_billing = require("../billing");
43
+ var import_app_observability = require("../utils/app-observability");
40
44
  async function handleChatCompletions(ctx, plugin) {
41
45
  var _a;
42
46
  const body = ctx.request.body;
@@ -50,6 +54,28 @@ async function handleChatCompletions(ctx, plugin) {
50
54
  ctx.body = (0, import_openai_format.toOpenAIError)(400, "'messages' must be a non-empty array", "invalid_request_error", "missing_messages");
51
55
  return;
52
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
+ }
53
79
  if (body.n !== void 0 && body.n !== null && body.n !== 1) {
54
80
  ctx.status = 400;
55
81
  ctx.body = (0, import_openai_format.toOpenAIError)(
@@ -157,7 +183,7 @@ async function handleChatCompletions(ctx, plugin) {
157
183
  }
158
184
  const langchainMessages = messages.map((msg) => {
159
185
  const role = msg.role === "assistant" ? "ai" : msg.role;
160
- const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
186
+ const content = normalizeMessageContent(msg.content);
161
187
  if (msg.role === "assistant" && msg.tool_calls) {
162
188
  return {
163
189
  role,
@@ -268,6 +294,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
268
294
  content = (textPart == null ? void 0 : textPart.text) || "";
269
295
  }
270
296
  if (content) {
297
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
271
298
  await (0, import_streaming.writeResponse)(
272
299
  ctx,
273
300
  (0, import_openai_format.formatSSE)(
@@ -281,6 +308,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
281
308
  }
282
309
  const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
283
310
  if (toolCallChunks.length) {
311
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
284
312
  finishReason = "tool_calls";
285
313
  await (0, import_streaming.writeResponse)(
286
314
  ctx,
@@ -307,6 +335,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
307
335
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
308
336
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
309
337
  } catch (err) {
338
+ const cancelled = (0, import_streaming.isClientDisconnected)(ctx, err);
310
339
  ctx.log.error("AI API streaming error:", err);
311
340
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
312
341
  await (0, import_streaming.writeResponse)(
@@ -320,7 +349,11 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
320
349
  );
321
350
  }
322
351
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
323
- 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
+ };
324
357
  } finally {
325
358
  requestAbort.dispose();
326
359
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -329,6 +362,111 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
329
362
  function getErrorMessage(error, fallback) {
330
363
  return error instanceof Error && error.message ? error.message : fallback;
331
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
+ }
332
470
  const GATEWAY_MANAGED_PARAMETERS = /* @__PURE__ */ new Set(["model", "messages", "tools", "tool_choice", "stream", "n"]);
333
471
  function getProviderRequestParameters(body) {
334
472
  return Object.fromEntries(
@@ -399,6 +537,9 @@ function serializeToolArguments(value) {
399
537
  // Annotate the CommonJS export names for ESM import in node:
400
538
  0 && (module.exports = {
401
539
  applyProviderRequestParameters,
540
+ findContentBlockProblem,
541
+ findMessageProblem,
402
542
  getProviderRequestParameters,
403
- handleChatCompletions
543
+ handleChatCompletions,
544
+ normalizeMessageContent
404
545
  });
@@ -34,6 +34,7 @@ var import_resolve_service = require("../utils/resolve-service");
34
34
  var import_streaming = require("../utils/streaming");
35
35
  var import_usage = require("../usage");
36
36
  var import_billing = require("../billing");
37
+ var import_app_observability = require("../utils/app-observability");
37
38
  async function handleCompletions(ctx, plugin) {
38
39
  var _a;
39
40
  const body = ctx.request.body;
@@ -215,6 +216,7 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
215
216
  text = (textPart == null ? void 0 : textPart.text) || "";
216
217
  }
217
218
  if (text) {
219
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
218
220
  await (0, import_streaming.writeResponse)(
219
221
  ctx,
220
222
  (0, import_openai_format.formatSSE)({
@@ -261,6 +263,7 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
261
263
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
262
264
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
263
265
  } catch (err) {
266
+ const cancelled = (0, import_streaming.isClientDisconnected)(ctx, err);
264
267
  ctx.log.error("AI API completions streaming error:", err);
265
268
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
266
269
  await (0, import_streaming.writeResponse)(
@@ -274,7 +277,11 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
274
277
  );
275
278
  }
276
279
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
277
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: "stream_error" };
280
+ ctx.state.aiApiStreamResult = {
281
+ succeeded: false,
282
+ id: completionId,
283
+ errorCode: cancelled ? "client_disconnected" : "stream_error"
284
+ };
278
285
  } finally {
279
286
  requestAbort.dispose();
280
287
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -26,6 +26,7 @@ var __copyProps = (to, from, except, desc) => {
26
26
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
27
  var models_exports = {};
28
28
  __export(models_exports, {
29
+ buildModelObject: () => buildModelObject,
29
30
  handleGetModel: () => handleGetModel,
30
31
  handleListModels: () => handleListModels
31
32
  });
@@ -49,6 +50,7 @@ async function handleListModels(ctx, plugin) {
49
50
  filter,
50
51
  sort: "sort"
51
52
  });
53
+ const metadataMap = await loadModelMetadata(ctx);
52
54
  const now = Math.floor(Date.now() / 1e3);
53
55
  const models = [];
54
56
  for (const service of services) {
@@ -56,14 +58,10 @@ async function handleListModels(ctx, plugin) {
56
58
  const enabledModels = resolveEnabledModels(service);
57
59
  const serviceLabel = service.title || service.name;
58
60
  for (const model of enabledModels) {
59
- models.push({
60
- // Use "serviceName/modelId" format so the ID can be used directly in
61
- // POST /v1/chat/completions without ambiguity in multi-service setups.
62
- id: `${service.name}/${model.value}`,
63
- object: "model",
64
- created: now,
65
- owned_by: serviceLabel
66
- });
61
+ const fullId = `${service.name}/${model.value}`;
62
+ const meta = metadataMap.get(fullId);
63
+ if (meta && meta.enabled === false) continue;
64
+ models.push(buildModelObject(fullId, now, serviceLabel, meta));
67
65
  }
68
66
  }
69
67
  ctx.status = 200;
@@ -89,6 +87,7 @@ async function handleGetModel(ctx, modelId, plugin) {
89
87
  filter,
90
88
  sort: "sort"
91
89
  });
90
+ const metadataMap = await loadModelMetadata(ctx);
92
91
  const now = Math.floor(Date.now() / 1e3);
93
92
  let found = null;
94
93
  for (const service of services) {
@@ -98,12 +97,9 @@ async function handleGetModel(ctx, modelId, plugin) {
98
97
  for (const model of enabledModels) {
99
98
  const fullId = `${service.name}/${model.value}`;
100
99
  if (fullId === modelId || model.value === modelId) {
101
- found = {
102
- id: fullId,
103
- object: "model",
104
- created: now,
105
- owned_by: serviceLabel
106
- };
100
+ const meta = metadataMap.get(fullId);
101
+ if (meta && meta.enabled === false) continue;
102
+ found = buildModelObject(fullId, now, serviceLabel, meta);
107
103
  break;
108
104
  }
109
105
  }
@@ -122,6 +118,61 @@ async function handleGetModel(ctx, modelId, plugin) {
122
118
  ctx.body = (0, import_openai_format.toOpenAIError)(500, "Failed to retrieve model", "server_error");
123
119
  }
124
120
  }
121
+ async function loadModelMetadata(ctx) {
122
+ var _a, _b;
123
+ const map = /* @__PURE__ */ new Map();
124
+ try {
125
+ const rows = await ctx.db.getRepository("aiApiModelMetadata").find();
126
+ for (const row of rows) {
127
+ const service = row.get("llmService");
128
+ const model = row.get("model");
129
+ if (!service || !model) continue;
130
+ map.set(`${service}/${model}`, {
131
+ contextWindow: row.get("contextWindow"),
132
+ maxCompletionTokens: row.get("maxCompletionTokens"),
133
+ ownedByOverride: row.get("ownedByOverride"),
134
+ displayName: row.get("displayName"),
135
+ description: row.get("description"),
136
+ enabled: row.get("enabled")
137
+ });
138
+ }
139
+ } catch (err) {
140
+ (_b = (_a = ctx.log) == null ? void 0 : _a.warn) == null ? void 0 : _b.call(_a, "AI API model metadata unavailable, skipping overrides:", err);
141
+ }
142
+ return map;
143
+ }
144
+ function buildModelObject(fullId, created, serviceLabel, meta) {
145
+ const model = {
146
+ id: fullId,
147
+ object: "model",
148
+ created,
149
+ owned_by: (meta == null ? void 0 : meta.ownedByOverride) || serviceLabel
150
+ };
151
+ const contextWindow = toPositiveInt(meta == null ? void 0 : meta.contextWindow);
152
+ if (contextWindow !== null) {
153
+ model.context_window = contextWindow;
154
+ model.context_length = contextWindow;
155
+ }
156
+ const maxCompletionTokens = toPositiveInt(meta == null ? void 0 : meta.maxCompletionTokens);
157
+ if (maxCompletionTokens !== null) {
158
+ model.max_completion_tokens = maxCompletionTokens;
159
+ }
160
+ if (meta == null ? void 0 : meta.displayName) {
161
+ model.display_name = meta.displayName;
162
+ model.name = meta.displayName;
163
+ }
164
+ if (meta == null ? void 0 : meta.description) {
165
+ model.description = meta.description;
166
+ }
167
+ if (meta) {
168
+ model.active = meta.enabled !== false;
169
+ }
170
+ return model;
171
+ }
172
+ function toPositiveInt(value) {
173
+ const n = Number(value);
174
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
175
+ }
125
176
  async function getPluginConfig(ctx) {
126
177
  return ctx.db.getRepository("aiApiConfig").findOne();
127
178
  }
@@ -142,16 +193,23 @@ function resolveEnabledModels(service) {
142
193
  return getRecommendedModelsForProvider(service.provider);
143
194
  }
144
195
  function getRecommendedModelsForProvider(provider) {
145
- try {
146
- const { getRecommendedModels } = require("@nocobase/plugin-ai/src/common/recommended-models");
147
- const models = getRecommendedModels(provider);
148
- return Array.isArray(models) ? models : [];
149
- } catch {
150
- return [];
196
+ const modulePaths = [
197
+ "@nocobase/plugin-ai/dist/common/recommended-models",
198
+ "@nocobase/plugin-ai/src/common/recommended-models"
199
+ ];
200
+ for (const modulePath of modulePaths) {
201
+ try {
202
+ const { getRecommendedModels } = require(modulePath);
203
+ const models = getRecommendedModels(provider);
204
+ return Array.isArray(models) ? models : [];
205
+ } catch {
206
+ }
151
207
  }
208
+ return [];
152
209
  }
153
210
  // Annotate the CommonJS export names for ESM import in node:
154
211
  0 && (module.exports = {
212
+ buildModelObject,
155
213
  handleGetModel,
156
214
  handleListModels
157
215
  });
@@ -36,7 +36,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
36
36
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
37
37
  var router_exports = {};
38
38
  __export(router_exports, {
39
- createAiLlmRouter: () => createAiLlmRouter
39
+ AI_LLM_PREFIX: () => AI_LLM_PREFIX,
40
+ MAX_REQUEST_BODY_MB_LIMIT: () => MAX_REQUEST_BODY_MB_LIMIT,
41
+ createAiLlmRouter: () => createAiLlmRouter,
42
+ getRawBody: () => getRawBody,
43
+ normalizeMaxRequestBodyMb: () => normalizeMaxRequestBodyMb
40
44
  });
41
45
  module.exports = __toCommonJS(router_exports);
42
46
  var import_crypto = __toESM(require("crypto"));
@@ -52,7 +56,9 @@ var import_role_permission = require("../middleware/role-permission");
52
56
  var import_usage = require("../usage");
53
57
  var import_streaming = require("../utils/streaming");
54
58
  var import_billing = require("../billing");
59
+ var import_app_observability = require("../utils/app-observability");
55
60
  const API_PREFIX = "/api/ai-llm/v1";
61
+ const AI_LLM_PREFIX = API_PREFIX;
56
62
  function createAiLlmRouter(plugin) {
57
63
  const checkRateLimit = (0, import_rate_limit.createRateLimitMiddleware)(plugin.rateLimiter);
58
64
  return async (ctx, next) => {
@@ -77,18 +83,6 @@ function createAiLlmRouter(plugin) {
77
83
  }
78
84
  const requestId = `req-${import_crypto.default.randomBytes(12).toString("hex")}`;
79
85
  ctx.set("X-Request-Id", requestId);
80
- if (method === "POST" && !ctx.request.body) {
81
- try {
82
- const rawBody = await getRawBody(ctx);
83
- ctx.request.body = JSON.parse(rawBody);
84
- } catch (bodyErr) {
85
- const status = bodyErr && typeof bodyErr === "object" && "statusCode" in bodyErr && bodyErr.statusCode === 413 ? 413 : 400;
86
- const message = status === 413 ? "Request body too large (max 10 MB)" : "Invalid JSON in request body";
87
- ctx.status = status;
88
- ctx.body = (0, import_openai_format.toOpenAIError)(status, message, "invalid_request_error");
89
- return;
90
- }
91
- }
92
86
  const isAuth = await (0, import_auth.authenticateBearer)(ctx);
93
87
  if (!isAuth) {
94
88
  logRequest(ctx, requestId, "-", "auth_failed", 0);
@@ -104,6 +98,29 @@ function createAiLlmRouter(plugin) {
104
98
  logRequest(ctx, requestId, "-", "rate_limited", 0);
105
99
  return;
106
100
  }
101
+ if (method === "POST" && !ctx.request.body) {
102
+ const maxBodyBytes = await resolveMaxBodyBytes(ctx);
103
+ const declaredLength = Number(ctx.get("Content-Length"));
104
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
105
+ respondBodyTooLarge(ctx, maxBodyBytes);
106
+ logRequest(ctx, requestId, "-", "body_too_large", 0);
107
+ return;
108
+ }
109
+ try {
110
+ const rawBody = await getRawBody(ctx, maxBodyBytes);
111
+ ctx.request.body = JSON.parse(rawBody);
112
+ } catch (bodyErr) {
113
+ const tooLarge = bodyErr && typeof bodyErr === "object" && "statusCode" in bodyErr && bodyErr.statusCode === 413;
114
+ if (tooLarge) {
115
+ respondBodyTooLarge(ctx, maxBodyBytes);
116
+ logRequest(ctx, requestId, "-", "body_too_large", 0);
117
+ return;
118
+ }
119
+ ctx.status = 400;
120
+ ctx.body = (0, import_openai_format.toOpenAIError)(400, "Invalid JSON in request body", "invalid_request_error");
121
+ return;
122
+ }
123
+ }
107
124
  const requestBody = ctx.request.body || {};
108
125
  const model = requestBody.model === void 0 || requestBody.model === null ? "-" : String(requestBody.model);
109
126
  const isUsageEndpoint = method === "POST" && (subPath === "/chat/completions" || subPath === "/completions" || subPath === "/embeddings");
@@ -121,6 +138,16 @@ function createAiLlmRouter(plugin) {
121
138
  };
122
139
  }
123
140
  const t0 = Date.now();
141
+ if (isUsageEndpoint) {
142
+ const service = subPath === "/embeddings" ? "llm.embedding" : resolvedMode === "agent" ? "llm.agent" : subPath === "/completions" ? "llm.completion" : "llm.chat";
143
+ (0, import_app_observability.startAiApiObservation)(ctx, {
144
+ service,
145
+ operation: subPath,
146
+ streaming,
147
+ model: model === "-" ? void 0 : model,
148
+ mode: resolvedMode
149
+ });
150
+ }
124
151
  let usageId;
125
152
  try {
126
153
  usageId = isUsageEndpoint ? await (0, import_usage.startUsageRecord)(ctx, requestId, subPath, model, streaming, resolvedMode) : void 0;
@@ -225,24 +252,65 @@ function createAiLlmRouter(plugin) {
225
252
  ctx.log.error("AI API quota reservation could not be finalized:", billingError);
226
253
  }
227
254
  }
255
+ if (isUsageEndpoint) {
256
+ const streamResult = ctx.state.aiApiStreamResult;
257
+ (0, import_app_observability.finishAiApiObservation)(ctx, {
258
+ status: (streamResult == null ? void 0 : streamResult.errorCode) === "client_disconnected" ? "cancelled" : streamResult ? streamResult.succeeded ? "succeeded" : "failed" : ctx.status >= 200 && ctx.status < 400 ? "succeeded" : ctx.status >= 500 ? "failed" : "rejected",
259
+ errorCode: streamResult == null ? void 0 : streamResult.errorCode
260
+ });
261
+ }
228
262
  }
229
263
  };
230
264
  }
231
- const MAX_BODY_BYTES = 10 * 1024 * 1024;
232
- function getRawBody(ctx) {
265
+ const DEFAULT_MAX_BODY_MB = 10;
266
+ const MAX_REQUEST_BODY_MB_LIMIT = 100;
267
+ function normalizeMaxRequestBodyMb(value) {
268
+ const mb = Number(value);
269
+ if (!Number.isSafeInteger(mb) || mb <= 0) return DEFAULT_MAX_BODY_MB;
270
+ return Math.min(mb, MAX_REQUEST_BODY_MB_LIMIT);
271
+ }
272
+ async function resolveMaxBodyBytes(ctx) {
273
+ var _a, _b;
274
+ let configuredMb;
275
+ try {
276
+ const config = await ctx.db.getRepository("aiApiConfig").findOne();
277
+ configuredMb = config == null ? void 0 : config.get("maxRequestBodyMb");
278
+ } catch (err) {
279
+ (_b = (_a = ctx.log) == null ? void 0 : _a.warn) == null ? void 0 : _b.call(_a, "AI API: could not read maxRequestBodyMb, using default:", err);
280
+ }
281
+ return normalizeMaxRequestBodyMb(configuredMb) * 1024 * 1024;
282
+ }
283
+ function formatMb(bytes) {
284
+ return `${Math.round(bytes / (1024 * 1024))} MB`;
285
+ }
286
+ function respondBodyTooLarge(ctx, maxBodyBytes) {
287
+ ctx.status = 413;
288
+ ctx.body = (0, import_openai_format.toOpenAIError)(
289
+ 413,
290
+ `Request body too large (max ${formatMb(maxBodyBytes)}). Inline base64 images inflate payloads by ~33%; raise "Max request body size" in Settings \u2192 AI API Gateway if needed.`,
291
+ "invalid_request_error"
292
+ );
293
+ }
294
+ function getRawBody(ctx, maxBodyBytes) {
233
295
  return new Promise((resolve, reject) => {
234
- let body = "";
296
+ const chunks = [];
235
297
  let byteCount = 0;
298
+ let aborted = false;
236
299
  ctx.req.on("data", (chunk) => {
300
+ if (aborted) return;
237
301
  byteCount += chunk.length;
238
- if (byteCount > MAX_BODY_BYTES) {
239
- ctx.req.destroy();
240
- reject(Object.assign(new Error("Request body too large (max 10 MB)"), { statusCode: 413 }));
302
+ if (byteCount > maxBodyBytes) {
303
+ aborted = true;
304
+ chunks.length = 0;
305
+ ctx.req.resume();
306
+ reject(Object.assign(new Error(`Request body too large (max ${formatMb(maxBodyBytes)})`), { statusCode: 413 }));
241
307
  return;
242
308
  }
243
- body += chunk.toString();
309
+ chunks.push(chunk);
310
+ });
311
+ ctx.req.on("end", () => {
312
+ if (!aborted) resolve(Buffer.concat(chunks).toString("utf8"));
244
313
  });
245
- ctx.req.on("end", () => resolve(body));
246
314
  ctx.req.on("error", reject);
247
315
  });
248
316
  }
@@ -277,5 +345,9 @@ function logRequest(ctx, requestId, model, status, durationMs) {
277
345
  }
278
346
  // Annotate the CommonJS export names for ESM import in node:
279
347
  0 && (module.exports = {
280
- createAiLlmRouter
348
+ AI_LLM_PREFIX,
349
+ MAX_REQUEST_BODY_MB_LIMIT,
350
+ createAiLlmRouter,
351
+ getRawBody,
352
+ normalizeMaxRequestBodyMb
281
353
  });
@@ -35,6 +35,7 @@ __export(usage_exports, {
35
35
  });
36
36
  module.exports = __toCommonJS(usage_exports);
37
37
  var import_billing = require("./billing");
38
+ var import_app_observability = require("./utils/app-observability");
38
39
  function getAiApiState(ctx) {
39
40
  return ctx.state;
40
41
  }
@@ -60,6 +61,7 @@ function normalizeUsage(value) {
60
61
  function setAiApiUsageResult(ctx, value, metadata = {}) {
61
62
  const usage = normalizeUsage(value);
62
63
  getAiApiState(ctx).aiApiUsageResult = usage ? { source: "provider", usage, ...metadata } : { source: "unavailable", ...metadata };
64
+ (0, import_app_observability.addAiApiUsage)(ctx, usage);
63
65
  return usage;
64
66
  }
65
67
  function setAiApiUsageUnavailable(ctx, gatewayResponseId) {