plugin-ai-api 1.0.20 → 1.0.23

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 (91) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  3. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  4. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  5. package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  6. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  7. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  10. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  11. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  12. package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  13. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  14. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  15. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  16. package/dist/client-v2/index.js +1 -1
  17. package/dist/constants.js +39 -0
  18. package/dist/externalVersion.js +9 -10
  19. package/dist/locale/en-US.json +39 -9
  20. package/dist/locale/vi-VN.json +31 -1
  21. package/dist/locale/zh-CN.json +31 -1
  22. package/dist/server/collections/ai-api-config.js +6 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  24. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  25. package/dist/server/plugin.js +45 -1
  26. package/dist/server/resource/ai-api-config.js +17 -0
  27. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  28. package/dist/server/routes/agent-completions.js +67 -51
  29. package/dist/server/routes/auth.js +11 -1
  30. package/dist/server/routes/chat-completions.js +174 -20
  31. package/dist/server/routes/completions.js +41 -21
  32. package/dist/server/routes/embeddings.js +6 -14
  33. package/dist/server/routes/models.js +102 -20
  34. package/dist/server/routes/router.js +94 -22
  35. package/dist/server/usage.js +2 -0
  36. package/dist/server/utils/app-observability.js +110 -0
  37. package/dist/server/utils/openai-format.js +17 -3
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/utils/user-permissions.js +160 -0
  40. package/dist/server/validation.js +18 -0
  41. package/dist/swagger.js +36 -4
  42. package/package.json +2 -2
  43. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  44. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  45. package/src/client/locale.ts +11 -21
  46. package/src/client/plugin.tsx +28 -8
  47. package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
  48. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  49. package/src/client-v2/locale.ts +21 -1
  50. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  51. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  52. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  53. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  54. package/src/client-v2/plugin.tsx +50 -1
  55. package/src/constants.ts +28 -0
  56. package/src/locale/en-US.json +39 -9
  57. package/src/locale/vi-VN.json +31 -1
  58. package/src/locale/zh-CN.json +31 -1
  59. package/src/server/__tests__/app-observability.test.ts +98 -0
  60. package/src/server/__tests__/models.test.ts +116 -0
  61. package/src/server/__tests__/openai-format.test.ts +52 -1
  62. package/src/server/__tests__/permission-sync.test.ts +109 -0
  63. package/src/server/__tests__/request-body.test.ts +310 -0
  64. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  65. package/src/server/__tests__/usage-route.test.ts +213 -0
  66. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  67. package/src/server/__tests__/user-permissions.test.ts +284 -0
  68. package/src/server/collections/ai-api-config.ts +6 -0
  69. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  70. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  71. package/src/server/plugin.ts +65 -4
  72. package/src/server/resource/ai-api-config.ts +23 -0
  73. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  74. package/src/server/routes/agent-completions.ts +84 -62
  75. package/src/server/routes/auth.ts +14 -1
  76. package/src/server/routes/chat-completions.ts +294 -20
  77. package/src/server/routes/completions.ts +54 -20
  78. package/src/server/routes/embeddings.ts +10 -15
  79. package/src/server/routes/models.ts +318 -195
  80. package/src/server/routes/router.ts +136 -26
  81. package/src/server/usage.ts +2 -0
  82. package/src/server/utils/app-observability.ts +105 -0
  83. package/src/server/utils/openai-format.ts +26 -0
  84. package/src/server/utils/streaming.ts +13 -1
  85. package/src/server/utils/user-permissions.ts +218 -0
  86. package/src/server/validation.ts +27 -0
  87. package/src/swagger.ts +47 -4
  88. package/dist/client/302.25edd5d75460acbf.js +0 -10
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  91. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -31,11 +31,12 @@ __export(completions_exports, {
31
31
  module.exports = __toCommonJS(completions_exports);
32
32
  var import_openai_format = require("../utils/openai-format");
33
33
  var import_resolve_service = require("../utils/resolve-service");
34
+ var import_user_permissions = require("../utils/user-permissions");
34
35
  var import_streaming = require("../utils/streaming");
35
36
  var import_usage = require("../usage");
36
37
  var import_billing = require("../billing");
38
+ var import_app_observability = require("../utils/app-observability");
37
39
  async function handleCompletions(ctx, plugin) {
38
- var _a;
39
40
  const body = ctx.request.body;
40
41
  if (!(body == null ? void 0 : body.model)) {
41
42
  ctx.status = 400;
@@ -88,20 +89,8 @@ async function handleCompletions(ctx, plugin) {
88
89
  return;
89
90
  }
90
91
  const config = await ctx.db.getRepository("aiApiConfig").findOne();
91
- if ((_a = config == null ? void 0 : config.enabledLlmServices) == null ? void 0 : _a.length) {
92
- const serviceName = service.name;
93
- const serviceTitle = service.title;
94
- const isAllowed = config.enabledLlmServices.some((s) => s === serviceName || s === serviceTitle);
95
- if (!isAllowed) {
96
- ctx.status = 403;
97
- ctx.body = (0, import_openai_format.toOpenAIError)(
98
- 403,
99
- `LLM service '${service.title || service.name}' is not enabled for API access`,
100
- "invalid_request_error",
101
- "model_not_available"
102
- );
103
- return;
104
- }
92
+ if (!await (0, import_user_permissions.enforceModelAccess)(ctx, config == null ? void 0 : config.enabledLlmServices, service, modelId)) {
93
+ return;
105
94
  }
106
95
  const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
107
96
  if (!providerMeta) {
@@ -142,7 +131,14 @@ async function handleCompletions(ctx, plugin) {
142
131
  const chatModel = provider.createModel();
143
132
  (0, import_billing.markLlmProviderAttempted)(ctx);
144
133
  if (stream) {
145
- await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
134
+ await handleStreamingTextCompletion(
135
+ ctx,
136
+ chatModel,
137
+ langchainMessages,
138
+ completionId,
139
+ body.model,
140
+ body.stream_options
141
+ );
146
142
  } else {
147
143
  await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
148
144
  }
@@ -192,7 +188,7 @@ async function handleNonStreamingTextCompletion(ctx, chatModel, messages, comple
192
188
  usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
193
189
  };
194
190
  }
195
- async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName) {
191
+ async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, streamOptions) {
196
192
  ctx.set({
197
193
  "Content-Type": "text/event-stream",
198
194
  "Cache-Control": "no-cache",
@@ -204,7 +200,10 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
204
200
  let usage;
205
201
  let providerRequestId;
206
202
  try {
207
- const stream = await chatModel.stream(messages, { signal: requestAbort.signal });
203
+ const stream = await chatModel.stream(messages, {
204
+ stream_options: { ...streamOptions, include_usage: true },
205
+ signal: requestAbort.signal
206
+ });
208
207
  for await (const chunk of stream) {
209
208
  if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
210
209
  let text = "";
@@ -215,6 +214,7 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
215
214
  text = (textPart == null ? void 0 : textPart.text) || "";
216
215
  }
217
216
  if (text) {
217
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
218
218
  await (0, import_streaming.writeResponse)(
219
219
  ctx,
220
220
  (0, import_openai_format.formatSSE)({
@@ -230,7 +230,8 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
230
230
  logprobs: null,
231
231
  finish_reason: null
232
232
  }
233
- ]
233
+ ],
234
+ usage: null
234
235
  })
235
236
  );
236
237
  }
@@ -254,13 +255,28 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
254
255
  logprobs: null,
255
256
  finish_reason: "stop"
256
257
  }
257
- ]
258
+ ],
259
+ usage: null
258
260
  })
259
261
  );
