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.
- package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
- package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
- package/dist/client/562.17a0a299d2e5152c.js +10 -0
- package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
- package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
- package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
- package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
- package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
- package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +36 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +28 -9
- package/dist/locale/vi-VN.json +20 -1
- package/dist/locale/zh-CN.json +20 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/plugin.js +13 -1
- package/dist/server/resource/ai-api-config.js +17 -0
- package/dist/server/routes/agent-completions.js +62 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +145 -4
- package/dist/server/routes/completions.js +8 -1
- package/dist/server/routes/models.js +78 -20
- package/dist/server/routes/router.js +94 -22
- package/dist/server/usage.js +2 -0
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/validation.js +18 -0
- package/dist/swagger.js +32 -1
- package/package.json +1 -1
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +17 -8
- package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
- package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
- package/src/client-v2/locale.ts +21 -1
- package/src/client-v2/pages/GeneralPage.tsx +13 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/plugin.tsx +41 -1
- package/src/constants.ts +21 -0
- package/src/locale/en-US.json +28 -9
- package/src/locale/vi-VN.json +20 -1
- package/src/locale/zh-CN.json +20 -1
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/models.test.ts +74 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/collections/ai-api-config.ts +6 -0
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/plugin.ts +24 -4
- package/src/server/resource/ai-api-config.ts +23 -0
- package/src/server/routes/agent-completions.ts +77 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +262 -4
- package/src/server/routes/completions.ts +14 -2
- package/src/server/routes/models.ts +290 -195
- package/src/server/routes/router.ts +136 -26
- package/src/server/usage.ts +2 -0
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/validation.ts +27 -0
- package/src/swagger.ts +38 -1
- package/dist/client/302.25edd5d75460acbf.js +0 -10
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
- 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
|
-
|
|
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 =
|
|
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 = {
|
|
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 = {
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
|
232
|
-
|
|
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
|
-
|
|
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 >
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
+
AI_LLM_PREFIX,
|
|
349
|
+
MAX_REQUEST_BODY_MB_LIMIT,
|
|
350
|
+
createAiLlmRouter,
|
|
351
|
+
getRawBody,
|
|
352
|
+
normalizeMaxRequestBodyMb
|
|
281
353
|
});
|
package/dist/server/usage.js
CHANGED
|
@@ -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) {
|