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.
Files changed (64) hide show
  1. package/dist/client/757.56952e321dc399b7.js +10 -0
  2. package/dist/client/902.e74518750f1e4201.js +10 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
  5. package/dist/client-v2/902.c7c00a565085438a.js +10 -0
  6. package/dist/client-v2/index.js +1 -1
  7. package/dist/externalVersion.js +8 -8
  8. package/dist/locale/en-US.json +4 -0
  9. package/dist/locale/vi-VN.json +4 -0
  10. package/dist/locale/zh-CN.json +4 -0
  11. package/dist/server/billing.js +6 -1
  12. package/dist/server/collections/ai-api-config.js +6 -0
  13. package/dist/server/collections/ai-api-usage-records.js +1 -0
  14. package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
  15. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  16. package/dist/server/plugin.js +10 -0
  17. package/dist/server/resource/ai-api-config.js +5 -0
  18. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  19. package/dist/server/routes/chat-completions.js +110 -19
  20. package/dist/server/routes/completions.js +59 -24
  21. package/dist/server/services/file-processor.js +262 -0
  22. package/dist/server/usage.js +33 -3
  23. package/dist/server/utils/direct-llm-context.js +319 -0
  24. package/dist/server/utils/openai-format.js +21 -2
  25. package/dist/server/validation.js +3 -0
  26. package/dist/swagger.js +42 -3
  27. package/package.json +1 -1
  28. package/src/client-v2/pages/UsagePage.tsx +9 -0
  29. package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
  30. package/src/locale/en-US.json +4 -0
  31. package/src/locale/vi-VN.json +4 -0
  32. package/src/locale/zh-CN.json +4 -0
  33. package/src/server/__tests__/direct-llm-context.test.ts +206 -0
  34. package/src/server/__tests__/openai-format.test.ts +12 -2
  35. package/src/server/__tests__/request-body.test.ts +45 -2
  36. package/src/server/__tests__/usage-route.test.ts +173 -9
  37. package/src/server/__tests__/usage.test.ts +19 -0
  38. package/src/server/__tests__/validation.test.ts +36 -0
  39. package/src/server/billing.ts +6 -1
  40. package/src/server/collections/ai-api-config.ts +8 -0
  41. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  42. package/src/server/collections/ai-api-usage-records.ts +1 -0
  43. package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
  44. package/src/server/index.ts +10 -10
  45. package/src/server/middleware/rate-limit.ts +70 -70
  46. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  47. package/src/server/plugin.ts +20 -0
  48. package/src/server/resource/ai-api-config.ts +5 -0
  49. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  50. package/src/server/routes/chat-completions.ts +157 -22
  51. package/src/server/routes/completions.ts +61 -23
  52. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  53. package/src/server/services/file-processor.ts +323 -0
  54. package/src/server/usage.ts +47 -1
  55. package/src/server/utils/direct-llm-context.ts +394 -0
  56. package/src/server/utils/openai-format.ts +25 -2
  57. package/src/server/utils/rate-limiter.ts +83 -83
  58. package/src/server/utils/resolve-service.ts +82 -82
  59. package/src/server/validation.ts +3 -0
  60. package/src/swagger.ts +45 -3
  61. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  62. package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
  63. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  64. package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
