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
@@ -33,6 +33,8 @@ var import_openai_format = require("../utils/openai-format");
33
33
  var import_resolve_service = require("../utils/resolve-service");
34
34
  var import_streaming = require("../utils/streaming");
35
35
  var import_usage = require("../usage");
36
+ var import_billing = require("../billing");
37
+ var import_app_observability = require("../utils/app-observability");
36
38
  async function handleCompletions(ctx, plugin) {
37
39
  var _a;
38
40
  const body = ctx.request.body;
@@ -108,6 +110,7 @@ async function handleCompletions(ctx, plugin) {
108
110
  ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
109
111
  return;
110
112
  }
113
+ await (0, import_billing.prepareLlmBilling)(ctx, resolved);
111
114
  const modelOptions = {
112
115
  model: modelId,
113
116
  llmService: service.name
@@ -138,6 +141,7 @@ async function handleCompletions(ctx, plugin) {
138
141
  langchainMessages.push(["human", prompt]);
139
142
  const completionId = (0, import_openai_format.generateCompletionId)().replace("chatcmpl-", "cmpl-");
140
143
  const chatModel = provider.createModel();
144
+ (0, import_billing.markLlmProviderAttempted)(ctx);
141
145
  if (stream) {
142
146
  await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
143
147
  } else {
@@ -146,8 +150,15 @@ async function handleCompletions(ctx, plugin) {
146
150
  } catch (err) {
147
151
  ctx.log.error("AI API completions error:", err);
148
152
  if (!ctx.res.headersSent) {
149
- ctx.status = 500;
150
- ctx.body = (0, import_openai_format.toOpenAIError)(500, getErrorMessage(err, "Internal server error"), "server_error");
153
+ const isQuotaError = err instanceof import_billing.AiApiQuotaError;
154
+ ctx.status = isQuotaError ? 429 : 500;
155
+ if (isQuotaError) ctx.set("X-RateLimit-Reason", err.code);
156
+ ctx.body = (0, import_openai_format.toOpenAIError)(
157
+ ctx.status,
158
+ getErrorMessage(err, "Internal server error"),
159
+ isQuotaError ? "quota_error" : "server_error",
160
+ isQuotaError ? err.code : void 0
161
+ );
151
162
  }
152
163
  }
153
164
  }
@@ -205,6 +216,7 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
205
216
  text = (textPart == null ? void 0 : textPart.text) || "";
206
217
  }
207
218
  if (text) {
219
+ (0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
208
220
  await (0, import_streaming.writeResponse)(
209
221
  ctx,
210
222
  (0, import_openai_format.formatSSE)({
@@ -251,6 +263,7 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
251
263
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
252
264
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
253
265
  } catch (err) {
266
+ const cancelled = (0, import_streaming.isClientDisconnected)(ctx, err);
254
267
  ctx.log.error("AI API completions streaming error:", err);
255
268
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
256
269
  await (0, import_streaming.writeResponse)(
@@ -264,7 +277,11 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
264
277
  );
265
278
  }
266
279
  (0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
267
- 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
+ };
268
285
  } finally {
269
286
  requestAbort.dispose();
270
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"));
@@ -51,7 +55,10 @@ var import_rate_limit = require("../middleware/rate-limit");
51
55
  var import_role_permission = require("../middleware/role-permission");
52
56
  var import_usage = require("../usage");
53
57
  var import_streaming = require("../utils/streaming");
58
+ var import_billing = require("../billing");
59
+ var import_app_observability = require("../utils/app-observability");
54
60
  const API_PREFIX = "/api/ai-llm/v1";
61
+ const AI_LLM_PREFIX = API_PREFIX;
55
62
  function createAiLlmRouter(plugin) {
56
63
  const checkRateLimit = (0, import_rate_limit.createRateLimitMiddleware)(plugin.rateLimiter);
57
64
  return async (ctx, next) => {
@@ -65,7 +72,10 @@ function createAiLlmRouter(plugin) {
65
72
  ctx.set("Access-Control-Allow-Origin", "*");
66
73
  ctx.set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
67
74
  ctx.set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-AI-Mode, X-Timezone, X-Locale");
68
- ctx.set("Access-Control-Expose-Headers", "X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After");
75
+ ctx.set(
76
+ "Access-Control-Expose-Headers",
77
+ "X-Request-Id, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reason, Retry-After"
78
+ );
69
79
  ctx.set("Access-Control-Max-Age", "86400");
70
80
  if (method === "OPTIONS") {
71
81
  ctx.status = 204;
@@ -73,18 +83,6 @@ function createAiLlmRouter(plugin) {
73
83
  }
74
84
  const requestId = `req-${import_crypto.default.randomBytes(12).toString("hex")}`;
75
85
  ctx.set("X-Request-Id", requestId);
76
- if (method === "POST" && !ctx.request.body) {
77
- try {
78
- const rawBody = await getRawBody(ctx);
79
- ctx.request.body = JSON.parse(rawBody);
80
- } catch (bodyErr) {
81
- const status = bodyErr && typeof bodyErr === "object" && "statusCode" in bodyErr && bodyErr.statusCode === 413 ? 413 : 400;
82
- const message = status === 413 ? "Request body too large (max 10 MB)" : "Invalid JSON in request body";
83
- ctx.status = status;
84
- ctx.body = (0, import_openai_format.toOpenAIError)(status, message, "invalid_request_error");
85
- return;
86
- }
87
- }
88
86
  const isAuth = await (0, import_auth.authenticateBearer)(ctx);
89
87
  if (!isAuth) {
90
88
  logRequest(ctx, requestId, "-", "auth_failed", 0);
@@ -100,6 +98,29 @@ function createAiLlmRouter(plugin) {
100
98
  logRequest(ctx, requestId, "-", "rate_limited", 0);
101
99
  return;
102
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
+ }
103
124
  const requestBody = ctx.request.body || {};
104
125
  const model = requestBody.model === void 0 || requestBody.model === null ? "-" : String(requestBody.model);
105
126
  const isUsageEndpoint = method === "POST" && (subPath === "/chat/completions" || subPath === "/completions" || subPath === "/embeddings");
@@ -117,6 +138,16 @@ function createAiLlmRouter(plugin) {
117
138
  };
118
139
  }
119
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
+ }
120
151
  let usageId;
121
152
  try {
122
153
  usageId = isUsageEndpoint ? await (0, import_usage.startUsageRecord)(ctx, requestId, subPath, model, streaming, resolvedMode) : void 0;
@@ -211,25 +242,75 @@ function createAiLlmRouter(plugin) {
211
242
  } catch (usageError) {
212
243
  ctx.log.error("AI API usage record could not be finalized:", usageError);
213
244
  }
245
+ } else if (ctx.state.aiApiLlmBilling) {
246
+ try {
247
+ const usageResult = ctx.state.aiApiUsageResult;
248
+ const providerUsage = (usageResult == null ? void 0 : usageResult.source) === "provider" ? usageResult.usage : void 0;
249
+ const succeeded = ctx.state.aiApiStreamResult ? ctx.state.aiApiStreamResult.succeeded : ctx.status >= 200 && ctx.status < 400;
250
+ await (0, import_billing.finalizeLlmBilling)(ctx, providerUsage, succeeded);
251
+ } catch (billingError) {
252
+ ctx.log.error("AI API quota reservation could not be finalized:", billingError);
253
+ }
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
+ });
214
261
  }
215
262
  }
216
263
  };
217
264
  }
218
- const MAX_BODY_BYTES = 10 * 1024 * 1024;
219
- 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) {
220
295
  return new Promise((resolve, reject) => {
221
- let body = "";
296
+ const chunks = [];
222
297
  let byteCount = 0;
298
+ let aborted = false;
223
299
  ctx.req.on("data", (chunk) => {
300
+ if (aborted) return;
224
301
  byteCount += chunk.length;
225
- if (byteCount > MAX_BODY_BYTES) {
226
- ctx.req.destroy();
227
- 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 }));
228
307
  return;
229
308
  }
230
- body += chunk.toString();
309
+ chunks.push(chunk);
310
+ });
311
+ ctx.req.on("end", () => {
312
+ if (!aborted) resolve(Buffer.concat(chunks).toString("utf8"));
231
313
  });
232
- ctx.req.on("end", () => resolve(body));
233
314
  ctx.req.on("error", reject);
234
315
  });
235
316
  }