262
+ if (usage) {
263
+ await (0, import_streaming.writeResponse)(
264
+ ctx,
265
+ (0, import_openai_format.formatSSE)(
266
+ (0, import_openai_format.toOpenAIUsageChunk)({
267
+ id: completionId,
268
+ model: modelName,
269
+ object: "text_completion",
270
+ usage
271
+ })
272
+ )
273
+ );
274
+ }
260
275
  await (0, import_streaming.writeResponse)(ctx, (0, import_openai_format.formatSSEDone)());
261
276
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
262
277
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
263
278
  } catch (err) {
279
+ const cancelled = (0, import_streaming.isClientDisconnected)(ctx, err);
264
280
  ctx.log.error("AI API completions streaming error:", err);
265
281
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
266
282
  await (0, import_streaming.writeResponse)(
@@ -274,7 +290,11 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
274
290
  );
275
291
  }
276
292
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
277
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: "stream_error" };
293
+ ctx.state.aiApiStreamResult = {
294
+ succeeded: false,
295
+ id: completionId,
296
+ errorCode: cancelled ? "client_disconnected" : "stream_error"
297
+ };
278
298
  } finally {
279
299
  requestAbort.dispose();
280
300
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -31,9 +31,9 @@ __export(embeddings_exports, {
31
31
  module.exports = __toCommonJS(embeddings_exports);
32
32
  var import_openai_format = require("../utils/openai-format");
33
33
  var import_resolve_service = require("../utils/resolve-service");
34
+ var import_user_permissions = require("../utils/user-permissions");
34
35
  var import_usage = require("../usage");
35
36
  async function handleEmbeddings(ctx, plugin) {
36
- var _a;
37
37
  const body = ctx.request.body;
38
38
  if (!(body == null ? void 0 : body.model)) {
39
39
  ctx.status = 400;
@@ -102,23 +102,15 @@ async function handleEmbeddings(ctx, plugin) {
102
102
  );
103
103
  return;
104
104
  }
105
+ let globalEnabledServices = [];
105
106
  try {
106
107
  const config = await ctx.db.getRepository("aiApiConfig").findOne();
107
- if ((_a = config == null ? void 0 : config.enabledLlmServices) == null ? void 0 : _a.length) {
108
- const allowed = config.enabledLlmServices.some((s) => s === service.name || s === service.title);
109
- if (!allowed) {
110
- ctx.status = 403;
111
- ctx.body = (0, import_openai_format.toOpenAIError)(
112
- 403,
113
- `LLM service '${service.title || service.name}' is not enabled for API access`,
114
- "invalid_request_error",
115
- "model_not_available"
116
- );
117
- return;
118
- }
119
- }
108
+ globalEnabledServices = (config == null ? void 0 : config.enabledLlmServices) ?? [];
120
109
  } catch {
121
110
  }
111
+ if (!await (0, import_user_permissions.enforceModelAccess)(ctx, globalEnabledServices, service, modelId)) {
112
+ return;
113
+ }
122
114
  const aiPlugin = ctx.app.pm.get("ai");
123
115
  if (!aiPlugin) {
124
116
  ctx.status = 500;
@@ -26,11 +26,13 @@ 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
  });
32
33
  module.exports = __toCommonJS(models_exports);
33
34
  var import_openai_format = require("../utils/openai-format");
35
+ var import_user_permissions = require("../utils/user-permissions");
34
36
  async function handleListModels(ctx, plugin) {
35
37
  var _a;
36
38
  try {
@@ -49,21 +51,25 @@ async function handleListModels(ctx, plugin) {
49
51
  filter,
50
52
  sort: "sort"
51
53
  });
54
+ const scope = await (0, import_user_permissions.resolveUserAccessScope)(ctx);
55
+ if (scope.lookupFailed) {
56
+ respondPermissionCheckFailed(ctx);
57
+ return;
58
+ }
59
+ const metadataMap = await loadModelMetadata(ctx);
52
60
  const now = Math.floor(Date.now() / 1e3);
53
61
  const models = [];
54
62
  for (const service of services) {
55
63
  if (service.enabled === false) continue;
64
+ if (!(0, import_user_permissions.isServiceAllowed)(scope, config == null ? void 0 : config.enabledLlmServices, service)) continue;
56
65
  const enabledModels = resolveEnabledModels(service);
57
66
  const serviceLabel = service.title || service.name;
58
67
  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
- });
68
+ const fullId = `${service.name}/${model.value}`;
69
+ if (!(0, import_user_permissions.isModelAllowed)(scope, fullId)) continue;
70
+ const meta = metadataMap.get(fullId);
71
+ if (meta && meta.enabled === false) continue;
72
+ models.push(buildModelObject(fullId, now, serviceLabel, meta));
67
73
  }
68
74
  }
69
75
  ctx.status = 200;
@@ -89,21 +95,26 @@ async function handleGetModel(ctx, modelId, plugin) {
89
95
  filter,
90
96
  sort: "sort"
91
97
  });