@@ -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
- const usage = normalizeUsage(value);
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,
@@ -0,0 +1,319 @@
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 direct_llm_context_exports = {};
28
+ __export(direct_llm_context_exports, {
29
+ DirectLlmContextError: () => DirectLlmContextError,
30
+ parseImageDimensions: () => parseImageDimensions,
31
+ prepareDirectLlmContext: () => prepareDirectLlmContext
32
+ });
33
+ module.exports = __toCommonJS(direct_llm_context_exports);
34
+ class DirectLlmContextError extends Error {
35
+ constructor(code, message) {
36
+ super(message);
37
+ this.code = code;
38
+ this.name = "DirectLlmContextError";
39
+ }
40
+ }
41
+ function getValue(record, key) {
42
+ var _a;
43
+ return ((_a = record == null ? void 0 : record.get) == null ? void 0 : _a.call(record, key)) ?? (record == null ? void 0 : record[key]);
44
+ }
45
+ function positiveInteger(value) {
46
+ const parsed = Number(value);
47
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
48
+ }
49
+ function estimateValueTokens(value) {
50
+ if (value === void 0) return 0;
51
+ return Math.ceil(Buffer.byteLength(JSON.stringify(value), "utf8") / 3);
52
+ }
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;
125
+ }
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);
202
+ }
203
+ function isInstruction(message) {
204
+ return message.role === "system" || message.role === "developer";
205
+ }
206
+ function splitTurns(messages) {
207
+ const fixed = [];
208
+ const turns = [];
209
+ let currentTurn;
210
+ for (const message of messages) {
211
+ if (isInstruction(message)) {
212
+ fixed.push(message);
213
+ continue;
214
+ }
215
+ if (message.role === "user" || !currentTurn) {
216
+ currentTurn = [message];
217
+ turns.push(currentTurn);
218
+ continue;
219
+ }
220
+ currentTurn.push(message);
221
+ }
222
+ return { fixed, turns };
223
+ }
224
+ function messagesWithTurns(messages, turns) {
225
+ const retainedMessages = new Set(turns.flat());
226
+ return messages.filter((message) => isInstruction(message) || retainedMessages.has(message));
227
+ }
228
+ async function loadModelMetadata(ctx, serviceName, modelId) {
229
+ const row = await ctx.db.getRepository("aiApiModelMetadata").findOne({
230
+ filter: { llmService: serviceName, model: modelId, enabled: true }
231
+ });
232
+ const contextWindow = positiveInteger(getValue(row, "contextWindow"));
233
+ const maxCompletionTokens = positiveInteger(getValue(row, "maxCompletionTokens"));
234
+ if (!contextWindow || !maxCompletionTokens) {
235
+ throw new DirectLlmContextError(
236
+ "model_context_metadata_not_configured",
237
+ `Context metadata is not configured for '${serviceName}/${modelId}'. Configure context window and max completion tokens.`
238
+ );
239
+ }
240
+ return { contextWindow, maxCompletionTokens };
241
+ }
242
+ async function resolveOverflowBehavior(ctx) {
243
+ var _a;
244
+ const userId = (_a = ctx.state.currentUser) == null ? void 0 : _a.id;
245
+ if (userId === null || userId === void 0) return "reject";
246
+ const policy = await ctx.db.getRepository("aiApiUserQuotaPolicies").findOne({
247
+ filter: { userId, enabled: true }
248
+ });
249
+ return getValue(policy, "contextOverflowBehavior") === "truncate" ? "truncate" : "reject";
250
+ }
251
+ function resolveReservedOutputTokens(options, metadata) {
252
+ const requested = positiveInteger(options.maxCompletionTokens ?? options.maxTokens);
253
+ if (requested && requested > metadata.maxCompletionTokens) {
254
+ throw new DirectLlmContextError(
255
+ "max_completion_tokens_exceeds_model_limit",
256
+ `Requested max completion tokens (${requested}) exceeds the model limit (${metadata.maxCompletionTokens}).`
257
+ );
258
+ }
259
+ return requested ?? metadata.maxCompletionTokens;
260
+ }
261
+ async function prepareDirectLlmContext(ctx, options) {
262
+ const [metadata, behavior] = await Promise.all([
263
+ loadModelMetadata(ctx, options.serviceName, options.modelId),
264
+ resolveOverflowBehavior(ctx)
265
+ ]);
266
+ const reservedOutputTokens = resolveReservedOutputTokens(options, metadata);
267
+ const inputTokenBudget = metadata.contextWindow - reservedOutputTokens;
268
+ if (inputTokenBudget <= 0) {
269
+ throw new DirectLlmContextError(
270
+ "context_length_exceeded",
271
+ `The model context window (${metadata.contextWindow}) leaves no input capacity after reserving ${reservedOutputTokens} output tokens.`
272
+ );
273
+ }
274
+ const fixedOverheadTokens = estimateValueTokens(options.tools) + (options.tools === void 0 ? 0 : 4);
275
+ const originalEstimate = estimateMessagesTokens(options.messages) + fixedOverheadTokens;
276
+ if (originalEstimate <= inputTokenBudget) {
277
+ return {
278
+ messages: options.messages,
279
+ estimatedInputTokens: originalEstimate,
280
+ inputTokenBudget,
281
+ reservedOutputTokens,
282
+ truncated: false
283
+ };
284
+ }
285
+ if (behavior === "reject") {
286
+ throw new DirectLlmContextError(
287
+ "context_length_exceeded",
288
+ `Estimated input tokens (${originalEstimate}) exceed the allowed input budget (${inputTokenBudget}).`
289
+ );
290
+ }
291
+ const { turns } = splitTurns(options.messages);
292
+ let remainingTurns = turns;
293
+ let messages = messagesWithTurns(options.messages, remainingTurns);
294
+ let estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
295
+ while (remainingTurns.length > 1 && estimatedInputTokens > inputTokenBudget) {
296
+ remainingTurns = remainingTurns.slice(1);
297
+ messages = messagesWithTurns(options.messages, remainingTurns);
298
+ estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
299
+ }
300
+ if (estimatedInputTokens > inputTokenBudget) {
301
+ throw new DirectLlmContextError(
302
+ "context_length_exceeded",
303
+ `The fixed instructions, tools, and newest conversation turn require ${estimatedInputTokens} input tokens, exceeding the allowed budget (${inputTokenBudget}).`
304
+ );
305
+ }
306
+ return {
307
+ messages,
308
+ estimatedInputTokens,
309
+ inputTokenBudget,
310
+ reservedOutputTokens,
311
+ truncated: messages.length !== options.messages.length
312
+ };
313
+ }
314
+ // Annotate the CommonJS export names for ESM import in node:
315
+ 0 && (module.exports = {
316
+ DirectLlmContextError,
317
+ parseImageDimensions,
318
+ prepareDirectLlmContext
319
+ });
@@ -99,7 +99,19 @@ function toOpenAIResponse(options) {
99
99
  finish_reason: finishReason
100
100
  }
