plugin-ai-api 1.0.24 → 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/index.js +1 -1
- package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +1 -0
- package/dist/locale/vi-VN.json +1 -0
- package/dist/locale/zh-CN.json +1 -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/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 +89 -10
- package/dist/server/routes/completions.js +32 -10
- package/dist/server/services/file-processor.js +262 -0
- package/dist/server/usage.js +33 -3
- package/dist/server/utils/direct-llm-context.js +150 -15
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/swagger.js +42 -3
- package/package.json +1 -1
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/locale/en-US.json +1 -0
- package/src/locale/vi-VN.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/server/__tests__/direct-llm-context.test.ts +87 -6
- 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 +120 -3
- package/src/server/__tests__/usage.test.ts +19 -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/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 +134 -11
- package/src/server/routes/completions.ts +33 -7
- 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 +198 -20
- 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/swagger.ts +45 -3
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
|
@@ -33,6 +33,7 @@ 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");
|
|
38
39
|
var import_direct_llm_context = require("../utils/direct-llm-context");
|
|
@@ -141,6 +142,8 @@ async function handleCompletions(ctx, plugin) {
|
|
|
141
142
|
]);
|
|
142
143
|
const completionId = (0, import_openai_format.generateCompletionId)().replace("chatcmpl-", "cmpl-");
|
|
143
144
|
const chatModel = provider.createModel();
|
|
145
|
+
const providerRequestParameters = (0, import_chat_completions.getProviderRequestParameters)(body);
|
|
146
|
+
(0, import_chat_completions.applyProviderRequestParameters)(chatModel, providerRequestParameters);
|
|
144
147
|
(0, import_billing.markLlmProviderAttempted)(ctx);
|
|
145
148
|
if (stream) {
|
|
146
149
|
await handleStreamingTextCompletion(
|
|
@@ -149,10 +152,18 @@ async function handleCompletions(ctx, plugin) {
|
|
|
149
152
|
langchainMessages,
|
|
150
153
|
completionId,
|
|
151
154
|
body.model,
|
|
152
|
-
body.stream_options
|
|
155
|
+
body.stream_options,
|
|
156
|
+
providerRequestParameters
|
|
153
157
|
);
|
|
154
158
|
} else {
|
|
155
|
-
await handleNonStreamingTextCompletion(
|
|
159
|
+
await handleNonStreamingTextCompletion(
|
|
160
|
+
ctx,
|
|
161
|
+
chatModel,
|
|
162
|
+
langchainMessages,
|
|
163
|
+
completionId,
|
|
164
|
+
body.model,
|
|
165
|
+
providerRequestParameters
|
|
166
|
+
);
|
|
156
167
|
}
|
|
157
168
|
} catch (err) {
|
|
158
169
|
ctx.log.error("AI API completions error:", err);
|
|
@@ -170,8 +181,8 @@ async function handleCompletions(ctx, plugin) {
|
|
|
170
181
|
}
|
|
171
182
|
}
|
|
172
183
|
}
|
|
173
|
-
async function handleNonStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName) {
|
|
174
|
-
const result = await chatModel.invoke(messages);
|
|
184
|
+
async function handleNonStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, providerRequestParameters) {
|
|
185
|
+
const result = await chatModel.invoke(messages, providerRequestParameters);
|
|
175
186
|
let text = "";
|
|
176
187
|
if (typeof result.content === "string") {
|
|
177
188
|
text = result.content;
|
|
@@ -179,10 +190,15 @@ async function handleNonStreamingTextCompletion(ctx, chatModel, messages, comple
|
|
|
179
190
|
const textPart = result.content.find((c) => c.type === "text");
|
|
180
191
|
text = (textPart == null ? void 0 : textPart.text) || JSON.stringify(result.content);
|
|
181
192
|
}
|
|
182
|
-
const usage = (0, import_usage.setAiApiUsageResult)(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
+
);
|
|
186
202
|
ctx.status = 200;
|
|
187
203
|
ctx.body = {
|
|
188
204
|
id: completionId,
|
|
@@ -198,10 +214,15 @@ async function handleNonStreamingTextCompletion(ctx, chatModel, messages, comple
|
|
|
198
214
|
finish_reason: "stop"
|
|
199
215
|
}
|
|
200
216
|
],
|
|
201
|
-
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 } }
|
|
202
223
|
};
|
|
203
224
|
}
|
|
204
|
-
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, streamOptions) {
|
|
225
|
+
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, streamOptions, providerRequestParameters) {
|
|
205
226
|
ctx.set({
|
|
206
227
|
"Content-Type": "text/event-stream",
|
|
207
228
|
"Cache-Control": "no-cache",
|
|
@@ -214,6 +235,7 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
|
|
|
214
235
|
let providerRequestId;
|
|
215
236
|
try {
|
|
216
237
|
const stream = await chatModel.stream(messages, {
|
|
238
|
+
...providerRequestParameters,
|
|
217
239
|
stream_options: { ...streamOptions, include_usage: true },
|
|
218
240
|
signal: requestAbort.signal
|
|
219
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
|
+
});
|
package/dist/server/usage.js
CHANGED
|
@@ -42,6 +42,24 @@ function getAiApiState(ctx) {
|
|
|
42
42
|
function normalizeTokenCount(value) {
|
|
43
43
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
44
44
|
}
|
|
45
|
+
function extractPromptCacheTokens(source) {
|
|
46
|
+
const inputDetails = source.input_token_details;
|
|
47
|
+
if (inputDetails && typeof inputDetails === "object") {
|
|
48
|
+
const cacheRead = inputDetails.cache_read;
|
|
49
|
+
if (typeof cacheRead === "number") return cacheRead;
|
|
50
|
+
const cachedTokens = inputDetails.cached_tokens;
|
|
51
|
+
if (typeof cachedTokens === "number") return cachedTokens;
|
|
52
|
+
}
|
|
53
|
+
const promptDetails = source.prompt_tokens_details;
|
|
54
|
+
if (promptDetails && typeof promptDetails === "object") {
|
|
55
|
+
const cachedTokens = promptDetails.cached_tokens;
|
|
56
|
+
if (typeof cachedTokens === "number") return cachedTokens;
|
|
57
|
+
}
|
|
58
|
+
if (typeof source.cached_tokens === "number") return source.cached_tokens;
|
|
59
|
+
if (typeof source.cache_read_tokens === "number") return source.cache_read_tokens;
|
|
60
|
+
if (typeof source.cache_read === "number") return source.cache_read;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
45
63
|
function normalizeUsage(value) {
|
|
46
64
|
if (!value || typeof value !== "object") return void 0;
|
|
47
65
|
const source = value;
|
|
@@ -55,11 +73,22 @@ function normalizeUsage(value) {
|
|
|
55
73
|
return {
|
|
56
74
|
prompt_tokens: prompt,
|
|
57
75
|
completion_tokens: completion,
|
|
58
|
-
total_tokens: total
|
|
76
|
+
total_tokens: total,
|
|
77
|
+
prompt_cache_tokens: extractPromptCacheTokens(source)
|
|
59
78
|
};
|
|
60
79
|
}
|
|
61
|
-
function setAiApiUsageResult(ctx, value, metadata = {}) {
|
|
62
|
-
|
|
80
|
+
function setAiApiUsageResult(ctx, value, metadata = {}, responseMetadata) {
|
|
81
|
+
let usage = normalizeUsage(value);
|
|
82
|
+
if ((usage == null ? void 0 : usage.prompt_cache_tokens) === null && responseMetadata && typeof responseMetadata === "object") {
|
|
83
|
+
const responseMetaRecord = responseMetadata;
|
|
84
|
+
let cacheTokens = extractPromptCacheTokens(responseMetaRecord);
|
|
85
|
+
if (cacheTokens === null && responseMetaRecord.usage && typeof responseMetaRecord.usage === "object") {
|
|
86
|
+
cacheTokens = extractPromptCacheTokens(responseMetaRecord.usage);
|
|
87
|
+
}
|
|
88
|
+
if (cacheTokens !== null) {
|
|
89
|
+
usage = { ...usage, prompt_cache_tokens: cacheTokens };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
63
92
|
getAiApiState(ctx).aiApiUsageResult = usage ? { source: "provider", usage, ...metadata } : { source: "unavailable", ...metadata };
|
|
64
93
|
(0, import_app_observability.addAiApiUsage)(ctx, usage);
|
|
65
94
|
return usage;
|
|
@@ -133,6 +162,7 @@ async function finishUsageRecord(ctx, id, startedAt, status) {
|
|
|
133
162
|
inputTokens: (usage == null ? void 0 : usage.prompt_tokens) ?? null,
|
|
134
163
|
outputTokens: (usage == null ? void 0 : usage.completion_tokens) ?? null,
|
|
135
164
|
totalTokens: (usage == null ? void 0 : usage.total_tokens) ?? null,
|
|
165
|
+
promptCacheTokens: (usage == null ? void 0 : usage.prompt_cache_tokens) ?? null,
|
|
136
166
|
resolvedService: ((_c = (_b = state.aiApiLlmBilling) == null ? void 0 : _b.resolution) == null ? void 0 : _c.service) ?? null,
|
|
137
167
|
resolvedProvider: ((_e = (_d = state.aiApiLlmBilling) == null ? void 0 : _d.resolution) == null ? void 0 : _e.provider) ?? null,
|
|
138
168
|
resolvedModel: ((_g = (_f = state.aiApiLlmBilling) == null ? void 0 : _f.resolution) == null ? void 0 : _g.model) ?? null,
|
|
@@ -27,6 +27,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
27
27
|
var direct_llm_context_exports = {};
|
|
28
28
|
__export(direct_llm_context_exports, {
|
|
29
29
|
DirectLlmContextError: () => DirectLlmContextError,
|
|
30
|
+
parseImageDimensions: () => parseImageDimensions,
|
|
30
31
|
prepareDirectLlmContext: () => prepareDirectLlmContext
|
|
31
32
|
});
|
|
32
33
|
module.exports = __toCommonJS(direct_llm_context_exports);
|
|
@@ -45,20 +46,159 @@ function positiveInteger(value) {
|
|
|
45
46
|
const parsed = Number(value);
|
|
46
47
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
|
|
47
48
|
}
|
|
48
|
-
function hasImageContent(value) {
|
|
49
|
-
return Array.isArray(value) && value.some(
|
|
50
|
-
(block) => typeof block === "object" && block !== null && block.type === "image_url"
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
49
|
function estimateValueTokens(value) {
|
|
54
50
|
if (value === void 0) return 0;
|
|
55
51
|
return Math.ceil(Buffer.byteLength(JSON.stringify(value), "utf8") / 3);
|
|
56
52
|
}
|
|
57
|
-
function
|
|
58
|
-
return
|
|
53
|
+
function isRecord(value) {
|
|
54
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
55
|
+
}
|
|
56
|
+
function readBigEndian(buf, offset) {
|
|
57
|
+
return buf.readUInt32BE(offset);
|
|
58
|
+
}
|
|
59
|
+
function readUInt16BE(buf, offset) {
|
|
60
|
+
return buf.readUInt16BE(offset);
|
|
61
|
+
}
|
|
62
|
+
function readLittleEndian(buf, offset) {
|
|
63
|
+
return buf.readUInt16LE(offset);
|
|
64
|
+
}
|
|
65
|
+
function parsePngDimensions(buffer) {
|
|
66
|
+
if (buffer.length < 24) return void 0;
|
|
67
|
+
return { width: readBigEndian(buffer, 16), height: readBigEndian(buffer, 20) };
|
|
68
|
+
}
|
|
69
|
+
function parseJpegDimensions(buffer) {
|
|
70
|
+
let offset = 2;
|
|
71
|
+
while (offset < buffer.length) {
|
|
72
|
+
if (buffer[offset] !== 255) {
|
|
73
|
+
offset++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const marker = buffer[offset + 1];
|
|
77
|
+
if (marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207) {
|
|
78
|
+
if (offset + 9 <= buffer.length) {
|
|
79
|
+
return { height: readUInt16BE(buffer, offset + 5), width: readUInt16BE(buffer, offset + 7) };
|
|
80
|
+
}
|
|
81
|
+
return void 0;
|
|
82
|
+
}
|
|
83
|
+
if (marker === 217 || offset + 4 >= buffer.length) break;
|
|
84
|
+
const segmentLength = buffer.readUInt16BE(offset + 2);
|
|
85
|
+
offset += 2 + segmentLength;
|
|
86
|
+
}
|
|
87
|
+
return void 0;
|
|
88
|
+
}
|
|
89
|
+
function parseGifDimensions(buffer) {
|
|
90
|
+
if (buffer.length < 10) return void 0;
|
|
91
|
+
return { width: readLittleEndian(buffer, 6), height: readLittleEndian(buffer, 8) };
|
|
92
|
+
}
|
|
93
|
+
function parseWebpDimensions(buffer) {
|
|
94
|
+
if (buffer.length < 30) return void 0;
|
|
95
|
+
const riff = buffer.toString("ascii", 0, 4);
|
|
96
|
+
const webp = buffer.toString("ascii", 8, 12);
|
|
97
|
+
if (riff !== "RIFF" || webp !== "WEBP") return void 0;
|
|
98
|
+
const chunkType = buffer.toString("ascii", 12, 16);
|
|
99
|
+
if (chunkType === "VP8 " && buffer.length >= 26) {
|
|
100
|
+
return { width: readLittleEndian(buffer, 26), height: readLittleEndian(buffer, 28) };
|
|
101
|
+
}
|
|
102
|
+
if (chunkType === "VP8L" && buffer.length >= 24) {
|
|
103
|
+
const bits = buffer.readUInt32LE(21);
|
|
104
|
+
return {
|
|
105
|
+
width: (bits & 16383) + 1,
|
|
106
|
+
height: (bits >> 14 & 16383) + 1
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
if (chunkType === "VP8X" && buffer.length >= 30) {
|
|
110
|
+
return {
|
|
111
|
+
width: ((buffer[24] | buffer[25] << 8 | buffer[26] << 16) & 16777215) + 1,
|
|
112
|
+
height: ((buffer[27] | buffer[28] << 8 | buffer[29] << 16) & 16777215) + 1
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
function parseImageDimensions(buffer) {
|
|
118
|
+
if (buffer.length < 12) return void 0;
|
|
119
|
+
const header = buffer.toString("binary", 0, 4);
|
|
120
|
+
if (header === "\x89PNG") return parsePngDimensions(buffer);
|
|
121
|
+
if (header === "GIF8") return parseGifDimensions(buffer);
|
|
122
|
+
if (header === "RIFF") return parseWebpDimensions(buffer);
|
|
123
|
+
if (buffer[0] === 255 && buffer[1] === 216) return parseJpegDimensions(buffer);
|
|
124
|
+
return void 0;
|
|
59
125
|
}
|
|
60
|
-
|
|
61
|
-
|
|
126
|
+
const VISION_TILE_SIZE = 512;
|
|
127
|
+
const VISION_LOW_DETAIL_TOKENS = 85;
|
|
128
|
+
const VISION_TILE_TOKENS = 170;
|
|
129
|
+
const VISION_HTTP_URL_ESTIMATE = 1024;
|
|
130
|
+
const FILE_BASE64_FALLBACK_TOKENS = 1024;
|
|
131
|
+
function estimateVisionTokensForDimensions(width, height) {
|
|
132
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
133
|
+
return VISION_LOW_DETAIL_TOKENS;
|
|
134
|
+
}
|
|
135
|
+
const tilesX = Math.ceil(width / VISION_TILE_SIZE);
|
|
136
|
+
const tilesY = Math.ceil(height / VISION_TILE_SIZE);
|
|
137
|
+
return VISION_LOW_DETAIL_TOKENS + tilesX * tilesY * VISION_TILE_TOKENS;
|
|
138
|
+
}
|
|
139
|
+
function decodeBase64DataUrl(url) {
|
|
140
|
+
const match = /^data:([^;]+);base64,([A-Za-z0-9+/]+=?=?)$/.exec(url);
|
|
141
|
+
if (!match) return void 0;
|
|
142
|
+
try {
|
|
143
|
+
const buffer = Buffer.from(match[2], "base64");
|
|
144
|
+
return { mimeType: match[1].toLowerCase(), buffer };
|
|
145
|
+
} catch {
|
|
146
|
+
return void 0;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function estimateImageUrlTokens(imageUrl) {
|
|
150
|
+
const url = typeof imageUrl === "string" ? imageUrl : isRecord(imageUrl) ? String(imageUrl.url ?? "") : "";
|
|
151
|
+
if (!url) return 0;
|
|
152
|
+
if (url.startsWith("data:")) {
|
|
153
|
+
const decoded = decodeBase64DataUrl(url);
|
|
154
|
+
if (!decoded) return FILE_BASE64_FALLBACK_TOKENS;
|
|
155
|
+
if (!decoded.mimeType.startsWith("image/")) return FILE_BASE64_FALLBACK_TOKENS;
|
|
156
|
+
const dimensions = parseImageDimensions(decoded.buffer);
|
|
157
|
+
return dimensions ? estimateVisionTokensForDimensions(dimensions.width, dimensions.height) : VISION_LOW_DETAIL_TOKENS;
|
|
158
|
+
}
|
|
159
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
160
|
+
return VISION_HTTP_URL_ESTIMATE;
|
|
161
|
+
}
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
function estimateFileBlockTokens(block) {
|
|
165
|
+
const file = isRecord(block.file) ? block.file : void 0;
|
|
166
|
+
if (!file) return 0;
|
|
167
|
+
const fileData = String(file.file_data ?? "");
|
|
168
|
+
if (fileData.startsWith("data:")) {
|
|
169
|
+
const decoded = decodeBase64DataUrl(fileData);
|
|
170
|
+
if (decoded) {
|
|
171
|
+
return Math.max(1, Math.ceil(decoded.buffer.length / 3));
|
|
172
|
+
}
|
|
173
|
+
return FILE_BASE64_FALLBACK_TOKENS;
|
|
174
|
+
}
|
|
175
|
+
return FILE_BASE64_FALLBACK_TOKENS;
|
|
176
|
+
}
|
|
177
|
+
function estimateContentBlockTokens(block) {
|
|
178
|
+
if (!isRecord(block)) return estimateValueTokens(block);
|
|
179
|
+
const type = typeof block.type === "string" ? block.type : void 0;
|
|
180
|
+
if (type === "text") {
|
|
181
|
+
return typeof block.text === "string" ? estimateValueTokens(block.text) + 4 : 4;
|
|
182
|
+
}
|
|
183
|
+
if (type === "image_url") {
|
|
184
|
+
return estimateImageUrlTokens(block.image_url) + 4;
|
|
185
|
+
}
|
|
186
|
+
if (type === "file") {
|
|
187
|
+
return estimateFileBlockTokens(block) + 4;
|
|
188
|
+
}
|
|
189
|
+
if (type === "file_url") {
|
|
190
|
+
return VISION_HTTP_URL_ESTIMATE + 4;
|
|
191
|
+
}
|
|
192
|
+
return estimateValueTokens(block) + 4;
|
|
193
|
+
}
|
|
194
|
+
function estimateMessageTokens(message) {
|
|
195
|
+
if (Array.isArray(message.content)) {
|
|
196
|
+
return message.content.reduce((total, block) => total + estimateContentBlockTokens(block), 4);
|
|
197
|
+
}
|
|
198
|
+
return estimateValueTokens(message) + 4;
|
|
199
|
+
}
|
|
200
|
+
function estimateMessagesTokens(messages) {
|
|
201
|
+
return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
|
|
62
202
|
}
|
|
63
203
|
function isInstruction(message) {
|
|
64
204
|
return message.role === "system" || message.role === "developer";
|
|
@@ -119,12 +259,6 @@ function resolveReservedOutputTokens(options, metadata) {
|
|
|
119
259
|
return requested ?? metadata.maxCompletionTokens;
|
|
120
260
|
}
|
|
121
261
|
async function prepareDirectLlmContext(ctx, options) {
|
|
122
|
-
if (containsUnsupportedContent(options.messages)) {
|
|
123
|
-
throw new DirectLlmContextError(
|
|
124
|
-
"context_estimation_unsupported",
|
|
125
|
-
"Context enforcement does not support image_url content without a model-specific vision token estimator."
|
|
126
|
-
);
|
|
127
|
-
}
|
|
128
262
|
const [metadata, behavior] = await Promise.all([
|
|
129
263
|
loadModelMetadata(ctx, options.serviceName, options.modelId),
|
|
130
264
|
resolveOverflowBehavior(ctx)
|
|
@@ -180,5 +314,6 @@ async function prepareDirectLlmContext(ctx, options) {
|
|
|
180
314
|
// Annotate the CommonJS export names for ESM import in node:
|
|
181
315
|
0 && (module.exports = {
|
|
182
316
|
DirectLlmContextError,
|
|
317
|
+
parseImageDimensions,
|
|
183
318
|
prepareDirectLlmContext
|
|
184
319
|
});
|
|
@@ -99,7 +99,19 @@ function toOpenAIResponse(options) {
|
|
|
99
99
|
finish_reason: finishReason
|
|
100
100
|
}
|
|
101
101
|
],
|
|
102
|
-
usage: usage
|
|
102
|
+
usage: usage ? {
|
|
103
|
+
prompt_tokens: usage.prompt_tokens ?? null,
|
|
104
|
+
completion_tokens: usage.completion_tokens ?? null,
|
|
105
|
+
total_tokens: usage.total_tokens ?? null,
|
|
106
|
+
prompt_tokens_details: {
|
|
107
|
+
cached_tokens: usage.prompt_cache_tokens ?? null
|
|
108
|
+
}
|
|
109
|
+
} : {
|
|
110
|
+
prompt_tokens: null,
|
|
111
|
+
completion_tokens: null,
|
|
112
|
+
total_tokens: null,
|
|
113
|
+
prompt_tokens_details: { cached_tokens: null }
|
|
114
|
+
}
|
|
103
115
|
};
|
|
104
116
|
}
|
|
105
117
|
function toOpenAIStreamChunk(options) {
|
|
@@ -129,7 +141,14 @@ function toOpenAIUsageChunk(options) {
|
|
|
129
141
|
created: Math.floor(Date.now() / 1e3),
|
|
130
142
|
model,
|
|
131
143
|
choices: [],
|
|
132
|
-
usage
|
|
144
|
+
usage: {
|
|
145
|
+
prompt_tokens: usage.prompt_tokens,
|
|
146
|
+
completion_tokens: usage.completion_tokens,
|
|
147
|
+
total_tokens: usage.total_tokens,
|
|
148
|
+
prompt_tokens_details: {
|
|
149
|
+
cached_tokens: usage.prompt_cache_tokens ?? null
|
|
150
|
+
}
|
|
151
|
+
}
|
|
133
152
|
};
|
|
134
153
|
}
|
|
135
154
|
function toOpenAIEmbeddingsResponse(options) {
|