98
+ const scope = await (0, import_user_permissions.resolveUserAccessScope)(ctx);
99
+ if (scope.lookupFailed) {
100
+ respondPermissionCheckFailed(ctx);
101
+ return;
102
+ }
103
+ const metadataMap = await loadModelMetadata(ctx);
92
104
  const now = Math.floor(Date.now() / 1e3);
93
105
  let found = null;
94
106
  for (const service of services) {
95
107
  if (service.enabled === false) continue;
108
+ if (!(0, import_user_permissions.isServiceAllowed)(scope, config == null ? void 0 : config.enabledLlmServices, service)) continue;
96
109
  const enabledModels = resolveEnabledModels(service);
97
110
  const serviceLabel = service.title || service.name;
98
111
  for (const model of enabledModels) {
99
112
  const fullId = `${service.name}/${model.value}`;
100
113
  if (fullId === modelId || model.value === modelId) {
101
- found = {
102
- id: fullId,
103
- object: "model",
104
- created: now,
105
- owned_by: serviceLabel
106
- };
114
+ if (!(0, import_user_permissions.isModelAllowed)(scope, fullId)) continue;
115
+ const meta = metadataMap.get(fullId);
116
+ if (meta && meta.enabled === false) continue;
117
+ found = buildModelObject(fullId, now, serviceLabel, meta);
107
118
  break;
108
119
  }
109
120
  }
@@ -122,6 +133,70 @@ async function handleGetModel(ctx, modelId, plugin) {
122
133
  ctx.body = (0, import_openai_format.toOpenAIError)(500, "Failed to retrieve model", "server_error");
123
134
  }
124
135
  }
