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.
- package/dist/client/123.e6fe04c856ce6417.js +10 -0
- 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/123.05f1f649923f93eb.js +10 -0
- 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 +39 -0
- package/dist/externalVersion.js +9 -10
- package/dist/locale/en-US.json +39 -9
- package/dist/locale/vi-VN.json +31 -1
- package/dist/locale/zh-CN.json +31 -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/collections/ai-api-user-permissions.js +67 -0
- package/dist/server/plugin.js +45 -1
- package/dist/server/resource/ai-api-config.js +17 -0
- package/dist/server/resource/ai-api-user-permissions.js +75 -0
- package/dist/server/routes/agent-completions.js +67 -51
- package/dist/server/routes/auth.js +11 -1
- package/dist/server/routes/chat-completions.js +174 -20
- package/dist/server/routes/completions.js +41 -21
- package/dist/server/routes/embeddings.js +6 -14
- package/dist/server/routes/models.js +102 -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/openai-format.js +17 -3
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/utils/user-permissions.js +160 -0
- package/dist/server/validation.js +18 -0
- package/dist/swagger.js +36 -4
- package/package.json +2 -2
- package/src/client/__tests__/settings-registration.test.tsx +69 -0
- package/src/client/components/AiApiRolePermissions.tsx +11 -169
- package/src/client/locale.ts +11 -21
- package/src/client/plugin.tsx +28 -8
- package/src/client-v2/__tests__/settings-registration.test.tsx +87 -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/pages/UserPermissionsPage.tsx +322 -0
- package/src/client-v2/plugin.tsx +50 -1
- package/src/constants.ts +28 -0
- package/src/locale/en-US.json +39 -9
- package/src/locale/vi-VN.json +31 -1
- package/src/locale/zh-CN.json +31 -1
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/models.test.ts +116 -0
- package/src/server/__tests__/openai-format.test.ts +52 -1
- package/src/server/__tests__/permission-sync.test.ts +109 -0
- package/src/server/__tests__/request-body.test.ts +310 -0
- package/src/server/__tests__/streaming-observability.test.ts +51 -0
- package/src/server/__tests__/usage-route.test.ts +213 -0
- package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
- package/src/server/__tests__/user-permissions.test.ts +284 -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/collections/ai-api-user-permissions.ts +46 -0
- package/src/server/plugin.ts +65 -4
- package/src/server/resource/ai-api-config.ts +23 -0
- package/src/server/resource/ai-api-user-permissions.ts +76 -0
- package/src/server/routes/agent-completions.ts +84 -62
- package/src/server/routes/auth.ts +14 -1
- package/src/server/routes/chat-completions.ts +294 -20
- package/src/server/routes/completions.ts +54 -20
- package/src/server/routes/embeddings.ts +10 -15
- package/src/server/routes/models.ts +318 -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/openai-format.ts +26 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/utils/user-permissions.ts +218 -0
- package/src/server/validation.ts +27 -0
- package/src/swagger.ts +47 -4
- 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
|
@@ -29,6 +29,18 @@ __export(ai_api_config_exports, {
|
|
|
29
29
|
default: () => ai_api_config_default
|
|
30
30
|
});
|
|
31
31
|
module.exports = __toCommonJS(ai_api_config_exports);
|
|
32
|
+
var import_router = require("../routes/router");
|
|
33
|
+
const DEFAULT_MAX_REQUEST_BODY_MB = 10;
|
|
34
|
+
function coerceMaxRequestBodyMb(value) {
|
|
35
|
+
const mb = Number(value);
|
|
36
|
+
if (!Number.isSafeInteger(mb) || mb <= 0) {
|
|
37
|
+
throw new Error(`maxRequestBodyMb must be a positive integer (1-${import_router.MAX_REQUEST_BODY_MB_LIMIT}).`);
|
|
38
|
+
}
|
|
39
|
+
if (mb > import_router.MAX_REQUEST_BODY_MB_LIMIT) {
|
|
40
|
+
throw new Error(`maxRequestBodyMb cannot exceed ${import_router.MAX_REQUEST_BODY_MB_LIMIT}.`);
|
|
41
|
+
}
|
|
42
|
+
return mb;
|
|
43
|
+
}
|
|
32
44
|
const aiApiConfigResource = {
|
|
33
45
|
name: "aiApiConfig",
|
|
34
46
|
actions: {
|
|
@@ -42,6 +54,7 @@ const aiApiConfigResource = {
|
|
|
42
54
|
defaultLlmService: "",
|
|
43
55
|
enabledLlmServices: [],
|
|
44
56
|
rateLimitPerMinute: 60,
|
|
57
|
+
maxRequestBodyMb: 10,
|
|
45
58
|
quotaEnabled: false,
|
|
46
59
|
defaultReservationOutputTokens: 4096,
|
|
47
60
|
options: {}
|
|
@@ -63,6 +76,7 @@ const aiApiConfigResource = {
|
|
|
63
76
|
defaultLlmService: values.defaultLlmService ?? "",
|
|
64
77
|
enabledLlmServices: values.enabledLlmServices ?? [],
|
|
65
78
|
rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
|
|
79
|
+
maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
|
|
66
80
|
quotaEnabled: values.quotaEnabled ?? false,
|
|
67
81
|
defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
|
|
68
82
|
options: values.options ?? {}
|
|
@@ -75,6 +89,9 @@ const aiApiConfigResource = {
|
|
|
75
89
|
if (values.defaultLlmService !== void 0) updateData.defaultLlmService = values.defaultLlmService;
|
|
76
90
|
if (values.enabledLlmServices !== void 0) updateData.enabledLlmServices = values.enabledLlmServices;
|
|
77
91
|
if (values.rateLimitPerMinute !== void 0) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
|
|
92
|
+
if (values.maxRequestBodyMb !== void 0) {
|
|
93
|
+
updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
|
|
94
|
+
}
|
|
78
95
|
if (values.quotaEnabled !== void 0) updateData.quotaEnabled = values.quotaEnabled;
|
|
79
96
|
if (values.defaultReservationOutputTokens !== void 0) {
|
|
80
97
|
updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
12
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
13
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
14
|
+
var __export = (target, all) => {
|
|
15
|
+
for (var name in all)
|
|
16
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
17
|
+
};
|
|
18
|
+
var __copyProps = (to, from, except, desc) => {
|
|
19
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
20
|
+
for (let key of __getOwnPropNames(from))
|
|
21
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
22
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
23
|
+
}
|
|
24
|
+
return to;
|
|
25
|
+
};
|
|
26
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
27
|
+
var ai_api_user_permissions_exports = {};
|
|
28
|
+
__export(ai_api_user_permissions_exports, {
|
|
29
|
+
default: () => ai_api_user_permissions_default
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(ai_api_user_permissions_exports);
|
|
32
|
+
const MAX_PAGE_SIZE = 100;
|
|
33
|
+
const aiApiUserPermissionsResource = {
|
|
34
|
+
name: "aiApiUserPermissions",
|
|
35
|
+
actions: {
|
|
36
|
+
async listUsers(ctx, next) {
|
|
37
|
+
const params = ctx.action.params || {};
|
|
38
|
+
const keyword = typeof params.keyword === "string" ? params.keyword.trim() : "";
|
|
39
|
+
const page = Math.max(1, Number(params.page) || 1);
|
|
40
|
+
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(params.pageSize) || 50));
|
|
41
|
+
const filter = keyword ? {
|
|
42
|
+
$or: [
|
|
43
|
+
{ nickname: { $includes: keyword } },
|
|
44
|
+
{ username: { $includes: keyword } },
|
|
45
|
+
{ email: { $includes: keyword } }
|
|
46
|
+
]
|
|
47
|
+
} : {};
|
|
48
|
+
if (params.excludeGranted) {
|
|
49
|
+
const granted = await ctx.db.getRepository("aiApiUserPermissions").find({ fields: ["userId"] });
|
|
50
|
+
const ids = granted.map((row) => row.get("userId")).filter((id) => id !== null && id !== void 0);
|
|
51
|
+
if (ids.length) filter.id = { $notIn: ids };
|
|
52
|
+
}
|
|
53
|
+
const [rows, count] = await ctx.db.getRepository("users").findAndCount({
|
|
54
|
+
filter,
|
|
55
|
+
fields: ["id", "nickname", "username", "email"],
|
|
56
|
+
sort: ["nickname", "id"],
|
|
57
|
+
offset: (page - 1) * pageSize,
|
|
58
|
+
limit: pageSize
|
|
59
|
+
});
|
|
60
|
+
ctx.body = {
|
|
61
|
+
rows: rows.map((row) => ({
|
|
62
|
+
id: row.get("id"),
|
|
63
|
+
nickname: row.get("nickname"),
|
|
64
|
+
username: row.get("username"),
|
|
65
|
+
email: row.get("email")
|
|
66
|
+
})),
|
|
67
|
+
count,
|
|
68
|
+
page,
|
|
69
|
+
pageSize
|
|
70
|
+
};
|
|
71
|
+
await next();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
var ai_api_user_permissions_default = aiApiUserPermissionsResource;
|
|
@@ -32,9 +32,11 @@ module.exports = __toCommonJS(agent_completions_exports);
|
|
|
32
32
|
var import_openai_format = require("../utils/openai-format");
|
|
33
33
|
var import_resolve_service = require("../utils/resolve-service");
|
|
34
34
|
var import_role_permission = require("../middleware/role-permission");
|
|
35
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
35
36
|
var import_streaming = require("../utils/streaming");
|
|
36
37
|
var import_ai_employee_runtime = require("../utils/ai-employee-runtime");
|
|
37
38
|
var import_usage = require("../usage");
|
|
39
|
+
var import_app_observability = require("../utils/app-observability");
|
|
38
40
|
async function handleAgentCompletions(ctx, plugin) {
|
|
39
41
|
var _a;
|
|
40
42
|
const body = ctx.request.body;
|
|
@@ -87,6 +89,10 @@ async function handleAgentCompletions(ctx, plugin) {
|
|
|
87
89
|
ctx.body = (0, import_openai_format.toOpenAIError)(404, "LLM service is disabled", "invalid_request_error", "model_not_found");
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
92
|
+
const globalEnabledServices = config ? config.get("enabledLlmServices") || config.enabledLlmServices : [];
|
|
93
|
+
if (!await (0, import_user_permissions.enforceModelAccess)(ctx, globalEnabledServices, service, modelId)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
90
96
|
const employeeUsername = defaultAiEmployee;
|
|
91
97
|
if (!(0, import_role_permission.checkEmployeeAccess)(ctx, employeeUsername)) {
|
|
92
98
|
ctx.status = 403;
|
|
@@ -201,74 +207,83 @@ async function handleAgentCompletions(ctx, plugin) {
|
|
|
201
207
|
ctx.res.write(args[0]);
|
|
202
208
|
}
|
|
203
209
|
};
|
|
204
|
-
|
|
210
|
+
const processSseFrame = (frame) => {
|
|
205
211
|
var _a2;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
212
|
+
for (const line of frame.split("\n")) {
|
|
213
|
+
const trimmed = line.trim();
|
|
214
|
+
if (!trimmed.startsWith("data: ")) continue;
|
|
215
|
+
const jsonStr = trimmed.substring(6);
|
|
216
|
+
if (!jsonStr) continue;
|
|
217
|
+
try {
|
|
218
|
+
const event = JSON.parse(jsonStr);
|
|
219
|
+
if (event.type === "content" && event.body) {
|
|
220
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
221
|
+
originalWrite(
|
|
222
|
+
(0, import_openai_format.formatSSE)(
|
|
223
|
+
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
224
|
+
id: completionId,
|
|
225
|
+
model: body.model,
|
|
226
|
+
delta: { content: String(event.body) }
|
|
227
|
+
})
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
} else if (event.type === "tool_call_chunks" && Array.isArray(event.body)) {
|
|
231
|
+
const chunks = toOpenAIToolCallChunks(event.body);
|
|
232
|
+
if (chunks.length) {
|
|
233
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
234
|
+
sawToolCalls = true;
|
|
218
235
|
originalWrite(
|
|
219
236
|
(0, import_openai_format.formatSSE)(
|
|
220
237
|
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
221
238
|
id: completionId,
|
|
222
239
|
model: body.model,
|
|
223
|
-
delta: {
|
|
240
|
+
delta: { tool_calls: chunks }
|
|
224
241
|
})
|
|
225
242
|
)
|
|
226
243
|
);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
234
|
-
id: completionId,
|
|
235
|
-
model: body.model,
|
|
236
|
-
delta: { tool_calls: chunks }
|
|
237
|
-
})
|
|
238
|
-
)
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
} else if (!sawToolCalls && event.type === "tool_calls" && Array.isArray((_a2 = event.body) == null ? void 0 : _a2.toolCalls)) {
|
|
242
|
-
const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
|
|
243
|
-
if (chunks.length) {
|
|
244
|
-
sawToolCalls = true;
|
|
245
|
-
originalWrite(
|
|
246
|
-
(0, import_openai_format.formatSSE)(
|
|
247
|
-
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
248
|
-
id: completionId,
|
|
249
|
-
model: body.model,
|
|
250
|
-
delta: { tool_calls: chunks }
|
|
251
|
-
})
|
|
252
|
-
)
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
} else if (event.type === "error" && event.body) {
|
|
244
|
+
}
|
|
245
|
+
} else if (!sawToolCalls && event.type === "tool_calls" && Array.isArray((_a2 = event.body) == null ? void 0 : _a2.toolCalls)) {
|
|
246
|
+
const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
|
|
247
|
+
if (chunks.length) {
|
|
248
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
249
|
+
sawToolCalls = true;
|
|
256
250
|
originalWrite(
|
|
257
|
-
(0, import_openai_format.formatSSE)(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
251
|
+
(0, import_openai_format.formatSSE)(
|
|
252
|
+
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
253
|
+
id: completionId,
|
|
254
|
+
model: body.model,
|
|
255
|
+
delta: { tool_calls: chunks }
|
|
256
|
+
})
|
|
257
|
+
)
|
|
264
258
|
);
|
|
265
259
|
}
|
|
266
|
-
}
|
|
260
|
+
} else if (event.type === "error" && event.body) {
|
|
261
|
+
originalWrite(
|
|
262
|
+
(0, import_openai_format.formatSSE)({
|
|
263
|
+
error: {
|
|
264
|
+
message: String(event.body),
|
|
265
|
+
type: "server_error",
|
|
266
|
+
code: "agent_error"
|
|
267
|
+
}
|
|
268
|
+
})
|
|
269
|
+
);
|
|
267
270
|
}
|
|
271
|
+
} catch {
|
|
268
272
|
}
|
|
269
273
|
}
|
|
274
|
+
};
|
|
275
|
+
ctx.res.write = (data) => {
|
|
276
|
+
pendingSse += typeof data === "string" ? data : data.toString("utf8");
|
|
277
|
+
const frames = pendingSse.split("\n\n");
|
|
278
|
+
pendingSse = frames.pop() || "";
|
|
279
|
+
for (const frame of frames) processSseFrame(frame);
|
|
270
280
|
return true;
|
|
271
281
|
};
|
|
282
|
+
const flushPendingSse = () => {
|
|
283
|
+
if (!pendingSse.trim()) return;
|
|
284
|
+
processSseFrame(pendingSse);
|
|
285
|
+
pendingSse = "";
|
|
286
|
+
};
|
|
272
287
|
try {
|
|
273
288
|
const aiEmployee = new AIEmployee(
|
|
274
289
|
(0, import_ai_employee_runtime.createAIEmployeeOptions)(ctx, employeeRecord, sessionId, {
|
|
@@ -291,6 +306,7 @@ async function handleAgentCompletions(ctx, plugin) {
|
|
|
291
306
|
ctx.req.off("aborted", abortAgent);
|
|
292
307
|
ctx.res.off("close", abortAgent);
|
|
293
308
|
if (streamSucceeded && !ctx.res.destroyed) {
|
|
309
|
+
flushPendingSse();
|
|
294
310
|
originalWrite(
|
|
295
311
|
(0, import_openai_format.formatSSE)(
|
|
296
312
|
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
@@ -57,7 +57,17 @@ async function authenticateBearer(ctx) {
|
|
|
57
57
|
const rolesRepository2 = ctx.db.getRepository("users.roles", ctx.state.currentUser.id);
|
|
58
58
|
const roles2 = await rolesRepository2.find({ fields: ["name"] });
|
|
59
59
|
const roleNames2 = roles2.map((role) => role.name);
|
|
60
|
-
|
|
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,18 +27,22 @@ 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");
|
|
35
38
|
var import_resolve_service = require("../utils/resolve-service");
|
|
36
39
|
var import_streaming = require("../utils/streaming");
|
|
37
40
|
var import_role_permission = require("../middleware/role-permission");
|
|
41
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
38
42
|
var import_usage = require("../usage");
|
|
39
43
|
var import_billing = require("../billing");
|
|
44
|
+
var import_app_observability = require("../utils/app-observability");
|
|
40
45
|
async function handleChatCompletions(ctx, plugin) {
|
|
41
|
-
var _a;
|
|
42
46
|
const body = ctx.request.body;
|
|
43
47
|
if (!(body == null ? void 0 : body.model)) {
|
|
44
48
|
ctx.status = 400;
|
|
@@ -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)(
|
|
@@ -91,20 +117,8 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
91
117
|
return;
|
|
92
118
|
}
|
|
93
119
|
const config = await ctx.db.getRepository("aiApiConfig").findOne();
|
|
94
|
-
if ((
|
|
95
|
-
|
|
96
|
-
const serviceTitle = service.title;
|
|
97
|
-
const isAllowed = config.enabledLlmServices.some((s) => s === serviceName || s === serviceTitle);
|
|
98
|
-
if (!isAllowed) {
|
|
99
|
-
ctx.status = 403;
|
|
100
|
-
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
101
|
-
403,
|
|
102
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
103
|
-
"invalid_request_error",
|
|
104
|
-
"model_not_available"
|
|
105
|
-
);
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
120
|
+
if (!await (0, import_user_permissions.enforceModelAccess)(ctx, config == null ? void 0 : config.enabledLlmServices, service, modelId)) {
|
|
121
|
+
return;
|
|
108
122
|
}
|
|
109
123
|
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
110
124
|
if (!providerMeta) {
|
|
@@ -114,6 +128,13 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
114
128
|
}
|
|
115
129
|
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
116
130
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
131
|
+
if (stream) {
|
|
132
|
+
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
|
|
133
|
+
providerRequestParameters.stream_options = {
|
|
134
|
+
...streamOptions,
|
|
135
|
+
include_usage: true
|
|
136
|
+
};
|
|
137
|
+
}
|
|
117
138
|
const modelOptions = {
|
|
118
139
|
model: modelId,
|
|
119
140
|
llmService: service.name
|
|
@@ -157,7 +178,7 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
157
178
|
}
|
|
158
179
|
const langchainMessages = messages.map((msg) => {
|
|
159
180
|
const role = msg.role === "assistant" ? "ai" : msg.role;
|
|
160
|
-
const content =
|
|
181
|
+
const content = normalizeMessageContent(msg.content);
|
|
161
182
|
if (msg.role === "assistant" && msg.tool_calls) {
|
|
162
183
|
return {
|
|
163
184
|
role,
|
|
@@ -268,6 +289,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
268
289
|
content = (textPart == null ? void 0 : textPart.text) || "";
|
|
269
290
|
}
|
|
270
291
|
if (content) {
|
|
292
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
271
293
|
await (0, import_streaming.writeResponse)(
|
|
272
294
|
ctx,
|
|
273
295
|
(0, import_openai_format.formatSSE)(
|
|
@@ -281,10 +303,17 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
281
303
|
}
|
|
282
304
|
const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
|
|
283
305
|
if (toolCallChunks.length) {
|
|
306
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
284
307
|
finishReason = "tool_calls";
|
|
285
308
|
await (0, import_streaming.writeResponse)(
|
|
286
309
|
ctx,
|
|
287
|
-
(0, import_openai_format.formatSSE)(
|
|
310
|
+
(0, import_openai_format.formatSSE)(
|
|
311
|
+
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
312
|
+
id: completionId,
|
|
313
|
+
model: modelName,
|
|
314
|
+
delta: { tool_calls: toolCallChunks }
|
|
315
|
+
})
|
|
316
|
+
)
|
|
288
317
|
);
|
|
289
318
|
}
|
|
290
319
|
if (chunk.usage_metadata) {
|
|
@@ -303,10 +332,23 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
303
332
|
})
|
|
304
333
|
)
|
|
305
334
|
);
|
|
335
|
+
if (usage) {
|
|
336
|
+
await (0, import_streaming.writeResponse)(
|
|
337
|
+
ctx,
|
|
338
|
+
(0, import_openai_format.formatSSE)(
|
|
339
|
+
(0, import_openai_format.toOpenAIUsageChunk)({
|
|
340
|
+
id: completionId,
|
|
341
|
+
model: modelName,
|
|
342
|
+
usage
|
|
343
|
+
})
|
|
344
|
+
)
|
|
345
|
+
);
|
|
346
|
+
}
|
|
306
347
|
await (0, import_streaming.writeResponse)(ctx, (0, import_openai_format.formatSSEDone)());
|
|
307
348
|
(0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
308
349
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
309
350
|
} catch (err) {
|
|
351
|
+
const cancelled = (0, import_streaming.isClientDisconnected)(ctx, err);
|
|
310
352
|
ctx.log.error("AI API streaming error:", err);
|
|
311
353
|
if (!ctx.res.destroyed && !ctx.res.writableEnded) {
|
|
312
354
|
await (0, import_streaming.writeResponse)(
|
|
@@ -320,7 +362,11 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
320
362
|
);
|
|
321
363
|
}
|
|
322
364
|
(0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
323
|
-
ctx.state.aiApiStreamResult = {
|
|
365
|
+
ctx.state.aiApiStreamResult = {
|
|
366
|
+
succeeded: false,
|
|
367
|
+
id: completionId,
|
|
368
|
+
errorCode: cancelled ? "client_disconnected" : "stream_error"
|
|
369
|
+
};
|
|
324
370
|
} finally {
|
|
325
371
|
requestAbort.dispose();
|
|
326
372
|
if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
|
|
@@ -329,6 +375,111 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
329
375
|
function getErrorMessage(error, fallback) {
|
|
330
376
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
331
377
|
}
|
|
378
|
+
const SUPPORTED_CONTENT_BLOCK_TYPES = /* @__PURE__ */ new Set(["text", "image_url"]);
|
|
379
|
+
const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
380
|
+
function isDecodableBase64(payload) {
|
|
381
|
+
try {
|
|
382
|
+
return Buffer.from(payload, "base64").toString("base64") === payload;
|
|
383
|
+
} catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const SUPPORTED_MESSAGE_ROLES = /* @__PURE__ */ new Set(["system", "developer", "user", "human", "assistant", "ai", "tool"]);
|
|
388
|
+
function findMessageProblem(messages) {
|
|
389
|
+
for (const [index, message] of messages.entries()) {
|
|
390
|
+
if (!isRecord(message)) return { index, reason: "each message must be an object" };
|
|
391
|
+
const role = typeof message.role === "string" ? message.role : void 0;
|
|
392
|
+
if (!role) return { index, reason: "each message requires a string 'role' field" };
|
|
393
|
+
if (!SUPPORTED_MESSAGE_ROLES.has(role)) {
|
|
394
|
+
return {
|
|
395
|
+
index,
|
|
396
|
+
reason: `role '${role}' is not supported \u2014 use one of ${[...SUPPORTED_MESSAGE_ROLES].join(", ")}`
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
if (role === "tool" && typeof message.tool_call_id !== "string") {
|
|
400
|
+
return { index, reason: "a 'tool' message requires a string 'tool_call_id' field" };
|
|
401
|
+
}
|
|
402
|
+
const { content } = message;
|
|
403
|
+
const hasToolCalls = Array.isArray(message.tool_calls) && message.tool_calls.length > 0;
|
|
404
|
+
if (content === void 0 || content === null) {
|
|
405
|
+
if ((role === "assistant" || role === "ai") && hasToolCalls) continue;
|
|
406
|
+
return { index, reason: "each message requires a 'content' field" };
|
|
407
|
+
}
|
|
408
|
+
if (typeof content !== "string" && !Array.isArray(content)) {
|
|
409
|
+
return { index, reason: "'content' must be a string or an array of content blocks" };
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return void 0;
|
|
413
|
+
}
|
|
414
|
+
function findContentBlockProblem(messages) {
|
|
415
|
+
for (const [index, message] of messages.entries()) {
|
|
416
|
+
const content = isRecord(message) ? message.content : void 0;
|
|
417
|
+
if (!Array.isArray(content)) continue;
|
|
418
|
+
for (const block of content) {
|
|
419
|
+
if (typeof block === "string") continue;
|
|
420
|
+
const reason = describeContentBlockProblem(block);
|
|
421
|
+
if (reason) return { index, reason };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return void 0;
|
|
425
|
+
}
|
|
426
|
+
function describeContentBlockProblem(block) {
|
|
427
|
+
if (!isRecord(block)) return "each content block must be an object";
|
|
428
|
+
const type = typeof block.type === "string" ? block.type : void 0;
|
|
429
|
+
if (!type) return "each content block requires a 'type' field";
|
|
430
|
+
if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
|
|
431
|
+
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`;
|
|
432
|
+
}
|
|
433
|
+
if (type === "text") {
|
|
434
|
+
return typeof block.text === "string" ? void 0 : "a 'text' block requires a string 'text' field";
|
|
435
|
+
}
|
|
436
|
+
return describeImageUrlProblem(block.image_url);
|
|
437
|
+
}
|
|
438
|
+
function describeImageUrlProblem(imageUrl) {
|
|
439
|
+
const url = typeof imageUrl === "string" ? imageUrl : isRecord(imageUrl) ? imageUrl.url : void 0;
|
|
440
|
+
if (typeof url !== "string" || url === "") {
|
|
441
|
+
return "an 'image_url' block requires a non-empty 'image_url.url' string";
|
|
442
|
+
}
|
|
443
|
+
if (url.startsWith("data:")) {
|
|
444
|
+
const match = BASE64_DATA_URL_PATTERN.exec(url);
|
|
445
|
+
if (!match) {
|
|
446
|
+
return `malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' with standard base64 (no whitespace or URL-safe characters)`;
|
|
447
|
+
}
|
|
448
|
+
const mimeType = match[1].toLowerCase();
|
|
449
|
+
if (!mimeType.startsWith("image/")) {
|
|
450
|
+
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`;
|
|
451
|
+
}
|
|
452
|
+
if (!isDecodableBase64(match[2])) {
|
|
453
|
+
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 '='`;
|
|
454
|
+
}
|
|
455
|
+
return void 0;
|
|
456
|
+
}
|
|
457
|
+
let protocol;
|
|
458
|
+
try {
|
|
459
|
+
protocol = new URL(url).protocol;
|
|
460
|
+
} catch {
|
|
461
|
+
return `'${url}' is not a valid URL. Use an http(s) URL or a base64 data URL`;
|
|
462
|
+
}
|
|
463
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
464
|
+
return `URL protocol '${protocol}' is not supported. Use an http(s) URL or a base64 data URL`;
|
|
465
|
+
}
|
|
466
|
+
return void 0;
|
|
467
|
+
}
|
|
468
|
+
function normalizeMessageContent(content) {
|
|
469
|
+
if (typeof content === "string") return content;
|
|
470
|
+
if (Array.isArray(content)) {
|
|
471
|
+
return content.map((block) => {
|
|
472
|
+
if (typeof block === "string") return { type: "text", text: block };
|
|
473
|
+
const record = block;
|
|
474
|
+
if ((record == null ? void 0 : record.type) === "image_url" && typeof record.image_url === "string") {
|
|
475
|
+
return { ...record, image_url: { url: record.image_url } };
|
|
476
|
+
}
|
|
477
|
+
return record;
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
if (content === null || content === void 0) return "";
|
|
481
|
+
return JSON.stringify(content);
|
|
482
|
+
}
|
|
332
483
|
const GATEWAY_MANAGED_PARAMETERS = /* @__PURE__ */ new Set(["model", "messages", "tools", "tool_choice", "stream", "n"]);
|
|
333
484
|
function getProviderRequestParameters(body) {
|
|
334
485
|
return Object.fromEntries(
|
|
@@ -399,6 +550,9 @@ function serializeToolArguments(value) {
|
|
|
399
550
|
// Annotate the CommonJS export names for ESM import in node:
|
|
400
551
|
0 && (module.exports = {
|
|
401
552
|
applyProviderRequestParameters,
|
|
553
|
+
findContentBlockProblem,
|
|
554
|
+
findMessageProblem,
|
|
402
555
|
getProviderRequestParameters,
|
|
403
|
-
handleChatCompletions
|
|
556
|
+
handleChatCompletions,
|
|
557
|
+
normalizeMessageContent
|
|
404
558
|
});
|