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
@@ -0,0 +1,323 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { basename } from 'path';
3
+
4
+ export interface FileContentBlock {
5
+ type: string;
6
+ [key: string]: unknown;
7
+ }
8
+
9
+ export interface FileProcessorContext {
10
+ ctx: Context;
11
+ }
12
+
13
+ export interface FileProcessor {
14
+ name: string;
15
+ canHandle(block: FileContentBlock): boolean;
16
+ process(block: FileContentBlock, context: FileProcessorContext): Promise<FileContentBlock | FileContentBlock[]>;
17
+ }
18
+
19
+ export interface PdfToImageRenderer {
20
+ name: string;
21
+ /**
22
+ * Render each page of a PDF into a PNG image buffer.
23
+ * @param buffer Raw PDF bytes.
24
+ * @returns Array of PNG buffers, one per page.
25
+ */
26
+ render(buffer: Buffer): Promise<Buffer[]>;
27
+ }
28
+
29
+ export type FileProcessorErrorCode =
30
+ | 'invalid_url'
31
+ | 'unsupported_protocol'
32
+ | 'fetch_failed'
33
+ | 'file_too_large'
34
+ | 'content_type_not_allowed'
35
+ | 'missing_url';
36
+
37
+ export class FileProcessorError extends Error {
38
+ constructor(
39
+ readonly code: FileProcessorErrorCode,
40
+ message: string,
41
+ ) {
42
+ super(message);
43
+ this.name = 'FileProcessorError';
44
+ }
45
+ }
46
+
47
+ export interface FetchFileOptions {
48
+ maxSizeBytes?: number;
49
+ timeoutMs?: number;
50
+ allowedProtocols?: string[];
51
+ allowedContentTypes?: string[];
52
+ }
53
+
54
+ const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
55
+ const DEFAULT_TIMEOUT_MS = 30_000;
56
+ const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
57
+
58
+ export class FileProcessorService {
59
+ private processors: FileProcessor[] = [];
60
+ private pdfRenderer: PdfToImageRenderer | null = null;
61
+
62
+ register(processor: FileProcessor): void {
63
+ this.unregister(processor.name);
64
+ this.processors.push(processor);
65
+ }
66
+
67
+ unregister(name: string): void {
68
+ this.processors = this.processors.filter((p) => p.name !== name);
69
+ }
70
+
71
+ async process(
72
+ block: FileContentBlock,
73
+ context: FileProcessorContext,
74
+ ): Promise<FileContentBlock | FileContentBlock[]> {
75
+ // Search from the end so the most recently registered processor wins.
76
+ // This makes it easy for a custom plugin to override a default processor.
77
+ const processor = [...this.processors].reverse().find((p) => p.canHandle(block));
78
+ if (!processor) {
79
+ return block;
80
+ }
81
+ return processor.process(block, context);
82
+ }
83
+
84
+ list(): ReadonlyArray<FileProcessor> {
85
+ return [...this.processors];
86
+ }
87
+
88
+ /**
89
+ * Register a renderer used to convert PDF pages into PNG images when
90
+ * `pdfRenderPagesAsImages` is enabled in the AI API config.
91
+ */
92
+ registerPdfRenderer(renderer: PdfToImageRenderer): void {
93
+ this.pdfRenderer = renderer;
94
+ }
95
+
96
+ unregisterPdfRenderer(): void {
97
+ this.pdfRenderer = null;
98
+ }
99
+
100
+ getPdfRenderer(): PdfToImageRenderer | null {
101
+ return this.pdfRenderer;
102
+ }
103
+ }
104
+
105
+ function isRecord(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
107
+ }
108
+
109
+ function getUrlString(value: unknown): string | undefined {
110
+ if (typeof value === 'string') return value;
111
+ if (isRecord(value) && typeof value.url === 'string') return value.url;
112
+ return undefined;
113
+ }
114
+
115
+ function extractFilename(url: string, contentDisposition: string | null): string | undefined {
116
+ if (contentDisposition) {
117
+ const match = contentDisposition.match(/filename="?([^"]+)"?/);
118
+ if (match) return match[1];
119
+ }
120
+ try {
121
+ const pathname = new URL(url).pathname;
122
+ if (pathname) return basename(pathname);
123
+ } catch {
124
+ // ignore malformed URL
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ export async function fetchFileAsBase64(
130
+ url: string,
131
+ options: FetchFileOptions = {},
132
+ ): Promise<{ fileData: string; mimeType: string | undefined; filename: string | undefined }> {
133
+ const maxSize = options.maxSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
134
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
135
+ const allowedProtocols = options.allowedProtocols ? new Set(options.allowedProtocols) : ALLOWED_PROTOCOLS;
136
+
137
+ let protocol: string;
138
+ try {
139
+ protocol = new URL(url).protocol;
140
+ } catch {
141
+ throw new FileProcessorError('invalid_url', `File URL '${url}' is not a valid URL.`);
142
+ }
143
+
144
+ if (!allowedProtocols.has(protocol)) {
145
+ throw new FileProcessorError('unsupported_protocol', `File URL protocol '${protocol}' is not allowed.`);
146
+ }
147
+
148
+ const controller = new AbortController();
149
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
150
+
151
+ try {
152
+ const response = await fetch(url, {
153
+ signal: controller.signal,
154
+ redirect: 'follow',
155
+ });
156
+
157
+ if (!response.ok) {
158
+ throw new FileProcessorError(
159
+ 'fetch_failed',
160
+ `Failed to fetch file from '${url}': ${response.status} ${response.statusText}`,
161
+ );
162
+ }
163
+
164
+ const contentLength = response.headers.get('content-length');
165
+ if (contentLength && Number(contentLength) > maxSize) {
166
+ throw new FileProcessorError('file_too_large', `File at '${url}' exceeds maximum allowed size.`);
167
+ }
168
+
169
+ const contentType = response.headers.get('content-type') || undefined;
170
+ if (
171
+ options.allowedContentTypes &&
172
+ contentType &&
173
+ !options.allowedContentTypes.some((type) => contentType.includes(type))
174
+ ) {
175
+ throw new FileProcessorError('content_type_not_allowed', `File content type '${contentType}' is not allowed.`);
176
+ }
177
+
178
+ const buffer = Buffer.from(await response.arrayBuffer());
179
+ if (buffer.length > maxSize) {
180
+ throw new FileProcessorError('file_too_large', `File at '${url}' exceeds maximum allowed size.`);
181
+ }
182
+
183
+ const mimeType = contentType?.split(';')[0].trim() ?? 'application/octet-stream';
184
+ const contentDisposition = response.headers.get('content-disposition');
185
+ const filename = extractFilename(url, contentDisposition) ?? 'file';
186
+
187
+ return {
188
+ fileData: `data:${mimeType};base64,${buffer.toString('base64')}`,
189
+ mimeType,
190
+ filename,
191
+ };
192
+ } finally {
193
+ clearTimeout(timeout);
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Default processor: forwards base64 `file` blocks unchanged.
199
+ * Handles `{ type: 'file', file: { file_data: 'data:...;base64,...' } }`.
200
+ */
201
+ export const base64FileForwarder: FileProcessor = {
202
+ name: 'base64FileForwarder',
203
+ canHandle(block: FileContentBlock): boolean {
204
+ if (block.type !== 'file') return false;
205
+ const file = isRecord(block.file) ? block.file : undefined;
206
+ if (!file) return false;
207
+ const fileData = String(file.file_data ?? '');
208
+ return fileData.startsWith('data:') && fileData.includes(';base64,');
209
+ },
210
+ async process(block: FileContentBlock): Promise<FileContentBlock> {
211
+ return block;
212
+ },
213
+ };
214
+
215
+ /**
216
+ * Default processor: downloads an http(s) `file_url` and converts it into a `file` block.
217
+ * Handles `{ type: 'file_url', file_url: { url: 'https://...' } }`.
218
+ */
219
+ export const httpFileUrlFetcher: FileProcessor = {
220
+ name: 'httpFileUrlFetcher',
221
+ canHandle(block: FileContentBlock): boolean {
222
+ if (block.type !== 'file_url') return false;
223
+ const fileUrl = isRecord(block.file_url) ? block.file_url : undefined;
224
+ if (!fileUrl) return false;
225
+ const url = String(fileUrl.url ?? '');
226
+ return url.startsWith('http://') || url.startsWith('https://');
227
+ },
228
+ async process(block: FileContentBlock): Promise<FileContentBlock> {
229
+ const fileUrl = isRecord(block.file_url) ? block.file_url : undefined;
230
+ const url = String(fileUrl?.url ?? '');
231
+ if (!url) {
232
+ throw new FileProcessorError('missing_url', "file_url block requires a 'url' property.");
233
+ }
234
+ const { fileData, mimeType, filename } = await fetchFileAsBase64(url);
235
+ return {
236
+ type: 'file',
237
+ file: {
238
+ file_data: fileData,
239
+ mime_type: mimeType,
240
+ filename: filename || 'file',
241
+ },
242
+ };
243
+ },
244
+ };
245
+
246
+ function decodeBase64DataUrl(url: string): { mimeType: string; buffer: Buffer } | undefined {
247
+ const match = /^data:([^;]+);base64,([A-Za-z0-9+/]+=*)$/.exec(url);
248
+ if (!match) return undefined;
249
+ try {
250
+ const buffer = Buffer.from(match[2], 'base64');
251
+ return { mimeType: match[1].toLowerCase(), buffer };
252
+ } catch {
253
+ return undefined;
254
+ }
255
+ }
256
+
257
+ function isPdfBuffer(buffer: Buffer): boolean {
258
+ return buffer.length >= 4 && buffer.toString('binary', 0, 4) === '%PDF';
259
+ }
260
+
261
+ function isPdfFileBlock(block: FileContentBlock): boolean {
262
+ if (block.type !== 'file') return false;
263
+ const file = isRecord(block.file) ? block.file : undefined;
264
+ if (!file) return false;
265
+ const fileData = String(file.file_data ?? '');
266
+ if (!fileData.startsWith('data:')) return false;
267
+ const mimeType = String(file.mime_type ?? '').toLowerCase();
268
+ if (mimeType === 'application/pdf') return true;
269
+ if (fileData.startsWith('data:application/pdf')) return true;
270
+ const decoded = decodeBase64DataUrl(fileData);
271
+ if (decoded && isPdfBuffer(decoded.buffer)) return true;
272
+ return false;
273
+ }
274
+
275
+ function getPluginFromContext(context: FileProcessorContext): any | undefined {
276
+ // @ts-expect-error app may not be typed on Context in test mocks.
277
+ return context.ctx.app?.pm?.get?.('plugin-ai-api');
278
+ }
279
+
280
+ /**
281
+ * Optional processor: converts a PDF `file` block into a series of `image_url`
282
+ * blocks when `pdfRenderPagesAsImages` is enabled and a PdfToImageRenderer is
283
+ * registered. When disabled, unavailable, or the block is not a PDF, the block
284
+ * is forwarded unchanged.
285
+ */
286
+ export const pdfFileProcessor: FileProcessor = {
287
+ name: 'pdfFileProcessor',
288
+ canHandle(block: FileContentBlock): boolean {
289
+ return isPdfFileBlock(block);
290
+ },
291
+ async process(
292
+ block: FileContentBlock,
293
+ context: FileProcessorContext,
294
+ ): Promise<FileContentBlock | FileContentBlock[]> {
295
+ const config = await context.ctx.db.getRepository('aiApiConfig').findOne();
296
+ if (!config?.pdfRenderPagesAsImages) {
297
+ return block;
298
+ }
299
+
300
+ const plugin = getPluginFromContext(context);
301
+ const renderer = plugin?.fileProcessorService?.getPdfRenderer?.();
302
+ if (!renderer) {
303
+ context.ctx.log?.warn?.(
304
+ '[pdfFileProcessor] pdfRenderPagesAsImages is enabled but no PdfToImageRenderer is registered. ' +
305
+ 'Forwarding PDF as a file block.',
306
+ );
307
+ return block;
308
+ }
309
+
310
+ const file = isRecord(block.file) ? block.file : undefined;
311
+ const fileData = String(file?.file_data ?? '');
312
+ const decoded = decodeBase64DataUrl(fileData);
313
+ if (!decoded) {
314
+ return block;
315
+ }
316
+
317
+ const pages = await renderer.render(decoded.buffer);
318
+ return pages.map((buffer) => ({
319
+ type: 'image_url',
320
+ image_url: { url: `data:image/png;base64,${buffer.toString('base64')}` },
321
+ }));
322
+ },
323
+ };
@@ -6,6 +6,7 @@ export type Usage = {
6
6
  prompt_tokens: number | null;
7
7
  completion_tokens: number | null;
8
8
  total_tokens: number | null;
9
+ prompt_cache_tokens?: number | null;
9
10
  };
10
11
 
11
12
  export type AiApiAuthType = 'apiKey' | 'bearer' | 'oidc' | 'unknown';
@@ -48,6 +49,33 @@ function normalizeTokenCount(value: unknown): number | null {
48
49
  return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
49
50
  }
50
51
 
52
+ /**
53
+ * Extract cached prompt tokens from a provider usage or response metadata object.
54
+ * Tolerates both LangChain-style (`input_token_details.cache_read`) and raw
55
+ * OpenAI-style (`prompt_tokens_details.cached_tokens`) shapes.
56
+ */
57
+ function extractPromptCacheTokens(source: Record<string, unknown>): number | null {
58
+ const inputDetails = source.input_token_details;
59
+ if (inputDetails && typeof inputDetails === 'object') {
60
+ const cacheRead = (inputDetails as Record<string, unknown>).cache_read;
61
+ if (typeof cacheRead === 'number') return cacheRead;
62
+ const cachedTokens = (inputDetails as Record<string, unknown>).cached_tokens;
63
+ if (typeof cachedTokens === 'number') return cachedTokens;
64
+ }
65
+
66
+ const promptDetails = source.prompt_tokens_details;
67
+ if (promptDetails && typeof promptDetails === 'object') {
68
+ const cachedTokens = (promptDetails as Record<string, unknown>).cached_tokens;
69
+ if (typeof cachedTokens === 'number') return cachedTokens;
70
+ }
71
+
72
+ if (typeof source.cached_tokens === 'number') return source.cached_tokens;
73
+ if (typeof source.cache_read_tokens === 'number') return source.cache_read_tokens;
74
+ if (typeof source.cache_read === 'number') return source.cache_read;
75
+
76
+ return null;
77
+ }
78
+
51
79
  export function normalizeUsage(value: unknown): Usage | undefined {
52
80
  if (!value || typeof value !== 'object') return undefined;
53
81
  const source = value as Record<string, unknown>;
@@ -65,6 +93,7 @@ export function normalizeUsage(value: unknown): Usage | undefined {
65
93
  prompt_tokens: prompt,
66
94
  completion_tokens: completion,
67
95
  total_tokens: total,
96
+ prompt_cache_tokens: extractPromptCacheTokens(source),
68
97
  };
69
98
  }
70
99
 
@@ -72,8 +101,24 @@ export function setAiApiUsageResult(
72
101
  ctx: Context,
73
102
  value: unknown,
74
103
  metadata: Pick<AiApiUsageResult, 'gatewayResponseId' | 'providerRequestId'> = {},
104
+ responseMetadata?: unknown,
75
105
  ): Usage | undefined {
76
- const usage = normalizeUsage(value);
106
+ let usage = normalizeUsage(value);
107
+
108
+ // Some providers only surface cached token details on response_metadata; fall
109
+ // back there when the usage object itself does not carry them.
110
+ if (usage?.prompt_cache_tokens === null && responseMetadata && typeof responseMetadata === 'object') {
111
+ const responseMetaRecord = responseMetadata as Record<string, unknown>;
112
+ let cacheTokens = extractPromptCacheTokens(responseMetaRecord);
113
+ // Providers sometimes nest the raw provider usage under response_metadata.usage.
114
+ if (cacheTokens === null && responseMetaRecord.usage && typeof responseMetaRecord.usage === 'object') {
115
+ cacheTokens = extractPromptCacheTokens(responseMetaRecord.usage as Record<string, unknown>);
116
+ }
117
+ if (cacheTokens !== null) {
118
+ usage = { ...usage, prompt_cache_tokens: cacheTokens };
119
+ }
120
+ }
121
+
77
122
  getAiApiState(ctx).aiApiUsageResult = usage
78
123
  ? { source: 'provider', usage, ...metadata }
79
124
  : { source: 'unavailable', ...metadata };
@@ -174,6 +219,7 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
174
219
  inputTokens: usage?.prompt_tokens ?? null,
175
220
  outputTokens: usage?.completion_tokens ?? null,
176
221
  totalTokens: usage?.total_tokens ?? null,
222
+ promptCacheTokens: usage?.prompt_cache_tokens ?? null,
177
223
  resolvedService: state.aiApiLlmBilling?.resolution?.service ?? null,
178
224
  resolvedProvider: state.aiApiLlmBilling?.resolution?.provider ?? null,
179
225
  resolvedModel: state.aiApiLlmBilling?.resolution?.model ?? null,