136
+ function respondPermissionCheckFailed(ctx) {
137
+ ctx.status = 503;
138
+ ctx.body = (0, import_openai_format.toOpenAIError)(
139
+ 503,
140
+ "Unable to verify LLM permissions for this user. Please retry shortly.",
141
+ "service_unavailable",
142
+ "permission_check_failed"
143
+ );
144
+ }
145
+ async function loadModelMetadata(ctx) {
146
+ var _a, _b;
147
+ const map = /* @__PURE__ */ new Map();
148
+ try {
149
+ const rows = await ctx.db.getRepository("aiApiModelMetadata").find();
150
+ for (const row of rows) {
151
+ const service = row.get("llmService");
152
+ const model = row.get("model");
153
+ if (!service || !model) continue;
154
+ map.set(`${service}/${model}`, {
155
+ contextWindow: row.get("contextWindow"),
156
+ maxCompletionTokens: row.get("maxCompletionTokens"),
157
+ ownedByOverride: row.get("ownedByOverride"),
158
+ displayName: row.get("displayName"),
159
+ description: row.get("description"),
160
+ enabled: row.get("enabled")
161
+ });
162
+ }
163
+ } catch (err) {
164
+ (_b = (_a = ctx.log) == null ? void 0 : _a.warn) == null ? void 0 : _b.call(_a, "AI API model metadata unavailable, skipping overrides:", err);
165
+ }
166
+ return map;
167
+ }
168
+ function buildModelObject(fullId, created, serviceLabel, meta) {
169
+ const model = {
170
+ id: fullId,
171
+ object: "model",
172
+ created,
173
+ owned_by: (meta == null ? void 0 : meta.ownedByOverride) || serviceLabel
174
+ };
175
+ const contextWindow = toPositiveInt(meta == null ? void 0 : meta.contextWindow);
176
+ if (contextWindow !== null) {
177
+ model.context_window = contextWindow;
178
+ model.context_length = contextWindow;
179
+ }
180
+ const maxCompletionTokens = toPositiveInt(meta == null ? void 0 : meta.maxCompletionTokens);
181
+ if (maxCompletionTokens !== null) {
182
+ model.max_completion_tokens = maxCompletionTokens;
183
+ }
184
+ if (meta == null ? void 0 : meta.displayName) {
185
+ model.display_name = meta.displayName;
186
+ model.name = meta.displayName;
187
+ }
188
+ if (meta == null ? void 0 : meta.description) {
189
+ model.description = meta.description;
190
+ }
191
+ if (meta) {
192
+ model.active = meta.enabled !== false;
193
+ }
194
+ return model;
195
+ }
196
+ function toPositiveInt(value) {
197
+ const n = Number(value);
198
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
199
+ }
125
200
  async function getPluginConfig(ctx) {
126
201
  return ctx.db.getRepository("aiApiConfig").findOne();
127
202
  }
@@ -142,16 +217,23 @@ function resolveEnabledModels(service) {
142
217
  return getRecommendedModelsForProvider(service.provider);
143
218
  }
144
219
  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 [];
220
+ const modulePaths = [
221
+ "@nocobase/plugin-ai/dist/common/recommended-models",
222
+ "@nocobase/plugin-ai/src/common/recommended-models"
223
+ ];
224
+ for (const modulePath of modulePaths) {
225
+ try {
226
+ const { getRecommendedModels } = require(modulePath);
227
+ const models = getRecommendedModels(provider);
228
+ return Array.isArray(models) ? models : [];
229
+ } catch {
230
+ }
151
231
  }