@@ -264,5 +345,9 @@ function logRequest(ctx, requestId, model, status, durationMs) {
264
345
  }
265
346
  // Annotate the CommonJS export names for ESM import in node:
266
347
  0 && (module.exports = {
267
- createAiLlmRouter
348
+ AI_LLM_PREFIX,
349
+ MAX_REQUEST_BODY_MB_LIMIT,
350
+ createAiLlmRouter,
351
+ getRawBody,
352
+ normalizeMaxRequestBodyMb
268
353
  });
@@ -34,6 +34,8 @@ __export(usage_exports, {
34
34
  startUsageRecord: () => startUsageRecord
35
35
  });
36
36
  module.exports = __toCommonJS(usage_exports);
37
+ var import_billing = require("./billing");
38
+ var import_app_observability = require("./utils/app-observability");
37
39
  function getAiApiState(ctx) {
38
40
  return ctx.state;
39
41
  }
@@ -59,6 +61,7 @@ function normalizeUsage(value) {
59
61
  function setAiApiUsageResult(ctx, value, metadata = {}) {
60
62
  const usage = normalizeUsage(value);
61
63
  getAiApiState(ctx).aiApiUsageResult = usage ? { source: "provider", usage, ...metadata } : { source: "unavailable", ...metadata };
64
+ (0, import_app_observability.addAiApiUsage)(ctx, usage);
62
65
  return usage;
63
66
  }
64
67
  function setAiApiUsageUnavailable(ctx, gatewayResponseId) {
@@ -113,12 +116,15 @@ async function startUsageRecord(ctx, requestId, endpoint, model, streaming, mode
113
116
  return record.id;
114
117
  }
115
118
  async function finishUsageRecord(ctx, id, startedAt, status) {
116
- var _a;
119
+ var _a, _b, _c, _d, _e, _f, _g;
117
120
  const response = ctx.body || {};
118
121
  const state = getAiApiState(ctx);
119
122
  const streamResult = state.aiApiStreamResult;
120
123
  const usageResult = state.aiApiUsageResult ?? { source: "unavailable" };
121
- const usage = usageResult.source === "provider" ? usageResult.usage : void 0;
124
+ const providerUsage = usageResult.source === "provider" ? usageResult.usage : void 0;
125
+ const succeeded = streamResult ? streamResult.succeeded : status === "succeeded";
126
+ const billing = await (0, import_billing.finalizeLlmBilling)(ctx, providerUsage, succeeded);
127
+ const usage = billing.usage ?? providerUsage;
122
128
  const gatewayResponseId = usageResult.gatewayResponseId || response.id || (streamResult == null ? void 0 : streamResult.id);
123
129
  const values = {
124
130
  status: streamResult ? streamResult.succeeded ? "succeeded" : "failed" : status,
@@ -127,6 +133,17 @@ async function finishUsageRecord(ctx, id, startedAt, status) {
127
133
  inputTokens: (usage == null ? void 0 : usage.prompt_tokens) ?? null,
128
134
  outputTokens: (usage == null ? void 0 : usage.completion_tokens) ?? null,
129
135
  totalTokens: (usage == null ? void 0 : usage.total_tokens) ?? null,
136
+ resolvedService: ((_c = (_b = state.aiApiLlmBilling) == null ? void 0 : _b.resolution) == null ? void 0 : _c.service) ?? null,
137
+ resolvedProvider: ((_e = (_d = state.aiApiLlmBilling) == null ? void 0 : _d.resolution) == null ? void 0 : _e.provider) ?? null,
138
+ resolvedModel: ((_g = (_f = state.aiApiLlmBilling) == null ? void 0 : _f.resolution) == null ? void 0 : _g.model) ?? null,
139
+ estimatedCost: billing.estimatedCost ?? null,
140
+ currency: billing.currency ?? null,
141
+ costStatus: billing.costStatus ?? null,
142
+ modelPriceId: billing.modelPriceId ?? null,
143
+ quotaPolicyId: billing.quotaPolicyId ?? null,
144
+ inputPricePerMillionTokens: billing.inputPricePerMillionTokens ?? null,
145
+ outputPricePerMillionTokens: billing.outputPricePerMillionTokens ?? null,
146
+ fixedCostPerRequest: billing.fixedCostPerRequest ?? null,
130
147
  providerRequestId: usageResult.providerRequestId ?? null,
131
148
  completedAt: /* @__PURE__ */ new Date(),
132
149
  durationMs: Date.now() - startedAt,
@@ -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
+ });
@@ -26,18 +26,30 @@ var __copyProps = (to, from, except, desc) => {
26
26
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
27
  var streaming_exports = {};
28
28
  __export(streaming_exports, {
29
+ AiApiClientDisconnectedError: () => AiApiClientDisconnectedError,
29
30
  createRequestAbortController: () => createRequestAbortController,
31
+ isClientDisconnected: () => isClientDisconnected,
30
32
  isStreamingRequested: () => isStreamingRequested,
31
33
  writeResponse: () => writeResponse
32
34
  });
33
35
  module.exports = __toCommonJS(streaming_exports);
36
+ class AiApiClientDisconnectedError extends Error {
37
+ code = "client_disconnected";
38
+ constructor() {
39
+ super("Client disconnected");
40
+ this.name = "AiApiClientDisconnectedError";
41
+ }
42
+ }
43
+ function isClientDisconnected(ctx, error) {
44
+ return ctx.req.aborted === true || error instanceof AiApiClientDisconnectedError;
45
+ }
34
46
  function isStreamingRequested(value) {
35
47
  return value !== false;
36
48
  }
37
49
  function createRequestAbortController(ctx) {
38
50
  const controller = new AbortController();
39
51
  const abort = () => {
40
- if (!ctx.res.writableEnded) controller.abort(new Error("Client disconnected"));
52
+ if (!ctx.res.writableEnded) controller.abort(new AiApiClientDisconnectedError());
41
53
  };
42
54
  ctx.req.once("aborted", abort);
43
55
  ctx.res.once("close", abort);
@@ -74,7 +86,9 @@ function waitForDrain(ctx) {
74
86
  }
75
87
  // Annotate the CommonJS export names for ESM import in node:
76
88
  0 && (module.exports = {
89
+ AiApiClientDisconnectedError,
77
90
  createRequestAbortController,
91
+ isClientDisconnected,
78
92
  isStreamingRequested,
79
93
  writeResponse
80
94
  });