101
101
  ],
102
- usage: usage || { prompt_tokens: null, completion_tokens: null, total_tokens: null }
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) {
@@ -101,6 +101,9 @@ function validateQuotaPolicy(model) {
101
101
  if (!["allow", "use_reserved"].includes(String(model.get("missingUsageBehavior")))) {
102
102
  throw new Error("missingUsageBehavior must be allow or use_reserved.");
103
103
  }
104
+ if (!["reject", "truncate"].includes(String(model.get("contextOverflowBehavior") ?? "reject"))) {
105
+ throw new Error("contextOverflowBehavior must be reject or truncate.");
106
+ }
104
107
  try {
105
108
  (0, import_dayjs.default)().tz(String(model.get("timezone") || "UTC"));
106
109
  } catch {
package/dist/swagger.js CHANGED
@@ -285,6 +285,11 @@ var swagger_default = {
285
285
  maximum: 100,
286
286
  default: 10,
287
287
  description: "Max request body size in megabytes. Requests above this return 413. The gateway buffers each body in memory, so values above 100 are rejected."
288
+ },
289
+ pdfRenderPagesAsImages: {
290
+ type: "boolean",
291
+ default: false,
292
+ description: "When true, PDF file/file_url blocks are rendered to per-page PNG images and sent as image_url blocks. Requires a registered PdfToImageRenderer. When false or no renderer is available, PDFs are forwarded as file blocks."
288
293
  }
289
294
  }
290
295
  },
@@ -299,9 +304,9 @@ var swagger_default = {
299
304
  },
300
305
  ContentBlock: {
301
306
  type: "object",
302
- description: "A multimodal content block. Only text and image_url blocks are forwarded to the provider; any other type is rejected with 400 unsupported_content_block.",
307
+ description: "A multimodal content block. text, image_url, file and file_url blocks are forwarded; file and file_url blocks are first run through the configurable file processor service.",
303
308
  properties: {
304
- type: { type: "string", enum: ["text", "image_url"] },
309
+ type: { type: "string", enum: ["text", "image_url", "file", "file_url"] },
305
310
  text: { type: "string" },
306
311
  image_url: {
307
312
  type: "object",
@@ -314,6 +319,34 @@ var swagger_default = {
314
319
  detail: { type: "string", enum: ["auto", "low", "high"] }
315
320
  },
316
321
  required: ["url"]
322
+ },
323
+ file: {
324
+ type: "object",
325
+ properties: {
326
+ file_data: {
327
+ type: "string",
328
+ description: "A base64 data URL, e.g. data:application/pdf;base64,JVBERi0...",
329
+ example: "data:application/pdf;base64,JVBERi0..."
330
+ },
331
+ filename: { type: "string" },
332
+ mime_type: {
333
+ type: "string",
334
+ description: "MIME type of the file, e.g. application/pdf",
335
+ example: "application/pdf"
336
+ }
337
+ },
338
+ required: ["file_data"]
339
+ },
340
+ file_url: {
341
+ type: "object",
342
+ properties: {
343
+ url: {
344
+ type: "string",
345
+ description: "An http(s) URL pointing to a file. The gateway downloads the file and converts it to a file block.",
346
+ example: "https://example.com/document.pdf"
347
+ }
348
+ },
349
+ required: ["url"]
317
350
  }
318
351
  },
319
352
  required: ["type"]
@@ -374,7 +407,13 @@ var swagger_default = {
374
407
  properties: {
375
408
  prompt_tokens: { type: "integer" },
376
409
  completion_tokens: { type: "integer" },
377
- total_tokens: { type: "integer" }
410
+ total_tokens: { type: "integer" },
411
+ prompt_tokens_details: {
412
+ type: "object",
413
+ properties: {
414
+ cached_tokens: { type: "integer" }
415
+ }
416
+ }
378
417
  }
379
418
  }
380
419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-ai-api",
3
- "version": "1.0.23",
3
+ "version": "1.0.25",
4
4
  "main": "dist/server/index.js",
5
5
  "dependencies": {},
6
6
  "peerDependencies": {
@@ -32,6 +32,7 @@ interface UsageRecord {
32
32
  inputTokens?: number;
33
33
  outputTokens?: number;
34
34
  totalTokens?: number;
35
+ promptCacheTokens?: number;
35
36
  estimatedCost?: string;
36
37
  currency?: string;
37
38
  costStatus?: string;
@@ -51,6 +52,7 @@ interface UsageSummary {
51
52
  inputTokens: number;
52
53
  outputTokens: number;
53
54
  totalTokens: number;
55
+ promptCacheTokens: number;
54
56
  costsByCurrency: { currency: string; totalCost: string }[];
55
57
  }
56
58
 
@@ -65,6 +67,7 @@ const emptySummary: UsageSummary = {
65
67
  inputTokens: 0,
66
68
  outputTokens: 0,
67
69
  totalTokens: 0,
70
+ promptCacheTokens: 0,
68
71
  costsByCurrency: [],
69
72
  };
70
73
 
@@ -153,6 +156,7 @@ export default function UsagePage() {
153
156
  { title: t('Input tokens'), dataIndex: 'inputTokens', key: 'inputTokens', width: 110 },
154
157
  { title: t('Output tokens'), dataIndex: 'outputTokens', key: 'outputTokens', width: 110 },
155
158
  { title: t('Total tokens'), dataIndex: 'totalTokens', key: 'totalTokens', width: 110 },
159
+ { title: t('Prompt cache tokens'), dataIndex: 'promptCacheTokens', key: 'promptCacheTokens', width: 140 },
156
160
  {
157
161
  title: t('Cost'),
158
162
  key: 'cost',
@@ -234,6 +238,11 @@ export default function UsagePage() {
234
238
  <Statistic title={t('Total tokens')} value={summary.totalTokens} />
235
239
  </Card>
236
240
  </Col>
241
+ <Col xs={24} sm={12} lg={6}>
242
+ <Card size="small">
243
+ <Statistic title={t('Prompt cache tokens')} value={summary.promptCacheTokens} />
244
+ </Card>
245
+ </Col>
237
246
  <Col xs={24}>
238
247
  <Card size="small">
239
248
  <Statistic title={t('Total cost')} value={totalCost} />
@@ -39,6 +39,7 @@ interface QuotaPolicy {
39
39
  currency: string;
40
40
  rejectUnpricedModel: boolean;
41
41
  missingUsageBehavior: 'allow' | 'use_reserved';
42
+ contextOverflowBehavior: 'reject' | 'truncate';
42
43
  }
43
44
 
44
45
  export default function UserQuotasPage() {
@@ -85,6 +86,7 @@ export default function UserQuotasPage() {
85
86
  currency: 'USD',
86
87
  rejectUnpricedModel: true,
87
88
  missingUsageBehavior: 'use_reserved',
89
+ contextOverflowBehavior: 'reject',
88
90
  } as QuotaPolicy);
89
91
  setOpen(true);
90
92
  };
@@ -157,6 +159,14 @@ export default function UserQuotasPage() {
157
159
  width: 120,
158
160
  render: (value, record) => (value == null ? t('Unlimited') : `${value} ${record.currency}`),
159
161
  },
162
+ {
163
+ title: t('Context overflow behavior'),
164
+ dataIndex: 'contextOverflowBehavior',
165
+ key: 'contextOverflowBehavior',
166
+ width: 150,
167
+ render: (value: QuotaPolicy['contextOverflowBehavior']) =>
168
+ value === 'truncate' ? t('Truncate oldest conversation turns') : t('Reject request'),
169
+ },
160
170
  { title: t('Timezone'), dataIndex: 'timezone', key: 'timezone', width: 140 },
161
171
  {
162
172
  title: t('Status'),
@@ -248,6 +258,14 @@ export default function UserQuotasPage() {
248
258
  ]}
249
259
  />
250
260
  </Form.Item>
261
+ <Form.Item name="contextOverflowBehavior" label={t('Context overflow behavior')} rules={[{ required: true }]}>
262
+ <Select
263
+ options={[
264
+ { label: t('Reject request'), value: 'reject' },
265
+ { label: t('Truncate oldest conversation turns'), value: 'truncate' },
266
+ ]}
267
+ />
268
+ </Form.Item>
251
269
  <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
252
270
  <Switch />
253
271
  </Form.Item>
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "Missing usage behavior",
54
54
  "Use reserved estimate": "Use reserved estimate",
55
55
  "Allow without token charge": "Allow without token charge",
56
+ "Context overflow behavior": "Context overflow behavior",
57
+ "Reject request": "Reject request",
58
+ "Truncate oldest conversation turns": "Truncate oldest conversation turns",
56
59
  "Started at": "Started at",
57
60
  "Requested model": "Requested model",
58
61
  "Resolved service": "Resolved service",
@@ -60,6 +63,7 @@
60
63
  "Input tokens": "Input tokens",
61
64
  "Output tokens": "Output tokens",
62
65
  "Total tokens": "Total tokens",
66
+ "Prompt cache tokens": "Prompt cache tokens",
63
67
  "Cost": "Cost",
64
68
  "Cost status": "Cost status",
65
69
  "Request ID": "Request ID",
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "Xử lý khi thiếu token usage",
54
54
  "Use reserved estimate": "Dùng số liệu giữ chỗ để ước tính",
55
55
  "Allow without token charge": "Cho phép và không tính token",
56
+ "Context overflow behavior": "Xử lý khi vượt context",
57
+ "Reject request": "Từ chối request",
58
+ "Truncate oldest conversation turns": "Cắt các lượt hội thoại cũ nhất",
56
59
  "Started at": "Bắt đầu lúc",
57
60
  "Requested model": "Model được yêu cầu",
58
61
  "Resolved service": "Service đã resolve",
@@ -60,6 +63,7 @@
60
63
  "Input tokens": "Input token",
61
64
  "Output tokens": "Output token",
62
65
  "Total tokens": "Tổng token",
66
+ "Prompt cache tokens": "Prompt cache token",
63
67
  "Cost": "Chi phí",
64
68
  "Cost status": "Trạng thái chi phí",
65
69
  "Request ID": "Request ID",
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "缺少用量时的行为",
54
54
  "Use reserved estimate": "使用预留估算",
55
55
  "Allow without token charge": "允许且不计令牌",
56
+ "Context overflow behavior": "上下文超限处理",
57
+ "Reject request": "拒绝请求",
58
+ "Truncate oldest conversation turns": "截断最早的对话轮次",
56
59
  "Started at": "开始时间",
57
60
  "Requested model": "请求模型",
58
61
  "Resolved service": "解析后的服务",
@@ -60,6 +63,7 @@
60
63
  "Input tokens": "输入令牌",
61
64
  "Output tokens": "输出令牌",
62
65
  "Total tokens": "总令牌",
66
+ "Prompt cache tokens": "提示缓存令牌",
63
67
  "Cost": "费用",
64
68
  "Cost status": "费用状态",
65
69
  "Request ID": "请求 ID",