232
+ return [];
152
233
  }
153
234
  // Annotate the CommonJS export names for ESM import in node:
154
235
  0 && (module.exports = {
236
+ buildModelObject,
155
237
  handleGetModel,
156
238
  handleListModels
157
239
  });
@@ -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) {
@@ -0,0 +1,110 @@
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 app_observability_exports = {};
28
+ __export(app_observability_exports, {
29
+ addAiApiUsage: () => addAiApiUsage,
30
+ finishAiApiObservation: () => finishAiApiObservation,
31
+ markAiApiFirstProviderOutput: () => markAiApiFirstProviderOutput,
32
+ setAiApiObservationOutcome: () => setAiApiObservationOutcome,
33
+ startAiApiObservation: () => startAiApiObservation
34
+ });
35
+ module.exports = __toCommonJS(app_observability_exports);
36
+ const CONTRACT_SYMBOL = Symbol.for("nocobase.app-observability.contract");
37
+ const NOOP_HANDLE = {
38
+ markFirstByte() {
39
+ },
40
+ addInputTokens() {
41
+ },
42
+ addOutputTokens() {
43
+ },
44
+ finish() {
45
+ }
46
+ };
47
+ function state(ctx) {
48
+ return ctx.state;
49
+ }
50
+ function safely(ctx, callback) {
51
+ var _a, _b, _c;
52
+ try {
53
+ callback();
54
+ } catch (error) {
55
+ (_c = (_b = (_a = ctx.app) == null ? void 0 : _a.logger) == null ? void 0 : _b.warn) == null ? void 0 : _c.call(_b, "[ai-api] App observability callback failed", { error });
56
+ }
57
+ }
58
+ function startAiApiObservation(ctx, input) {
59
+ let handle = NOOP_HANDLE;
60
+ safely(ctx, () => {
61
+ const contract = ctx.app[CONTRACT_SYMBOL];
62
+ if (!contract || typeof contract.start !== "function") return;
63
+ const candidate = contract.start({
64
+ service: input.service,
65
+ operation: input.operation,
66
+ streaming: input.streaming,
67
+ attributes: {
68
+ mode: input.mode,
69
+ endpoint: input.operation,
70
+ ...input.model ? { model: input.model } : {}
71
+ }
72
+ });
73
+ if (candidate && typeof candidate.finish === "function") handle = candidate;
74
+ });
75
+ state(ctx).aiApiObservabilityHandle = handle;
76
+ }
77
+ function markAiApiFirstProviderOutput(ctx) {
78
+ safely(ctx, () => {
79
+ var _a;
80
+ return (_a = state(ctx).aiApiObservabilityHandle) == null ? void 0 : _a.markFirstByte();
81
+ });
82
+ }
83
+ function addAiApiUsage(ctx, usage) {
84
+ if (!usage) return;
85
+ safely(ctx, () => {
86
+ const handle = state(ctx).aiApiObservabilityHandle;
87
+ if (usage.prompt_tokens !== null) handle == null ? void 0 : handle.addInputTokens(usage.prompt_tokens);
88
+ if (usage.completion_tokens !== null) handle == null ? void 0 : handle.addOutputTokens(usage.completion_tokens);
89
+ });
90
+ }
91
+ function setAiApiObservationOutcome(ctx, outcome) {
92
+ state(ctx).aiApiObservabilityOutcome = outcome;
93
+ }
94
+ function finishAiApiObservation(ctx, fallback) {
95
+ const current = state(ctx);
96
+ const handle = current.aiApiObservabilityHandle;
97
+ if (!handle) return;
98
+ current.aiApiObservabilityHandle = void 0;
99
+ const outcome = current.aiApiObservabilityOutcome ?? fallback;
100
+ current.aiApiObservabilityOutcome = void 0;
101
+ safely(ctx, () => handle.finish(outcome));
102
+ }
103
+ // Annotate the CommonJS export names for ESM import in node:
104
+ 0 && (module.exports = {
105
+ addAiApiUsage,
106
+ finishAiApiObservation,
107
+ markAiApiFirstProviderOutput,
108
+ setAiApiObservationOutcome,
109
+ startAiApiObservation
110
+ });