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.
- 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.a01403fb7a1bea01.js +10 -0
- package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
- package/dist/client/97.72979a11a067a7c9.js +10 -0
- 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.a117ce1cf7119cea.js +10 -0
- package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
- package/dist/client-v2/952.94100128b7757f56.js +10 -0
- package/dist/client-v2/97.29c663318eebbd57.js +10 -0
- 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 +105 -10
- package/dist/locale/vi-VN.json +105 -0
- package/dist/locale/zh-CN.json +105 -10
- package/dist/server/billing.js +331 -0
- package/dist/server/collections/ai-api-config.js +18 -0
- package/dist/server/collections/ai-api-model-metadata.js +83 -0
- package/dist/server/collections/ai-api-model-prices.js +55 -0
- package/dist/server/collections/ai-api-usage-records.js +9 -0
- package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
- package/dist/server/plugin.js +36 -3
- package/dist/server/resource/ai-api-config.js +25 -0
- package/dist/server/resource/ai-api-usage-monitor.js +86 -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 +157 -6
- package/dist/server/routes/completions.js +20 -3
- package/dist/server/routes/models.js +78 -20
- package/dist/server/routes/router.js +108 -23
- package/dist/server/usage.js +19 -2
- package/dist/server/utils/app-observability.js +110 -0
- package/dist/server/utils/streaming.js +15 -1
- package/dist/server/validation.js +120 -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 +82 -48
- 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 -0
- package/src/client-v2/pages/GeneralPage.tsx +183 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
- package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
- package/src/client-v2/pages/UsagePage.tsx +248 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
- package/src/client-v2/pages/api.ts +16 -0
- package/src/client-v2/plugin.tsx +62 -4
- package/src/constants.ts +21 -0
- package/src/locale/en-US.json +105 -10
- package/src/locale/vi-VN.json +105 -0
- package/src/locale/zh-CN.json +105 -10
- package/src/server/__tests__/app-observability.test.ts +98 -0
- package/src/server/__tests__/billing-quota.test.ts +134 -0
- package/src/server/__tests__/billing.test.ts +33 -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/__tests__/usage-monitor.test.ts +63 -0
- package/src/server/__tests__/usage-route.test.ts +4 -0
- package/src/server/billing.ts +387 -0
- package/src/server/collections/ai-api-config.ts +69 -51
- package/src/server/collections/ai-api-model-metadata.ts +72 -0
- package/src/server/collections/ai-api-model-prices.ts +25 -0
- package/src/server/collections/ai-api-usage-records.ts +9 -0
- package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
- package/src/server/plugin.ts +47 -5
- package/src/server/resource/ai-api-config.ts +105 -74
- package/src/server/resource/ai-api-usage-monitor.ts +74 -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 +275 -6
- package/src/server/routes/completions.ts +27 -4
- package/src/server/routes/models.ts +290 -195
- package/src/server/routes/router.ts +152 -27
- package/src/server/usage.ts +19 -1
- package/src/server/utils/app-observability.ts +105 -0
- package/src/server/utils/streaming.ts +13 -1
- package/src/server/validation.ts +89 -0
- package/src/swagger.ts +38 -1
- package/dist/client/778.5c452944cb747975.js +0 -10
- package/dist/client/950.83390c5f1d5a97fb.js +0 -10
- package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
- package/src/client/AiApiConfigPage.tsx +0 -309
package/dist/server/plugin.js
CHANGED
|
@@ -43,8 +43,11 @@ module.exports = __toCommonJS(plugin_exports);
|
|
|
43
43
|
var import_server = require("@nocobase/server");
|
|
44
44
|
var import_router = require("./routes/router");
|
|
45
45
|
var import_ai_api_config = __toESM(require("./resource/ai-api-config"));
|
|
46
|
+
var import_ai_api_usage_monitor = __toESM(require("./resource/ai-api-usage-monitor"));
|
|
46
47
|
var import_rate_limiter = require("./utils/rate-limiter");
|
|
47
48
|
var import_role_permission = require("./middleware/role-permission");
|
|
49
|
+
var import_validation = require("./validation");
|
|
50
|
+
var import_constants = require("../constants");
|
|
48
51
|
var import_dayjs = __toESM(require("dayjs"));
|
|
49
52
|
var import_utc = __toESM(require("dayjs/plugin/utc"));
|
|
50
53
|
var import_timezone = __toESM(require("dayjs/plugin/timezone"));
|
|
@@ -60,10 +63,27 @@ class PluginAiApiServer extends import_server.Plugin {
|
|
|
60
63
|
async afterAdd() {
|
|
61
64
|
}
|
|
62
65
|
async beforeLoad() {
|
|
66
|
+
this.app.db.on("aiApiModelPrices.beforeSave", async (model) => {
|
|
67
|
+
await (0, import_validation.validateModelPrice)(this.db, model);
|
|
68
|
+
});
|
|
69
|
+
this.app.db.on("aiApiModelMetadata.beforeSave", (model) => {
|
|
70
|
+
(0, import_validation.validateModelMetadata)(model);
|
|
71
|
+
});
|
|
72
|
+
this.app.db.on("aiApiUserQuotaPolicies.beforeSave", (model) => {
|
|
73
|
+
(0, import_validation.validateQuotaPolicy)(model);
|
|
74
|
+
});
|
|
63
75
|
}
|
|
64
76
|
async load() {
|
|
77
|
+
this.app.use(
|
|
78
|
+
async (ctx, next) => {
|
|
79
|
+
if (ctx.path.startsWith(import_router.AI_LLM_PREFIX)) ctx.disableBodyParser = true;
|
|
80
|
+
await next();
|
|
81
|
+
},
|
|
82
|
+
{ tag: "aiApiDisableBodyParser", before: "bodyParser" }
|
|
83
|
+
);
|
|
65
84
|
this.app.use((0, import_router.createAiLlmRouter)(this), { after: "idp-oauth-resource-auth", before: "resourcer" });
|
|
66
85
|
this.app.resourceManager.define(import_ai_api_config.default);
|
|
86
|
+
this.app.resourceManager.define(import_ai_api_usage_monitor.default);
|
|
67
87
|
this.app.db.on("aiApiRolePermissions.afterSave", (model) => {
|
|
68
88
|
(0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
|
|
69
89
|
});
|
|
@@ -71,8 +91,19 @@ class PluginAiApiServer extends import_server.Plugin {
|
|
|
71
91
|
(0, import_role_permission.invalidateRolePermissionCache)(model.get("roleName"));
|
|
72
92
|
});
|
|
73
93
|
this.app.acl.registerSnippet({
|
|
74
|
-
name:
|
|
75
|
-
actions: [
|
|
94
|
+
name: import_constants.AI_API_ACL_SNIPPET,
|
|
95
|
+
actions: [
|
|
96
|
+
"aiApiConfig:*",
|
|
97
|
+
"aiApiRolePermissions:*",
|
|
98
|
+
"aiApiModelPrices:*",
|
|
99
|
+
"aiApiModelMetadata:*",
|
|
100
|
+
"aiApiUserQuotaPolicies:*",
|
|
101
|
+
"aiApiUserQuotaBuckets:list",
|
|
102
|
+
"aiApiUserQuotaBuckets:get",
|
|
103
|
+
"aiApiUsageRecords:list",
|
|
104
|
+
"aiApiUsageRecords:get",
|
|
105
|
+
"aiApiUsageMonitor:summary"
|
|
106
|
+
]
|
|
76
107
|
});
|
|
77
108
|
this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1e3);
|
|
78
109
|
this.gcInterval.unref();
|
|
@@ -84,7 +115,9 @@ class PluginAiApiServer extends import_server.Plugin {
|
|
|
84
115
|
values: {
|
|
85
116
|
defaultAiEmployee: "",
|
|
86
117
|
enabledLlmServices: [],
|
|
87
|
-
rateLimitPerMinute: 60
|
|
118
|
+
rateLimitPerMinute: 60,
|
|
119
|
+
quotaEnabled: false,
|
|
120
|
+
defaultReservationOutputTokens: 4096
|
|
88
121
|
}
|
|
89
122
|
});
|
|
90
123
|
}
|
|
@@ -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,9 @@ const aiApiConfigResource = {
|
|
|
42
54
|
defaultLlmService: "",
|
|
43
55
|
enabledLlmServices: [],
|
|
44
56
|
rateLimitPerMinute: 60,
|
|
57
|
+
maxRequestBodyMb: 10,
|
|
58
|
+
quotaEnabled: false,
|
|
59
|
+
defaultReservationOutputTokens: 4096,
|
|
45
60
|
options: {}
|
|
46
61
|
}
|
|
47
62
|
});
|
|
@@ -61,6 +76,9 @@ const aiApiConfigResource = {
|
|
|
61
76
|
defaultLlmService: values.defaultLlmService ?? "",
|
|
62
77
|
enabledLlmServices: values.enabledLlmServices ?? [],
|
|
63
78
|
rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
|
|
79
|
+
maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
|
|
80
|
+
quotaEnabled: values.quotaEnabled ?? false,
|
|
81
|
+
defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
|
|
64
82
|
options: values.options ?? {}
|
|
65
83
|
}
|
|
66
84
|
});
|
|
@@ -71,6 +89,13 @@ const aiApiConfigResource = {
|
|
|
71
89
|
if (values.defaultLlmService !== void 0) updateData.defaultLlmService = values.defaultLlmService;
|
|
72
90
|
if (values.enabledLlmServices !== void 0) updateData.enabledLlmServices = values.enabledLlmServices;
|
|
73
91
|
if (values.rateLimitPerMinute !== void 0) updateData.rateLimitPerMinute = values.rateLimitPerMinute;
|
|
92
|
+
if (values.maxRequestBodyMb !== void 0) {
|
|
93
|
+
updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
|
|
94
|
+
}
|
|
95
|
+
if (values.quotaEnabled !== void 0) updateData.quotaEnabled = values.quotaEnabled;
|
|
96
|
+
if (values.defaultReservationOutputTokens !== void 0) {
|
|
97
|
+
updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
|
|
98
|
+
}
|
|
74
99
|
if (values.options !== void 0) updateData.options = values.options;
|
|
75
100
|
await config.update(updateData);
|
|
76
101
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
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_usage_monitor_exports = {};
|
|
28
|
+
__export(ai_api_usage_monitor_exports, {
|
|
29
|
+
default: () => ai_api_usage_monitor_default
|
|
30
|
+
});
|
|
31
|
+
module.exports = __toCommonJS(ai_api_usage_monitor_exports);
|
|
32
|
+
var import_sequelize = require("sequelize");
|
|
33
|
+
function buildWhere(ctx) {
|
|
34
|
+
const params = ctx.action.params;
|
|
35
|
+
const where = {};
|
|
36
|
+
const start = typeof params.start === "string" ? new Date(params.start) : void 0;
|
|
37
|
+
const end = typeof params.end === "string" ? new Date(params.end) : void 0;
|
|
38
|
+
if (start && !Number.isNaN(start.getTime()) || end && !Number.isNaN(end.getTime())) {
|
|
39
|
+
const startedAt = {};
|
|
40
|
+
if (start && !Number.isNaN(start.getTime())) startedAt[import_sequelize.Op.gte] = start;
|
|
41
|
+
if (end && !Number.isNaN(end.getTime())) startedAt[import_sequelize.Op.lte] = end;
|
|
42
|
+
where.startedAt = startedAt;
|
|
43
|
+
}
|
|
44
|
+
if (params.userId !== void 0 && params.userId !== "") where.userId = params.userId;
|
|
45
|
+
if (params.resolvedService) where.resolvedService = params.resolvedService;
|
|
46
|
+
if (params.resolvedModel) where.resolvedModel = params.resolvedModel;
|
|
47
|
+
if (params.status) where.status = params.status;
|
|
48
|
+
return where;
|
|
49
|
+
}
|
|
50
|
+
const aiApiUsageMonitorResource = {
|
|
51
|
+
name: "aiApiUsageMonitor",
|
|
52
|
+
actions: {
|
|
53
|
+
async summary(ctx, next) {
|
|
54
|
+
const model = ctx.db.getCollection("aiApiUsageRecords").model;
|
|
55
|
+
const where = buildWhere(ctx);
|
|
56
|
+
const totals = await model.findOne({
|
|
57
|
+
attributes: [
|
|
58
|
+
[(0, import_sequelize.fn)("COUNT", (0, import_sequelize.col)("id")), "requestCount"],
|
|
59
|
+
[(0, import_sequelize.fn)("COALESCE", (0, import_sequelize.fn)("SUM", (0, import_sequelize.col)("inputTokens")), 0), "inputTokens"],
|
|
60
|
+
[(0, import_sequelize.fn)("COALESCE", (0, import_sequelize.fn)("SUM", (0, import_sequelize.col)("outputTokens")), 0), "outputTokens"],
|
|
61
|
+
[(0, import_sequelize.fn)("COALESCE", (0, import_sequelize.fn)("SUM", (0, import_sequelize.col)("totalTokens")), 0), "totalTokens"]
|
|
62
|
+
],
|
|
63
|
+
where,
|
|
64
|
+
raw: true
|
|
65
|
+
});
|
|
66
|
+
const costs = await model.findAll({
|
|
67
|
+
attributes: ["currency", [(0, import_sequelize.fn)("COALESCE", (0, import_sequelize.fn)("SUM", (0, import_sequelize.col)("estimatedCost")), 0), "totalCost"]],
|
|
68
|
+
where: { ...where, estimatedCost: { [import_sequelize.Op.ne]: null } },
|
|
69
|
+
group: ["currency"],
|
|
70
|
+
raw: true
|
|
71
|
+
});
|
|
72
|
+
ctx.body = {
|
|
73
|
+
requestCount: Number((totals == null ? void 0 : totals.requestCount) ?? 0),
|
|
74
|
+
inputTokens: Number((totals == null ? void 0 : totals.inputTokens) ?? 0),
|
|
75
|
+
outputTokens: Number((totals == null ? void 0 : totals.outputTokens) ?? 0),
|
|
76
|
+
totalTokens: Number((totals == null ? void 0 : totals.totalTokens) ?? 0),
|
|
77
|
+
costsByCurrency: costs.map((item) => ({
|
|
78
|
+
currency: item.currency || "USD",
|
|
79
|
+
totalCost: String(item.totalCost ?? 0)
|
|
80
|
+
}))
|
|
81
|
+
};
|
|
82
|
+
await next();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
var ai_api_usage_monitor_default = aiApiUsageMonitorResource;
|
|
@@ -35,6 +35,7 @@ var import_role_permission = require("../middleware/role-permission");
|
|
|
35
35
|
var import_streaming = require("../utils/streaming");
|
|
36
36
|
var import_ai_employee_runtime = require("../utils/ai-employee-runtime");
|
|
37
37
|
var import_usage = require("../usage");
|
|
38
|
+
var import_app_observability = require("../utils/app-observability");
|
|
38
39
|
async function handleAgentCompletions(ctx, plugin) {
|
|
39
40
|
var _a;
|
|
40
41
|
const body = ctx.request.body;
|
|
@@ -201,74 +202,83 @@ async function handleAgentCompletions(ctx, plugin) {
|
|
|
201
202
|
ctx.res.write(args[0]);
|
|
202
203
|
}
|
|
203
204
|
};
|
|
204
|
-
|
|
205
|
+
const processSseFrame = (frame) => {
|
|
205
206
|
var _a2;
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
207
|
+
for (const line of frame.split("\n")) {
|
|
208
|
+
const trimmed = line.trim();
|
|
209
|
+
if (!trimmed.startsWith("data: ")) continue;
|
|
210
|
+
const jsonStr = trimmed.substring(6);
|
|
211
|
+
if (!jsonStr) continue;
|
|
212
|
+
try {
|
|
213
|
+
const event = JSON.parse(jsonStr);
|
|
214
|
+
if (event.type === "content" && event.body) {
|
|
215
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
216
|
+
originalWrite(
|
|
217
|
+
(0, import_openai_format.formatSSE)(
|
|
218
|
+
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
219
|
+
id: completionId,
|
|
220
|
+
model: body.model,
|
|
221
|
+
delta: { content: String(event.body) }
|
|
222
|
+
})
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
} else if (event.type === "tool_call_chunks" && Array.isArray(event.body)) {
|
|
226
|
+
const chunks = toOpenAIToolCallChunks(event.body);
|
|
227
|
+
if (chunks.length) {
|
|
228
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
229
|
+
sawToolCalls = true;
|
|
218
230
|
originalWrite(
|
|
219
231
|
(0, import_openai_format.formatSSE)(
|
|
220
232
|
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
221
233
|
id: completionId,
|
|
222
234
|
model: body.model,
|
|
223
|
-
delta: {
|
|
235
|
+
delta: { tool_calls: chunks }
|
|
224
236
|
})
|
|
225
237
|
)
|
|
226
238
|
);
|
|
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) {
|
|
239
|
+
}
|
|
240
|
+
} else if (!sawToolCalls && event.type === "tool_calls" && Array.isArray((_a2 = event.body) == null ? void 0 : _a2.toolCalls)) {
|
|
241
|
+
const chunks = toOpenAIToolCallChunks(event.body.toolCalls);
|
|
242
|
+
if (chunks.length) {
|
|
243
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
244
|
+
sawToolCalls = true;
|
|
256
245
|
originalWrite(
|
|
257
|
-
(0, import_openai_format.formatSSE)(
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
}
|
|
263
|
-
|
|
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
|
+
)
|
|
264
253
|
);
|
|
265
254
|
}
|
|
266
|
-
}
|
|
255
|
+
} else if (event.type === "error" && event.body) {
|
|
256
|
+
originalWrite(
|
|
257
|
+
(0, import_openai_format.formatSSE)({
|
|
258
|
+
error: {
|
|
259
|
+
message: String(event.body),
|
|
260
|
+
type: "server_error",
|
|
261
|
+
code: "agent_error"
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
);
|
|
267
265
|
}
|
|
266
|
+
} catch {
|
|
268
267
|
}
|
|
269
268
|
}
|
|
269
|
+
};
|
|
270
|
+
ctx.res.write = (data) => {
|
|
271
|
+
pendingSse += typeof data === "string" ? data : data.toString("utf8");
|
|
272
|
+
const frames = pendingSse.split("\n\n");
|
|
273
|
+
pendingSse = frames.pop() || "";
|
|
274
|
+
for (const frame of frames) processSseFrame(frame);
|
|
270
275
|
return true;
|
|
271
276
|
};
|
|
277
|
+
const flushPendingSse = () => {
|
|
278
|
+
if (!pendingSse.trim()) return;
|
|
279
|
+
processSseFrame(pendingSse);
|
|
280
|
+
pendingSse = "";
|
|
281
|
+
};
|
|
272
282
|
try {
|
|
273
283
|
const aiEmployee = new AIEmployee(
|
|
274
284
|
(0, import_ai_employee_runtime.createAIEmployeeOptions)(ctx, employeeRecord, sessionId, {
|
|
@@ -291,6 +301,7 @@ async function handleAgentCompletions(ctx, plugin) {
|
|
|
291
301
|
ctx.req.off("aborted", abortAgent);
|
|
292
302
|
ctx.res.off("close", abortAgent);
|
|
293
303
|
if (streamSucceeded && !ctx.res.destroyed) {
|
|
304
|
+
flushPendingSse();
|
|
294
305
|
originalWrite(
|
|
295
306
|
(0, import_openai_format.formatSSE)(
|
|
296
307
|
(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,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");
|
|
@@ -36,6 +39,8 @@ 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");
|
|
38
41
|
var import_usage = require("../usage");
|
|
42
|
+
var import_billing = require("../billing");
|
|
43
|
+
var import_app_observability = require("../utils/app-observability");
|
|
39
44
|
async function handleChatCompletions(ctx, plugin) {
|
|
40
45
|
var _a;
|
|
41
46
|
const body = ctx.request.body;
|
|
@@ -49,6 +54,28 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
49
54
|
ctx.body = (0, import_openai_format.toOpenAIError)(400, "'messages' must be a non-empty array", "invalid_request_error", "missing_messages");
|
|
50
55
|
return;
|
|
51
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
|
+
}
|
|
52
79
|
if (body.n !== void 0 && body.n !== null && body.n !== 1) {
|
|
53
80
|
ctx.status = 400;
|
|
54
81
|
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
@@ -111,6 +138,7 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
111
138
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
|
|
112
139
|
return;
|
|
113
140
|
}
|
|
141
|
+
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
114
142
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
115
143
|
const modelOptions = {
|
|
116
144
|
model: modelId,
|
|
@@ -155,7 +183,7 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
155
183
|
}
|
|
156
184
|
const langchainMessages = messages.map((msg) => {
|
|
157
185
|
const role = msg.role === "assistant" ? "ai" : msg.role;
|
|
158
|
-
const content =
|
|
186
|
+
const content = normalizeMessageContent(msg.content);
|
|
159
187
|
if (msg.role === "assistant" && msg.tool_calls) {
|
|
160
188
|
return {
|
|
161
189
|
role,
|
|
@@ -173,6 +201,7 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
173
201
|
const baseModel = provider.createModel();
|
|
174
202
|
applyProviderRequestParameters(baseModel, providerRequestParameters);
|
|
175
203
|
const chatModel = bindRequestTools(baseModel, body.tools, body.tool_choice, providerRequestParameters);
|
|
204
|
+
(0, import_billing.markLlmProviderAttempted)(ctx);
|
|
176
205
|
if (stream) {
|
|
177
206
|
await handleStreamingCompletion(
|
|
178
207
|
ctx,
|
|
@@ -195,8 +224,15 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
195
224
|
} catch (err) {
|
|
196
225
|
ctx.log.error("AI API chat completions error:", err);
|
|
197
226
|
if (!ctx.res.headersSent) {
|
|
198
|
-
|
|
199
|
-
ctx.
|
|
227
|
+
const isQuotaError = err instanceof import_billing.AiApiQuotaError;
|
|
228
|
+
ctx.status = isQuotaError ? 429 : 500;
|
|
229
|
+
if (isQuotaError) ctx.set("X-RateLimit-Reason", err.code);
|
|
230
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
231
|
+
ctx.status,
|
|
232
|
+
getErrorMessage(err, "Internal server error"),
|
|
233
|
+
isQuotaError ? "quota_error" : "server_error",
|
|
234
|
+
isQuotaError ? err.code : void 0
|
|
235
|
+
);
|
|
200
236
|
}
|
|
201
237
|
}
|
|
202
238
|
}
|
|
@@ -258,6 +294,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
258
294
|
content = (textPart == null ? void 0 : textPart.text) || "";
|
|
259
295
|
}
|
|
260
296
|
if (content) {
|
|
297
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
261
298
|
await (0, import_streaming.writeResponse)(
|
|
262
299
|
ctx,
|
|
263
300
|
(0, import_openai_format.formatSSE)(
|
|
@@ -271,6 +308,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
271
308
|
}
|
|
272
309
|
const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
|
|
273
310
|
if (toolCallChunks.length) {
|
|
311
|
+
(0, import_app_observability.markAiApiFirstProviderOutput)(ctx);
|
|
274
312
|
finishReason = "tool_calls";
|
|
275
313
|
await (0, import_streaming.writeResponse)(
|
|
276
314
|
ctx,
|
|
@@ -297,6 +335,7 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
297
335
|
(0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
298
336
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
299
337
|
} catch (err) {
|
|
338
|
+
const cancelled = (0, import_streaming.isClientDisconnected)(ctx, err);
|
|
300
339
|
ctx.log.error("AI API streaming error:", err);
|
|
301
340
|
if (!ctx.res.destroyed && !ctx.res.writableEnded) {
|
|
302
341
|
await (0, import_streaming.writeResponse)(
|
|
@@ -310,7 +349,11 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
310
349
|
);
|
|
311
350
|
}
|
|
312
351
|
(0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
313
|
-
ctx.state.aiApiStreamResult = {
|
|
352
|
+
ctx.state.aiApiStreamResult = {
|
|
353
|
+
succeeded: false,
|
|
354
|
+
id: completionId,
|
|
355
|
+
errorCode: cancelled ? "client_disconnected" : "stream_error"
|
|
356
|
+
};
|
|
314
357
|
} finally {
|
|
315
358
|
requestAbort.dispose();
|
|
316
359
|
if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
|
|
@@ -319,6 +362,111 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
319
362
|
function getErrorMessage(error, fallback) {
|
|
320
363
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
321
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
|
+
}
|
|
322
470
|
const GATEWAY_MANAGED_PARAMETERS = /* @__PURE__ */ new Set(["model", "messages", "tools", "tool_choice", "stream", "n"]);
|
|
323
471
|
function getProviderRequestParameters(body) {
|
|
324
472
|
return Object.fromEntries(
|
|
@@ -389,6 +537,9 @@ function serializeToolArguments(value) {
|
|
|
389
537
|
// Annotate the CommonJS export names for ESM import in node:
|
|
390
538
|
0 && (module.exports = {
|
|
391
539
|
applyProviderRequestParameters,
|
|
540
|
+
findContentBlockProblem,
|
|
541
|
+
findMessageProblem,
|
|
392
542
|
getProviderRequestParameters,
|
|
393
|
-
handleChatCompletions
|
|
543
|
+
handleChatCompletions,
|
|
544
|
+
normalizeMessageContent
|
|
394
545
|
});
|