plugin-ai-api 1.0.23 → 1.0.25
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/757.56952e321dc399b7.js +10 -0
- package/dist/client/902.e74518750f1e4201.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
- package/dist/client-v2/902.c7c00a565085438a.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +4 -0
- package/dist/locale/vi-VN.json +4 -0
- package/dist/locale/zh-CN.json +4 -0
- package/dist/server/billing.js +6 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- package/dist/server/plugin.js +10 -0
- package/dist/server/resource/ai-api-config.js +5 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/chat-completions.js +110 -19
- package/dist/server/routes/completions.js +59 -24
- package/dist/server/services/file-processor.js +262 -0
- package/dist/server/usage.js +33 -3
- package/dist/server/utils/direct-llm-context.js +319 -0
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/server/validation.js +3 -0
- package/dist/swagger.js +42 -3
- package/package.json +1 -1
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
- package/src/locale/en-US.json +4 -0
- package/src/locale/vi-VN.json +4 -0
- package/src/locale/zh-CN.json +4 -0
- package/src/server/__tests__/direct-llm-context.test.ts +206 -0
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/request-body.test.ts +45 -2
- package/src/server/__tests__/usage-route.test.ts +173 -9
- package/src/server/__tests__/usage.test.ts +19 -0
- package/src/server/__tests__/validation.test.ts +36 -0
- package/src/server/billing.ts +6 -1
- package/src/server/collections/ai-api-config.ts +8 -0
- package/src/server/collections/ai-api-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +70 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- package/src/server/plugin.ts +20 -0
- package/src/server/resource/ai-api-config.ts +5 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/chat-completions.ts +157 -22
- package/src/server/routes/completions.ts +61 -23
- package/src/server/services/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +323 -0
- package/src/server/usage.ts +47 -1
- package/src/server/utils/direct-llm-context.ts +394 -0
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/resolve-service.ts +82 -82
- package/src/server/validation.ts +3 -0
- package/src/swagger.ts +45 -3
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
- package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
|
@@ -41,8 +41,11 @@ var import_role_permission = require("../middleware/role-permission");
|
|
|
41
41
|
var import_user_permissions = require("../utils/user-permissions");
|
|
42
42
|
var import_usage = require("../usage");
|
|
43
43
|
var import_billing = require("../billing");
|
|
44
|
+
var import_direct_llm_context = require("../utils/direct-llm-context");
|
|
44
45
|
var import_app_observability = require("../utils/app-observability");
|
|
46
|
+
var import_file_processor = require("../services/file-processor");
|
|
45
47
|
async function handleChatCompletions(ctx, plugin) {
|
|
48
|
+
var _a;
|
|
46
49
|
const body = ctx.request.body;
|
|
47
50
|
if (!(body == null ? void 0 : body.model)) {
|
|
48
51
|
ctx.status = 400;
|
|
@@ -126,7 +129,6 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
126
129
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
|
|
127
130
|
return;
|
|
128
131
|
}
|
|
129
|
-
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
130
132
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
131
133
|
if (stream) {
|
|
132
134
|
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
|
|
@@ -146,12 +148,6 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
146
148
|
if (body.frequency_penalty !== void 0) modelOptions.frequencyPenalty = body.frequency_penalty;
|
|
147
149
|
if (body.presence_penalty !== void 0) modelOptions.presencePenalty = body.presence_penalty;
|
|
148
150
|
if (body.stop !== void 0) modelOptions.stop = body.stop;
|
|
149
|
-
const Provider = providerMeta.provider;
|
|
150
|
-
const provider = new Provider({
|
|
151
|
-
app: ctx.app,
|
|
152
|
-
serviceOptions: service.options,
|
|
153
|
-
modelOptions
|
|
154
|
-
});
|
|
155
151
|
let systemPrompt = "";
|
|
156
152
|
if (config == null ? void 0 : config.defaultAiEmployee) {
|
|
157
153
|
if (!(0, import_role_permission.checkEmployeeAccess)(ctx, config.defaultAiEmployee)) {
|
|
@@ -171,11 +167,33 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
171
167
|
systemPrompt = employee.about || employee.defaultPrompt || "";
|
|
172
168
|
}
|
|
173
169
|
}
|
|
174
|
-
|
|
170
|
+
let messages = [...body.messages];
|
|
175
171
|
const hasSystemMessage = messages.some((m) => m.role === "system");
|
|
176
172
|
if (systemPrompt && !hasSystemMessage) {
|
|
177
173
|
messages.unshift({ role: "system", content: systemPrompt });
|
|
178
174
|
}
|
|
175
|
+
messages = await Promise.all(
|
|
176
|
+
messages.map(async (msg) => ({
|
|
177
|
+
...msg,
|
|
178
|
+
content: await processMessageContentFileBlocks(msg.content, ctx, plugin)
|
|
179
|
+
}))
|
|
180
|
+
);
|
|
181
|
+
const preparedContext = await (0, import_direct_llm_context.prepareDirectLlmContext)(ctx, {
|
|
182
|
+
serviceName: service.name,
|
|
183
|
+
modelId,
|
|
184
|
+
messages,
|
|
185
|
+
tools: body.tools,
|
|
186
|
+
maxCompletionTokens: body.max_completion_tokens,
|
|
187
|
+
maxTokens: body.max_tokens
|
|
188
|
+
});
|
|
189
|
+
messages = preparedContext.messages;
|
|
190
|
+
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
191
|
+
const Provider = providerMeta.provider;
|
|
192
|
+
const provider = new Provider({
|
|
193
|
+
app: ctx.app,
|
|
194
|
+
serviceOptions: service.options,
|
|
195
|
+
modelOptions
|
|
196
|
+
});
|
|
179
197
|
const langchainMessages = messages.map((msg) => {
|
|
180
198
|
const role = msg.role === "assistant" ? "ai" : msg.role;
|
|
181
199
|
const content = normalizeMessageContent(msg.content);
|
|
@@ -218,15 +236,17 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
218
236
|
}
|
|
219
237
|
} catch (err) {
|
|
220
238
|
ctx.log.error("AI API chat completions error:", err);
|
|
221
|
-
if (!ctx.res.headersSent) {
|
|
239
|
+
if (!((_a = ctx.res) == null ? void 0 : _a.headersSent)) {
|
|
222
240
|
const isQuotaError = err instanceof import_billing.AiApiQuotaError;
|
|
223
|
-
|
|
241
|
+
const isContextError = err instanceof import_direct_llm_context.DirectLlmContextError;
|
|
242
|
+
const isFileError = err instanceof import_file_processor.FileProcessorError;
|
|
243
|
+
ctx.status = isQuotaError ? 429 : isContextError || isFileError ? 400 : 500;
|
|
224
244
|
if (isQuotaError) ctx.set("X-RateLimit-Reason", err.code);
|
|
225
245
|
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
226
246
|
ctx.status,
|
|
227
247
|
getErrorMessage(err, "Internal server error"),
|
|
228
|
-
isQuotaError ? "quota_error" : "server_error",
|
|
229
|
-
isQuotaError ? err.code : void 0
|
|
248
|
+
isQuotaError ? "quota_error" : isContextError || isFileError ? "invalid_request_error" : "server_error",
|
|
249
|
+
isQuotaError || isContextError || isFileError ? err.code : void 0
|
|
230
250
|
);
|
|
231
251
|
}
|
|
232
252
|
}
|
|
@@ -240,10 +260,15 @@ async function handleNonStreamingCompletion(ctx, chatModel, messages, completion
|
|
|
240
260
|
const textPart = result.content.find((c) => c.type === "text");
|
|
241
261
|
content = (textPart == null ? void 0 : textPart.text) || JSON.stringify(result.content);
|
|
242
262
|
}
|
|
243
|
-
const usage = (0, import_usage.setAiApiUsageResult)(
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
263
|
+
const usage = (0, import_usage.setAiApiUsageResult)(
|
|
264
|
+
ctx,
|
|
265
|
+
result.usage_metadata,
|
|
266
|
+
{
|
|
267
|
+
gatewayResponseId: completionId,
|
|
268
|
+
providerRequestId: (0, import_usage.extractProviderRequestId)(result)
|
|
269
|
+
},
|
|
270
|
+
result.response_metadata
|
|
271
|
+
);
|
|
247
272
|
ctx.status = 200;
|
|
248
273
|
const toolCalls = normalizeToolCalls(result.tool_calls);
|
|
249
274
|
ctx.body = (0, import_openai_format.toOpenAIResponse)({
|
|
@@ -375,8 +400,9 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
375
400
|
function getErrorMessage(error, fallback) {
|
|
376
401
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
377
402
|
}
|
|
378
|
-
const SUPPORTED_CONTENT_BLOCK_TYPES = /* @__PURE__ */ new Set(["text", "image_url"]);
|
|
403
|
+
const SUPPORTED_CONTENT_BLOCK_TYPES = /* @__PURE__ */ new Set(["text", "image_url", "file", "file_url"]);
|
|
379
404
|
const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
405
|
+
const FILE_BASE64_DATA_URL_PATTERN = /^data:([^;\s]+);base64,([A-Za-z0-9+/]+=*)$/;
|
|
380
406
|
function isDecodableBase64(payload) {
|
|
381
407
|
try {
|
|
382
408
|
return Buffer.from(payload, "base64").toString("base64") === payload;
|
|
@@ -428,13 +454,50 @@ function describeContentBlockProblem(block) {
|
|
|
428
454
|
const type = typeof block.type === "string" ? block.type : void 0;
|
|
429
455
|
if (!type) return "each content block requires a 'type' field";
|
|
430
456
|
if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
|
|
431
|
-
return `content block type '${type}' is not supported \u2014 this gateway forwards 'text' and '
|
|
457
|
+
return `content block type '${type}' is not supported \u2014 this gateway forwards 'text', 'image_url', 'file', and 'file_url' only. Send documents as text, or inline them as a 'file' / 'file_url' block`;
|
|
432
458
|
}
|
|
433
459
|
if (type === "text") {
|
|
434
460
|
return typeof block.text === "string" ? void 0 : "a 'text' block requires a string 'text' field";
|
|
435
461
|
}
|
|
462
|
+
if (type === "file") {
|
|
463
|
+
return describeFileProblem(block.file);
|
|
464
|
+
}
|
|
465
|
+
if (type === "file_url") {
|
|
466
|
+
return describeFileUrlProblem(block.file_url);
|
|
467
|
+
}
|
|
436
468
|
return describeImageUrlProblem(block.image_url);
|
|
437
469
|
}
|
|
470
|
+
function describeFileProblem(file) {
|
|
471
|
+
if (!isRecord(file)) return "a 'file' block requires an object 'file' field";
|
|
472
|
+
const fileData = typeof file.file_data === "string" ? file.file_data : void 0;
|
|
473
|
+
if (!fileData) return "a 'file' block requires a string 'file.file_data' field";
|
|
474
|
+
if (!fileData.startsWith("data:")) {
|
|
475
|
+
return "a 'file' block's 'file_data' must be a base64 data URL starting with 'data:'";
|
|
476
|
+
}
|
|
477
|
+
const match = FILE_BASE64_DATA_URL_PATTERN.exec(fileData);
|
|
478
|
+
if (!match || !match[1].includes("/")) {
|
|
479
|
+
return `malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' with a valid type/subtype and standard base64 (no whitespace or URL-safe characters)`;
|
|
480
|
+
}
|
|
481
|
+
if (!isDecodableBase64(match[2])) {
|
|
482
|
+
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 '='`;
|
|
483
|
+
}
|
|
484
|
+
return void 0;
|
|
485
|
+
}
|
|
486
|
+
function describeFileUrlProblem(fileUrl) {
|
|
487
|
+
if (!isRecord(fileUrl)) return "a 'file_url' block requires an object 'file_url' field";
|
|
488
|
+
const url = typeof fileUrl.url === "string" ? fileUrl.url : void 0;
|
|
489
|
+
if (!url) return "a 'file_url' block requires a string 'file_url.url' field";
|
|
490
|
+
let protocol;
|
|
491
|
+
try {
|
|
492
|
+
protocol = new URL(url).protocol;
|
|
493
|
+
} catch {
|
|
494
|
+
return `'${url}' is not a valid URL. Use an http(s) URL`;
|
|
495
|
+
}
|
|
496
|
+
if (protocol !== "http:" && protocol !== "https:") {
|
|
497
|
+
return `URL protocol '${protocol}' is not supported. Use an http(s) URL`;
|
|
498
|
+
}
|
|
499
|
+
return void 0;
|
|
500
|
+
}
|
|
438
501
|
function describeImageUrlProblem(imageUrl) {
|
|
439
502
|
const url = typeof imageUrl === "string" ? imageUrl : isRecord(imageUrl) ? imageUrl.url : void 0;
|
|
440
503
|
if (typeof url !== "string" || url === "") {
|
|
@@ -480,7 +543,35 @@ function normalizeMessageContent(content) {
|
|
|
480
543
|
if (content === null || content === void 0) return "";
|
|
481
544
|
return JSON.stringify(content);
|
|
482
545
|
}
|
|
483
|
-
|
|
546
|
+
async function processMessageContentFileBlocks(content, ctx, plugin) {
|
|
547
|
+
if (!Array.isArray(content)) return content;
|
|
548
|
+
const processed = [];
|
|
549
|
+
for (const block of content) {
|
|
550
|
+
processed.push(...await processFileBlockChain(block, ctx, plugin, 0));
|
|
551
|
+
}
|
|
552
|
+
return processed;
|
|
553
|
+
}
|
|
554
|
+
const MAX_FILE_PROCESSOR_CHAIN_DEPTH = 3;
|
|
555
|
+
async function processFileBlockChain(block, ctx, plugin, depth) {
|
|
556
|
+
if (!isRecord(block) || block.type !== "file" && block.type !== "file_url") {
|
|
557
|
+
return [block];
|
|
558
|
+
}
|
|
559
|
+
if (depth > MAX_FILE_PROCESSOR_CHAIN_DEPTH) {
|
|
560
|
+
return [block];
|
|
561
|
+
}
|
|
562
|
+
const result = await plugin.fileProcessorService.process(block, { ctx });
|
|
563
|
+
const results = Array.isArray(result) ? result : [result];
|
|
564
|
+
const next = [];
|
|
565
|
+
for (const item of results) {
|
|
566
|
+
if (isRecord(item) && (item.type === "file" || item.type === "file_url")) {
|
|
567
|
+
next.push(...await processFileBlockChain(item, ctx, plugin, depth + 1));
|
|
568
|
+
} else {
|
|
569
|
+
next.push(item);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return next;
|
|
573
|
+
}
|
|
574
|
+
const GATEWAY_MANAGED_PARAMETERS = /* @__PURE__ */ new Set(["model", "messages", "prompt", "tools", "tool_choice", "stream", "n"]);
|
|
484
575
|
function getProviderRequestParameters(body) {
|
|
485
576
|
return Object.fromEntries(
|
|
486
577
|
Object.entries(body).filter(([name, value]) => !GATEWAY_MANAGED_PARAMETERS.has(name) && value !== void 0)
|
|
@@ -33,10 +33,13 @@ var import_openai_format = require("../utils/openai-format");
|
|
|
33
33
|
var import_resolve_service = require("../utils/resolve-service");
|
|
34
34
|
var import_user_permissions = require("../utils/user-permissions");
|
|
35
35
|
var import_streaming = require("../utils/streaming");
|
|
36
|
+
var import_chat_completions = require("./chat-completions");
|
|
36
37
|
var import_usage = require("../usage");
|
|
37
38
|
var import_billing = require("../billing");
|
|
39
|
+
var import_direct_llm_context = require("../utils/direct-llm-context");
|
|
38
40
|
var import_app_observability = require("../utils/app-observability");
|
|
39
41
|
async function handleCompletions(ctx, plugin) {
|
|
42
|
+
var _a;
|
|
40
43
|
const body = ctx.request.body;
|
|
41
44
|
if (!(body == null ? void 0 : body.model)) {
|
|
42
45
|
ctx.status = 400;
|
|
@@ -98,7 +101,6 @@ async function handleCompletions(ctx, plugin) {
|
|
|
98
101
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
|
|
99
102
|
return;
|
|
100
103
|
}
|
|
101
|
-
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
102
104
|
const modelOptions = {
|
|
103
105
|
model: modelId,
|
|
104
106
|
llmService: service.name
|
|
@@ -107,14 +109,8 @@ async function handleCompletions(ctx, plugin) {
|
|
|
107
109
|
if (body.top_p !== void 0) modelOptions.topP = body.top_p;
|
|
108
110
|
if (body.max_tokens !== void 0) modelOptions.maxTokens = body.max_tokens;
|
|
109
111
|
if (body.stop !== void 0) modelOptions.stop = body.stop;
|
|
110
|
-
const Provider = providerMeta.provider;
|
|
111
|
-
const provider = new Provider({
|
|
112
|
-
app: ctx.app,
|
|
113
|
-
serviceOptions: service.options,
|
|
114
|
-
modelOptions
|
|
115
|
-
});
|
|
116
112
|
const prompt = typeof body.prompt === "string" ? body.prompt : Array.isArray(body.prompt) ? body.prompt.join("\n") : String(body.prompt);
|
|
117
|
-
const
|
|
113
|
+
const messages = [];
|
|
118
114
|
if (config == null ? void 0 : config.defaultAiEmployee) {
|
|
119
115
|
const employee = await ctx.db.getRepository("aiEmployees").findOne({
|
|
120
116
|
filter: { username: config.defaultAiEmployee }
|
|
@@ -122,13 +118,32 @@ async function handleCompletions(ctx, plugin) {
|
|
|
122
118
|
if (employee) {
|
|
123
119
|
const systemPrompt = employee.about || employee.defaultPrompt || "";
|
|
124
120
|
if (systemPrompt) {
|
|
125
|
-
|
|
121
|
+
messages.push({ role: "system", content: systemPrompt });
|
|
126
122
|
}
|
|
127
123
|
}
|
|
128
124
|
}
|
|
129
|
-
|
|
125
|
+
messages.push({ role: "user", content: prompt });
|
|
126
|
+
const preparedContext = await (0, import_direct_llm_context.prepareDirectLlmContext)(ctx, {
|
|
127
|
+
serviceName: service.name,
|
|
128
|
+
modelId,
|
|
129
|
+
messages,
|
|
130
|
+
maxTokens: body.max_tokens
|
|
131
|
+
});
|
|
132
|
+
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
133
|
+
const Provider = providerMeta.provider;
|
|
134
|
+
const provider = new Provider({
|
|
135
|
+
app: ctx.app,
|
|
136
|
+
serviceOptions: service.options,
|
|
137
|
+
modelOptions
|
|
138
|
+
});
|
|
139
|
+
const langchainMessages = preparedContext.messages.map((message) => [
|
|
140
|
+
message.role === "user" ? "human" : message.role,
|
|
141
|
+
String(message.content ?? "")
|
|
142
|
+
]);
|
|
130
143
|
const completionId = (0, import_openai_format.generateCompletionId)().replace("chatcmpl-", "cmpl-");
|
|
131
144
|
const chatModel = provider.createModel();
|
|
145
|
+
const providerRequestParameters = (0, import_chat_completions.getProviderRequestParameters)(body);
|
|
146
|
+
(0, import_chat_completions.applyProviderRequestParameters)(chatModel, providerRequestParameters);
|
|
132
147
|
(0, import_billing.markLlmProviderAttempted)(ctx);
|
|
133
148
|
if (stream) {
|
|
134
149
|
await handleStreamingTextCompletion(
|
|
@@ -137,28 +152,37 @@ async function handleCompletions(ctx, plugin) {
|
|
|
137
152
|
langchainMessages,
|
|
138
153
|
completionId,
|
|
139
154
|
body.model,
|
|
140
|
-
body.stream_options
|
|
155
|
+
body.stream_options,
|
|
156
|
+
providerRequestParameters
|
|
141
157
|
);
|
|
142
158
|
} else {
|
|
143
|
-
await handleNonStreamingTextCompletion(
|
|
159
|
+
await handleNonStreamingTextCompletion(
|
|
160
|
+
ctx,
|
|
161
|
+
chatModel,
|
|
162
|
+
langchainMessages,
|
|
163
|
+
completionId,
|
|
164
|
+
body.model,
|
|
165
|
+
providerRequestParameters
|
|
166
|
+
);
|
|
144
167
|
}
|
|
145
168
|
} catch (err) {
|
|
146
169
|
ctx.log.error("AI API completions error:", err);
|
|
147
|
-
if (!ctx.res.headersSent) {
|
|
170
|
+
if (!((_a = ctx.res) == null ? void 0 : _a.headersSent)) {
|
|
148
171
|
const isQuotaError = err instanceof import_billing.AiApiQuotaError;
|
|
149
|
-
|
|
172
|
+
const isContextError = err instanceof import_direct_llm_context.DirectLlmContextError;
|
|
173
|
+
ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
|
|
150
174
|
if (isQuotaError) ctx.set("X-RateLimit-Reason", err.code);
|
|
151
175
|
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
152
176
|
ctx.status,
|
|
153
177
|
getErrorMessage(err, "Internal server error"),
|
|
154
|
-
isQuotaError ? "quota_error" : "server_error",
|
|
155
|
-
isQuotaError ? err.code : void 0
|
|
178
|
+
isQuotaError ? "quota_error" : isContextError ? "invalid_request_error" : "server_error",
|
|
179
|
+
isQuotaError || isContextError ? err.code : void 0
|
|
156
180
|
);
|
|
157
181
|
}
|
|
158
182
|
}
|
|
159
183
|
}
|
|
160
|
-
async function handleNonStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName) {
|
|
161
|
-
const result = await chatModel.invoke(messages);
|
|
184
|
+
async function handleNonStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, providerRequestParameters) {
|
|
185
|
+
const result = await chatModel.invoke(messages, providerRequestParameters);
|
|
162
186
|
let text = "";
|
|
163
187
|
if (typeof result.content === "string") {
|
|
164
188
|
text = result.content;
|
|
@@ -166,10 +190,15 @@ async function handleNonStreamingTextCompletion(ctx, chatModel, messages, comple
|
|
|
166
190
|
const textPart = result.content.find((c) => c.type === "text");
|
|
167
191
|
text = (textPart == null ? void 0 : textPart.text) || JSON.stringify(result.content);
|
|
168
192
|
}
|
|
169
|
-
const usage = (0, import_usage.setAiApiUsageResult)(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
193
|
+
const usage = (0, import_usage.setAiApiUsageResult)(
|
|
194
|
+
ctx,
|
|
195
|
+
result.usage_metadata,
|
|
196
|
+
{
|
|
197
|
+
gatewayResponseId: completionId,
|
|
198
|
+
providerRequestId: (0, import_usage.extractProviderRequestId)(result)
|
|
199
|
+
},
|
|
200
|
+
result.response_metadata
|
|
201
|
+
);
|
|
173
202
|
ctx.status = 200;
|
|
174
203
|
ctx.body = {
|
|
175
204
|
id: completionId,
|
|
@@ -185,10 +214,15 @@ async function handleNonStreamingTextCompletion(ctx, chatModel, messages, comple
|
|
|
185
214
|
finish_reason: "stop"
|
|
186
215
|
}
|
|
187
216
|
],
|
|
188
|
-
usage: usage
|
|
217
|
+
usage: usage ? {
|
|
218
|
+
prompt_tokens: usage.prompt_tokens,
|
|
219
|
+
completion_tokens: usage.completion_tokens,
|
|
220
|
+
total_tokens: usage.total_tokens,
|
|
221
|
+
prompt_tokens_details: { cached_tokens: usage.prompt_cache_tokens ?? null }
|
|
222
|
+
} : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: null } }
|
|
189
223
|
};
|
|
190
224
|
}
|
|
191
|
-
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, streamOptions) {
|
|
225
|
+
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, streamOptions, providerRequestParameters) {
|
|
192
226
|
ctx.set({
|
|
193
227
|
"Content-Type": "text/event-stream",
|
|
194
228
|
"Cache-Control": "no-cache",
|
|
@@ -201,6 +235,7 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
|
|
|
201
235
|
let providerRequestId;
|
|
202
236
|
try {
|
|
203
237
|
const stream = await chatModel.stream(messages, {
|
|
238
|
+
...providerRequestParameters,
|
|
204
239
|
stream_options: { ...streamOptions, include_usage: true },
|
|
205
240
|
signal: requestAbort.signal
|
|
206
241
|
});
|
|
@@ -0,0 +1,262 @@
|
|
|
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 file_processor_exports = {};
|
|
28
|
+
__export(file_processor_exports, {
|
|
29
|
+
FileProcessorError: () => FileProcessorError,
|
|
30
|
+
FileProcessorService: () => FileProcessorService,
|
|
31
|
+
base64FileForwarder: () => base64FileForwarder,
|
|
32
|
+
fetchFileAsBase64: () => fetchFileAsBase64,
|
|
33
|
+
httpFileUrlFetcher: () => httpFileUrlFetcher,
|
|
34
|
+
pdfFileProcessor: () => pdfFileProcessor
|
|
35
|
+
});
|
|
36
|
+
module.exports = __toCommonJS(file_processor_exports);
|
|
37
|
+
var import_path = require("path");
|
|
38
|
+
class FileProcessorError extends Error {
|
|
39
|
+
constructor(code, message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.code = code;
|
|
42
|
+
this.name = "FileProcessorError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
46
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
47
|
+
const ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
48
|
+
class FileProcessorService {
|
|
49
|
+
processors = [];
|
|
50
|
+
pdfRenderer = null;
|
|
51
|
+
register(processor) {
|
|
52
|
+
this.unregister(processor.name);
|
|
53
|
+
this.processors.push(processor);
|
|
54
|
+
}
|
|
55
|
+
unregister(name) {
|
|
56
|
+
this.processors = this.processors.filter((p) => p.name !== name);
|
|
57
|
+
}
|
|
58
|
+
async process(block, context) {
|
|
59
|
+
const processor = [...this.processors].reverse().find((p) => p.canHandle(block));
|
|
60
|
+
if (!processor) {
|
|
61
|
+
return block;
|
|
62
|
+
}
|
|
63
|
+
return processor.process(block, context);
|
|
64
|
+
}
|
|
65
|
+
list() {
|
|
66
|
+
return [...this.processors];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Register a renderer used to convert PDF pages into PNG images when
|
|
70
|
+
* `pdfRenderPagesAsImages` is enabled in the AI API config.
|
|
71
|
+
*/
|
|
72
|
+
registerPdfRenderer(renderer) {
|
|
73
|
+
this.pdfRenderer = renderer;
|
|
74
|
+
}
|
|
75
|
+
unregisterPdfRenderer() {
|
|
76
|
+
this.pdfRenderer = null;
|
|
77
|
+
}
|
|
78
|
+
getPdfRenderer() {
|
|
79
|
+
return this.pdfRenderer;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function isRecord(value) {
|
|
83
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
84
|
+
}
|
|
85
|
+
function getUrlString(value) {
|
|
86
|
+
if (typeof value === "string") return value;
|
|
87
|
+
if (isRecord(value) && typeof value.url === "string") return value.url;
|
|
88
|
+
return void 0;
|
|
89
|
+
}
|
|
90
|
+
function extractFilename(url, contentDisposition) {
|
|
91
|
+
if (contentDisposition) {
|
|
92
|
+
const match = contentDisposition.match(/filename="?([^"]+)"?/);
|
|
93
|
+
if (match) return match[1];
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const pathname = new URL(url).pathname;
|
|
97
|
+
if (pathname) return (0, import_path.basename)(pathname);
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
return void 0;
|
|
101
|
+
}
|
|
102
|
+
async function fetchFileAsBase64(url, options = {}) {
|
|
103
|
+
const maxSize = options.maxSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
|
|
104
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
105
|
+
const allowedProtocols = options.allowedProtocols ? new Set(options.allowedProtocols) : ALLOWED_PROTOCOLS;
|
|
106
|
+
let protocol;
|
|
107
|
+
try {
|
|
108
|
+
protocol = new URL(url).protocol;
|
|
109
|
+
} catch {
|
|
110
|
+
throw new FileProcessorError("invalid_url", `File URL '${url}' is not a valid URL.`);
|
|
111
|
+
}
|
|
112
|
+
if (!allowedProtocols.has(protocol)) {
|
|
113
|
+
throw new FileProcessorError("unsupported_protocol", `File URL protocol '${protocol}' is not allowed.`);
|
|
114
|
+
}
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
117
|
+
try {
|
|
118
|
+
const response = await fetch(url, {
|
|
119
|
+
signal: controller.signal,
|
|
120
|
+
redirect: "follow"
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
throw new FileProcessorError(
|
|
124
|
+
"fetch_failed",
|
|
125
|
+
`Failed to fetch file from '${url}': ${response.status} ${response.statusText}`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const contentLength = response.headers.get("content-length");
|
|
129
|
+
if (contentLength && Number(contentLength) > maxSize) {
|
|
130
|
+
throw new FileProcessorError("file_too_large", `File at '${url}' exceeds maximum allowed size.`);
|
|
131
|
+
}
|
|
132
|
+
const contentType = response.headers.get("content-type") || void 0;
|
|
133
|
+
if (options.allowedContentTypes && contentType && !options.allowedContentTypes.some((type) => contentType.includes(type))) {
|
|
134
|
+
throw new FileProcessorError("content_type_not_allowed", `File content type '${contentType}' is not allowed.`);
|
|
135
|
+
}
|
|
136
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
137
|
+
if (buffer.length > maxSize) {
|
|
138
|
+
throw new FileProcessorError("file_too_large", `File at '${url}' exceeds maximum allowed size.`);
|
|
139
|
+
}
|
|
140
|
+
const mimeType = (contentType == null ? void 0 : contentType.split(";")[0].trim()) ?? "application/octet-stream";
|
|
141
|
+
const contentDisposition = response.headers.get("content-disposition");
|
|
142
|
+
const filename = extractFilename(url, contentDisposition) ?? "file";
|
|
143
|
+
return {
|
|
144
|
+
fileData: `data:${mimeType};base64,${buffer.toString("base64")}`,
|
|
145
|
+
mimeType,
|
|
146
|
+
filename
|
|
147
|
+
};
|
|
148
|
+
} finally {
|
|
149
|
+
clearTimeout(timeout);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const base64FileForwarder = {
|
|
153
|
+
name: "base64FileForwarder",
|
|
154
|
+
canHandle(block) {
|
|
155
|
+
if (block.type !== "file") return false;
|
|
156
|
+
const file = isRecord(block.file) ? block.file : void 0;
|
|
157
|
+
if (!file) return false;
|
|
158
|
+
const fileData = String(file.file_data ?? "");
|
|
159
|
+
return fileData.startsWith("data:") && fileData.includes(";base64,");
|
|
160
|
+
},
|
|
161
|
+
async process(block) {
|
|
162
|
+
return block;
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const httpFileUrlFetcher = {
|
|
166
|
+
name: "httpFileUrlFetcher",
|
|
167
|
+
canHandle(block) {
|
|
168
|
+
if (block.type !== "file_url") return false;
|
|
169
|
+
const fileUrl = isRecord(block.file_url) ? block.file_url : void 0;
|
|
170
|
+
if (!fileUrl) return false;
|
|
171
|
+
const url = String(fileUrl.url ?? "");
|
|
172
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
173
|
+
},
|
|
174
|
+
async process(block) {
|
|
175
|
+
const fileUrl = isRecord(block.file_url) ? block.file_url : void 0;
|
|
176
|
+
const url = String((fileUrl == null ? void 0 : fileUrl.url) ?? "");
|
|
177
|
+
if (!url) {
|
|
178
|
+
throw new FileProcessorError("missing_url", "file_url block requires a 'url' property.");
|
|
179
|
+
}
|
|
180
|
+
const { fileData, mimeType, filename } = await fetchFileAsBase64(url);
|
|
181
|
+
return {
|
|
182
|
+
type: "file",
|
|
183
|
+
file: {
|
|
184
|
+
file_data: fileData,
|
|
185
|
+
mime_type: mimeType,
|
|
186
|
+
filename: filename || "file"
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
function decodeBase64DataUrl(url) {
|
|
192
|
+
const match = /^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/.exec(url);
|
|
193
|
+
if (!match) return void 0;
|
|
194
|
+
try {
|
|
195
|
+
const buffer = Buffer.from(match[2], "base64");
|
|
196
|
+
return { mimeType: match[1].toLowerCase(), buffer };
|
|
197
|
+
} catch {
|
|
198
|
+
return void 0;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function isPdfBuffer(buffer) {
|
|
202
|
+
return buffer.length >= 4 && buffer.toString("binary", 0, 4) === "%PDF";
|
|
203
|
+
}
|
|
204
|
+
function isPdfFileBlock(block) {
|
|
205
|
+
if (block.type !== "file") return false;
|
|
206
|
+
const file = isRecord(block.file) ? block.file : void 0;
|
|
207
|
+
if (!file) return false;
|
|
208
|
+
const fileData = String(file.file_data ?? "");
|
|
209
|
+
if (!fileData.startsWith("data:")) return false;
|
|
210
|
+
const mimeType = String(file.mime_type ?? "").toLowerCase();
|
|
211
|
+
if (mimeType === "application/pdf") return true;
|
|
212
|
+
if (fileData.startsWith("data:application/pdf")) return true;
|
|
213
|
+
const decoded = decodeBase64DataUrl(fileData);
|
|
214
|
+
if (decoded && isPdfBuffer(decoded.buffer)) return true;
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
function getPluginFromContext(context) {
|
|
218
|
+
var _a, _b, _c;
|
|
219
|
+
return (_c = (_b = (_a = context.ctx.app) == null ? void 0 : _a.pm) == null ? void 0 : _b.get) == null ? void 0 : _c.call(_b, "plugin-ai-api");
|
|
220
|
+
}
|
|
221
|
+
const pdfFileProcessor = {
|
|
222
|
+
name: "pdfFileProcessor",
|
|
223
|
+
canHandle(block) {
|
|
224
|
+
return isPdfFileBlock(block);
|
|
225
|
+
},
|
|
226
|
+
async process(block, context) {
|
|
227
|
+
var _a, _b, _c, _d;
|
|
228
|
+
const config = await context.ctx.db.getRepository("aiApiConfig").findOne();
|
|
229
|
+
if (!(config == null ? void 0 : config.pdfRenderPagesAsImages)) {
|
|
230
|
+
return block;
|
|
231
|
+
}
|
|
232
|
+
const plugin = getPluginFromContext(context);
|
|
233
|
+
const renderer = (_b = (_a = plugin == null ? void 0 : plugin.fileProcessorService) == null ? void 0 : _a.getPdfRenderer) == null ? void 0 : _b.call(_a);
|
|
234
|
+
if (!renderer) {
|
|
235
|
+
(_d = (_c = context.ctx.log) == null ? void 0 : _c.warn) == null ? void 0 : _d.call(
|
|
236
|
+
_c,
|
|
237
|
+
"[pdfFileProcessor] pdfRenderPagesAsImages is enabled but no PdfToImageRenderer is registered. Forwarding PDF as a file block."
|
|
238
|
+
);
|
|
239
|
+
return block;
|
|
240
|
+
}
|
|
241
|
+
const file = isRecord(block.file) ? block.file : void 0;
|
|
242
|
+
const fileData = String((file == null ? void 0 : file.file_data) ?? "");
|
|
243
|
+
const decoded = decodeBase64DataUrl(fileData);
|
|
244
|
+
if (!decoded) {
|
|
245
|
+
return block;
|
|
246
|
+
}
|
|
247
|
+
const pages = await renderer.render(decoded.buffer);
|
|
248
|
+
return pages.map((buffer) => ({
|
|
249
|
+
type: "image_url",
|
|
250
|
+
image_url: { url: `data:image/png;base64,${buffer.toString("base64")}` }
|
|
251
|
+
}));
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
255
|
+
0 && (module.exports = {
|
|
256
|
+
FileProcessorError,
|
|
257
|
+
FileProcessorService,
|
|
258
|
+
base64FileForwarder,
|
|
259
|
+
fetchFileAsBase64,
|
|
260
|
+
httpFileUrlFetcher,
|
|
261
|
+
pdfFileProcessor
|
|
262
|
+
});
|