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
@@ -31,7 +31,9 @@ import { enforceModelAccess } from '../utils/user-permissions';
31
31
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
32
32
  import type PluginAiApiServer from '../plugin';
33
33
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
34
+ import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
34
35
  import { markAiApiFirstProviderOutput } from '../utils/app-observability';
36
+ import { FileContentBlock, FileProcessorError } from '../services/file-processor';
35
37
 
36
38
  /**
37
39
  * POST /api/ai-llm/v1/chat/completions
@@ -142,8 +144,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
142
144
  return;
143
145
  }
144
146
 
145
- await prepareLlmBilling(ctx, resolved);
146
-
147
147
  const providerRequestParameters = getProviderRequestParameters(body);
148
148
  if (stream) {
149
149
  const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
@@ -166,13 +166,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
166
166
  if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
167
167
  if (body.stop !== undefined) modelOptions.stop = body.stop;
168
168
 
169
- const Provider = providerMeta.provider;
170
- const provider = new Provider({
171
- app: ctx.app,
172
- serviceOptions: service.options,
173
- modelOptions,
174
- });
175
-
176
169
  // ─── Build system prompt from AI Employee ───
177
170
  let systemPrompt = '';
178
171
  if (config?.defaultAiEmployee) {
@@ -197,12 +190,39 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
197
190
  }
198
191
 
199
192
  // ─── Build messages (inject system prompt if not provided by client) ───
200
- const messages = [...body.messages];
193
+ let messages: OpenAIMessage[] = [...body.messages];
201
194
  const hasSystemMessage = messages.some((m: any) => m.role === 'system');
202
195
  if (systemPrompt && !hasSystemMessage) {
203
196
  messages.unshift({ role: 'system', content: systemPrompt });
204
197
  }
205
198
 
199
+ // ─── Process file / file_url blocks through the file processor service ───
200
+ messages = await Promise.all(
201
+ messages.map(async (msg) => ({
202
+ ...msg,
203
+ content: await processMessageContentFileBlocks(msg.content, ctx, plugin),
204
+ })),
205
+ );
206
+
207
+ const preparedContext = await prepareDirectLlmContext(ctx, {
208
+ serviceName: service.name,
209
+ modelId,
210
+ messages,
211
+ tools: body.tools,
212
+ maxCompletionTokens: body.max_completion_tokens,
213
+ maxTokens: body.max_tokens,
214
+ });
215
+ messages = preparedContext.messages;
216
+
217
+ await prepareLlmBilling(ctx, resolved);
218
+
219
+ const Provider = providerMeta.provider;
220
+ const provider = new Provider({
221
+ app: ctx.app,
222
+ serviceOptions: service.options,
223
+ modelOptions,
224
+ });
225
+
206
226
  // ─── Build message tuples for LangChain model ───
207
227
  // LangChain chat models accept [role, content] tuples or BaseMessage objects.
208
228
  // We use tuples to avoid importing @langchain/core directly.
@@ -257,15 +277,17 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
257
277
  }
258
278
  } catch (err) {
259
279
  ctx.log.error('AI API chat completions error:', err);
260
- if (!ctx.res.headersSent) {
280
+ if (!ctx.res?.headersSent) {
261
281
  const isQuotaError = err instanceof AiApiQuotaError;
262
- ctx.status = isQuotaError ? 429 : 500;
282
+ const isContextError = err instanceof DirectLlmContextError;
283
+ const isFileError = err instanceof FileProcessorError;
284
+ ctx.status = isQuotaError ? 429 : isContextError || isFileError ? 400 : 500;
263
285
  if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
264
286
  ctx.body = toOpenAIError(
265
287
  ctx.status,
266
288
  getErrorMessage(err, 'Internal server error'),
267
- isQuotaError ? 'quota_error' : 'server_error',
268
- isQuotaError ? err.code : undefined,
289
+ isQuotaError ? 'quota_error' : isContextError || isFileError ? 'invalid_request_error' : 'server_error',
290
+ isQuotaError || isContextError || isFileError ? err.code : undefined,
269
291
  );
270
292
  }
271
293
  }
@@ -293,10 +315,15 @@ async function handleNonStreamingCompletion(
293
315
  }
294
316
 
295
317
  // Extract usage if available
296
- const usage = setAiApiUsageResult(ctx, result.usage_metadata, {
297
- gatewayResponseId: completionId,
298
- providerRequestId: extractProviderRequestId(result),
299
- });
318
+ const usage = setAiApiUsageResult(
319
+ ctx,
320
+ result.usage_metadata,
321
+ {
322
+ gatewayResponseId: completionId,
323
+ providerRequestId: extractProviderRequestId(result),
324
+ },
325
+ result.response_metadata,
326
+ );
300
327
 
301
328
  ctx.status = 200;
302
329
  const toolCalls = normalizeToolCalls(result.tool_calls);
@@ -463,7 +490,7 @@ function getErrorMessage(error: unknown, fallback: string) {
463
490
  * the model answers as if the attachment was never sent. A 400 is far easier to
464
491
  * debug than a confidently wrong completion.
465
492
  */
466
- const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
493
+ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url', 'file', 'file_url']);
467
494
 
468
495
  /**
469
496
  * Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
@@ -474,6 +501,14 @@ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
474
501
  */
475
502
  const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
476
503
 
504
+ /**
505
+ * File blocks accept a wider range of MIME types than `image_url` blocks:
506
+ * documents such as `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
507
+ * or `image/svg+xml` are valid attachments. The grammar still requires a proper
508
+ * `type/subtype` and standard base64 payload.
509
+ */
510
+ const FILE_BASE64_DATA_URL_PATTERN = /^data:([^;\s]+);base64,([A-Za-z0-9+/]+=*)$/;
511
+
477
512
  /**
478
513
  * The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
479
514
  * `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
@@ -579,8 +614,8 @@ function describeContentBlockProblem(block: unknown): string | undefined {
579
614
  if (!type) return "each content block requires a 'type' field";
580
615
  if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
581
616
  return (
582
- `content block type '${type}' is not supported — this gateway forwards 'text' and 'image_url' only. ` +
583
- `Send documents as text, or inline them as an 'image_url' data URL if the model reads images`
617
+ `content block type '${type}' is not supported — this gateway forwards 'text', 'image_url', ` +
618
+ `'file', and 'file_url' only. Send documents as text, or inline them as a 'file' / 'file_url' block`
584
619
  );
585
620
  }
586
621
 
@@ -588,9 +623,56 @@ function describeContentBlockProblem(block: unknown): string | undefined {
588
623
  return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
589
624
  }
590
625
 
626
+ if (type === 'file') {
627
+ return describeFileProblem(block.file);
628
+ }
629
+
630
+ if (type === 'file_url') {
631
+ return describeFileUrlProblem(block.file_url);
632
+ }
633
+
591
634
  return describeImageUrlProblem(block.image_url);
592
635
  }
593
636
 
637
+ function describeFileProblem(file: unknown): string | undefined {
638
+ if (!isRecord(file)) return "a 'file' block requires an object 'file' field";
639
+ const fileData = typeof file.file_data === 'string' ? file.file_data : undefined;
640
+ if (!fileData) return "a 'file' block requires a string 'file.file_data' field";
641
+ if (!fileData.startsWith('data:')) {
642
+ return "a 'file' block's 'file_data' must be a base64 data URL starting with 'data:'";
643
+ }
644
+ const match = FILE_BASE64_DATA_URL_PATTERN.exec(fileData);
645
+ if (!match || !match[1].includes('/')) {
646
+ return (
647
+ `malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' ` +
648
+ `with a valid type/subtype and standard base64 (no whitespace or URL-safe characters)`
649
+ );
650
+ }
651
+ if (!isDecodableBase64(match[2])) {
652
+ return (
653
+ `base64 payload is not decodable. Check the padding and length — ` +
654
+ `the data must be a multiple of 4 characters with at most two trailing '='`
655
+ );
656
+ }
657
+ return undefined;
658
+ }
659
+
660
+ function describeFileUrlProblem(fileUrl: unknown): string | undefined {
661
+ if (!isRecord(fileUrl)) return "a 'file_url' block requires an object 'file_url' field";
662
+ const url = typeof fileUrl.url === 'string' ? fileUrl.url : undefined;
663
+ if (!url) return "a 'file_url' block requires a string 'file_url.url' field";
664
+ let protocol: string;
665
+ try {
666
+ protocol = new URL(url).protocol;
667
+ } catch {
668
+ return `'${url}' is not a valid URL. Use an http(s) URL`;
669
+ }
670
+ if (protocol !== 'http:' && protocol !== 'https:') {
671
+ return `URL protocol '${protocol}' is not supported. Use an http(s) URL`;
672
+ }
673
+ return undefined;
674
+ }
675
+
594
676
  function describeImageUrlProblem(imageUrl: unknown): string | undefined {
595
677
  const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
596
678
  if (typeof url !== 'string' || url === '') {
@@ -669,7 +751,60 @@ export function normalizeMessageContent(content: unknown): MessageContent {
669
751
  return JSON.stringify(content);
670
752
  }
671
753
 
672
- const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'tools', 'tool_choice', 'stream', 'n']);
754
+ /**
755
+ * Run any `file` or `file_url` content blocks through the plugin's file
756
+ * processor service. Custom plugins can register processors to fetch URLs,
757
+ * extract text, OCR, etc. Other block types are left untouched.
758
+ *
759
+ * The service is invoked repeatedly if a processor returns another file-like
760
+ * block (e.g. a `file_url` becomes a `file` block, which may then be converted
761
+ * to images by the PDF processor).
762
+ */
763
+ async function processMessageContentFileBlocks(
764
+ content: unknown,
765
+ ctx: Context,
766
+ plugin: PluginAiApiServer,
767
+ ): Promise<unknown> {
768
+ if (!Array.isArray(content)) return content;
769
+ const processed: unknown[] = [];
770
+ for (const block of content) {
771
+ processed.push(...(await processFileBlockChain(block, ctx, plugin, 0)));
772
+ }
773
+ return processed;
774
+ }
775
+
776
+ const MAX_FILE_PROCESSOR_CHAIN_DEPTH = 3;
777
+
778
+ async function processFileBlockChain(
779
+ block: unknown,
780
+ ctx: Context,
781
+ plugin: PluginAiApiServer,
782
+ depth: number,
783
+ ): Promise<unknown[]> {
784
+ if (!isRecord(block) || (block.type !== 'file' && block.type !== 'file_url')) {
785
+ return [block];
786
+ }
787
+ if (depth > MAX_FILE_PROCESSOR_CHAIN_DEPTH) {
788
+ return [block];
789
+ }
790
+
791
+ const result = await plugin.fileProcessorService.process(block as FileContentBlock, { ctx });
792
+ const results = Array.isArray(result) ? result : [result];
793
+
794
+ const next: unknown[] = [];
795
+ for (const item of results) {
796
+ if (isRecord(item) && (item.type === 'file' || item.type === 'file_url')) {
797
+ // The output is still a file-like block; run it through the chain again
798
+ // so that a `file_url` -> `file` -> images pipeline can complete.
799
+ next.push(...(await processFileBlockChain(item, ctx, plugin, depth + 1)));
800
+ } else {
801
+ next.push(item);
802
+ }
803
+ }
804
+ return next;
805
+ }
806
+
807
+ const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'prompt', 'tools', 'tool_choice', 'stream', 'n']);
673
808
 
674
809
  export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
675
810
  return Object.fromEntries(
@@ -23,9 +23,11 @@ import {
23
23
  isStreamingRequested,
24
24
  writeResponse,
25
25
  } from '../utils/streaming';
26
+ import { getProviderRequestParameters, applyProviderRequestParameters } from './chat-completions';
26
27
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
27
28
  import type PluginAiApiServer from '../plugin';
28
29
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
30
+ import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
29
31
  import { markAiApiFirstProviderOutput } from '../utils/app-observability';
30
32
 
31
33
  /**
@@ -114,8 +116,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
114
116
  return;
115
117
  }
116
118
 
117
- await prepareLlmBilling(ctx, resolved);
118
-
119
119
  const modelOptions: Record<string, any> = {
120
120
  model: modelId,
121
121
  llmService: service.name,
@@ -126,13 +126,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
126
126
  if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
127
127
  if (body.stop !== undefined) modelOptions.stop = body.stop;
128
128
 
129
- const Provider = providerMeta.provider;
130
- const provider = new Provider({
131
- app: ctx.app,
132
- serviceOptions: service.options,
133
- modelOptions,
134
- });
135
-
136
129
  // ─── Convert prompt to message tuple ───
137
130
  const prompt =
138
131
  typeof body.prompt === 'string'
@@ -142,7 +135,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
142
135
  : String(body.prompt);
143
136
 
144
137
  // Inject system prompt from AI Employee if configured
145
- const langchainMessages: [string, string][] = [];
138
+ const messages: OpenAIMessage[] = [];
146
139
  if (config?.defaultAiEmployee) {
147
140
  const employee = await ctx.db.getRepository('aiEmployees').findOne({
148
141
  filter: { username: config.defaultAiEmployee },
@@ -150,14 +143,35 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
150
143
  if (employee) {
151
144
  const systemPrompt = employee.about || employee.defaultPrompt || '';
152
145
  if (systemPrompt) {
153
- langchainMessages.push(['system', systemPrompt]);
146
+ messages.push({ role: 'system', content: systemPrompt });
154
147
  }
155
148
  }
156
149
  }
157
- langchainMessages.push(['human', prompt]);
150
+ messages.push({ role: 'user', content: prompt });
151
+
152
+ const preparedContext = await prepareDirectLlmContext(ctx, {
153
+ serviceName: service.name,
154
+ modelId,
155
+ messages,
156
+ maxTokens: body.max_tokens,
157
+ });
158
+ await prepareLlmBilling(ctx, resolved);
159
+
160
+ const Provider = providerMeta.provider;
161
+ const provider = new Provider({
162
+ app: ctx.app,
163
+ serviceOptions: service.options,
164
+ modelOptions,
165
+ });
166
+ const langchainMessages = preparedContext.messages.map((message): [string, string] => [
167
+ message.role === 'user' ? 'human' : message.role,
168
+ String(message.content ?? ''),
169
+ ]);
158
170
 
159
171
  const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
160
172
  const chatModel = provider.createModel();
173
+ const providerRequestParameters = getProviderRequestParameters(body);
174
+ applyProviderRequestParameters(chatModel, providerRequestParameters);
161
175
  markLlmProviderAttempted(ctx);
162
176
 
163
177
  if (stream) {
@@ -168,21 +182,30 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
168
182
  completionId,
169
183
  body.model,
170
184
  body.stream_options,
185
+ providerRequestParameters,
171
186
  );
172
187
  } else {
173
- await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
188
+ await handleNonStreamingTextCompletion(
189
+ ctx,
190
+ chatModel,
191
+ langchainMessages,
192
+ completionId,
193
+ body.model,
194
+ providerRequestParameters,
195
+ );
174
196
  }
175
197
  } catch (err) {
176
198
  ctx.log.error('AI API completions error:', err);
177
- if (!ctx.res.headersSent) {
199
+ if (!ctx.res?.headersSent) {
178
200
  const isQuotaError = err instanceof AiApiQuotaError;
179
- ctx.status = isQuotaError ? 429 : 500;
201
+ const isContextError = err instanceof DirectLlmContextError;
202
+ ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
180
203
  if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
181
204
  ctx.body = toOpenAIError(
182
205
  ctx.status,
183
206
  getErrorMessage(err, 'Internal server error'),
184
- isQuotaError ? 'quota_error' : 'server_error',
185
- isQuotaError ? err.code : undefined,
207
+ isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
208
+ isQuotaError || isContextError ? err.code : undefined,
186
209
  );
187
210
  }
188
211
  }
@@ -196,8 +219,9 @@ async function handleNonStreamingTextCompletion(
196
219
  messages: [string, string][],
197
220
  completionId: string,
198
221
  modelName: string,
222
+ providerRequestParameters: Record<string, unknown>,
199
223
  ) {
200
- const result = await chatModel.invoke(messages);
224
+ const result = await chatModel.invoke(messages, providerRequestParameters);
201
225
 
202
226
  let text = '';
203
227
  if (typeof result.content === 'string') {
@@ -207,10 +231,15 @@ async function handleNonStreamingTextCompletion(
207
231
  text = textPart?.text || JSON.stringify(result.content);
208
232
  }
209
233
 
210
- const usage = setAiApiUsageResult(ctx, result.usage_metadata, {
211
- gatewayResponseId: completionId,
212
- providerRequestId: extractProviderRequestId(result),
213
- });
234
+ const usage = setAiApiUsageResult(
235
+ ctx,
236
+ result.usage_metadata,
237
+ {
238
+ gatewayResponseId: completionId,
239
+ providerRequestId: extractProviderRequestId(result),
240
+ },
241
+ result.response_metadata,
242
+ );
214
243
 
215
244
  ctx.status = 200;
216
245
  ctx.body = {
@@ -227,7 +256,14 @@ async function handleNonStreamingTextCompletion(
227
256
  finish_reason: 'stop',
228
257
  },
229
258
  ],
230
- usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
259
+ usage: usage
260
+ ? {
261
+ prompt_tokens: usage.prompt_tokens,
262
+ completion_tokens: usage.completion_tokens,
263
+ total_tokens: usage.total_tokens,
264
+ prompt_tokens_details: { cached_tokens: usage.prompt_cache_tokens ?? null },
265
+ }
266
+ : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: null } },
231
267
  };
232
268
  }
233
269
 
@@ -240,6 +276,7 @@ async function handleStreamingTextCompletion(
240
276
  completionId: string,
241
277
  modelName: string,
242
278
  streamOptions: Record<string, unknown> | undefined,
279
+ providerRequestParameters: Record<string, unknown> | undefined,
243
280
  ) {
244
281
  ctx.set({
245
282
  'Content-Type': 'text/event-stream',
@@ -254,6 +291,7 @@ async function handleStreamingTextCompletion(
254
291
  let providerRequestId: string | undefined;
255
292
  try {
256
293
  const stream = await chatModel.stream(messages, {
294
+ ...providerRequestParameters,
257
295
  stream_options: { ...streamOptions, include_usage: true },
258
296
  signal: requestAbort.signal,
259
297
  });
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ FileProcessorService,
4
+ base64FileForwarder,
5
+ httpFileUrlFetcher,
6
+ fetchFileAsBase64,
7
+ pdfFileProcessor,
8
+ type PdfToImageRenderer,
9
+ } from '../file-processor';
10
+
11
+ describe('FileProcessorService', () => {
12
+ it('forwards file blocks with base64 data unchanged', async () => {
13
+ const service = new FileProcessorService();
14
+ service.register(base64FileForwarder);
15
+
16
+ const block = { type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } };
17
+ const result = await service.process(block, { ctx: {} as any });
18
+
19
+ expect(result).toEqual(block);
20
+ });
21
+
22
+ it('returns the block unchanged when no processor can handle it', async () => {
23
+ const service = new FileProcessorService();
24
+ const block = { type: 'text', text: 'hello' };
25
+ const result = await service.process(block, { ctx: {} as any });
26
+
27
+ expect(result).toEqual(block);
28
+ });
29
+
30
+ it('allows custom processors to override default behavior', async () => {
31
+ const service = new FileProcessorService();
32
+ service.register(base64FileForwarder);
33
+
34
+ const customProcessor = {
35
+ name: 'customFileProcessor',
36
+ canHandle: (block: { type: string }) => block.type === 'file',
37
+ process: vi.fn().mockResolvedValue({ type: 'file', file: { file_data: 'data:text/plain;base64,SGVsbG8=' } }),
38
+ };
39
+ service.register(customProcessor);
40
+
41
+ const block = { type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } };
42
+ const result = await service.process(block, { ctx: {} as any });
43
+
44
+ expect(customProcessor.process).toHaveBeenCalledWith(block, { ctx: {} as any });
45
+ expect(result).toEqual({ type: 'file', file: { file_data: 'data:text/plain;base64,SGVsbG8=' } });
46
+ });
47
+
48
+ it('unregister removes a processor by name', () => {
49
+ const service = new FileProcessorService();
50
+ service.register(base64FileForwarder);
51
+ expect(service.list()).toHaveLength(1);
52
+
53
+ service.unregister(base64FileForwarder.name);
54
+ expect(service.list()).toHaveLength(0);
55
+ });
56
+ });
57
+
58
+ describe('httpFileUrlFetcher', () => {
59
+ it('fetches a file from an http(s) URL and converts it to a file block', async () => {
60
+ const originalFetch = globalThis.fetch;
61
+ const fileBuffer = Buffer.from('hello world');
62
+ globalThis.fetch = vi.fn().mockResolvedValue({
63
+ ok: true,
64
+ status: 200,
65
+ headers: new Headers({
66
+ 'content-type': 'text/plain',
67
+ 'content-length': String(fileBuffer.length),
68
+ }),
69
+ arrayBuffer: () =>
70
+ Promise.resolve(fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.length)),
71
+ } as unknown as Response);
72
+
73
+ const result = await httpFileUrlFetcher.process(
74
+ { type: 'file_url', file_url: { url: 'https://example.com/hello.txt' } },
75
+ { ctx: {} as any },
76
+ );
77
+
78
+ expect(result).toMatchObject({
79
+ type: 'file',
80
+ file: {
81
+ file_data: expect.stringContaining('data:text/plain;base64,'),
82
+ mime_type: 'text/plain',
83
+ filename: 'hello.txt',
84
+ },
85
+ });
86
+
87
+ globalThis.fetch = originalFetch;
88
+ });
89
+
90
+ it('rejects unsupported protocols', async () => {
91
+ await expect(
92
+ httpFileUrlFetcher.process(
93
+ { type: 'file_url', file_url: { url: 'ftp://example.com/file.txt' } },
94
+ { ctx: {} as any },
95
+ ),
96
+ ).rejects.toThrow('protocol');
97
+ });
98
+ });
99
+
100
+ describe('fetchFileAsBase64', () => {
101
+ it('validates URL protocols', async () => {
102
+ await expect(fetchFileAsBase64('ftp://example.com/file.txt')).rejects.toThrow('protocol');
103
+ });
104
+
105
+ it('rejects malformed URLs', async () => {
106
+ await expect(fetchFileAsBase64('not a url')).rejects.toThrow('valid URL');
107
+ });
108
+ });
109
+
110
+ describe('pdfFileProcessor', () => {
111
+ const pdfBuffer = Buffer.from('%PDF-1.4\n1 0 obj\n<<\n>>\nendobj\n', 'binary');
112
+ const pdfDataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}`;
113
+
114
+ function createContext(config: { pdfRenderPagesAsImages?: boolean }, renderer?: PdfToImageRenderer | null) {
115
+ const service = new FileProcessorService();
116
+ if (renderer) {
117
+ service.registerPdfRenderer(renderer);
118
+ }
119
+ return {
120
+ ctx: {
121
+ db: {
122
+ getRepository: vi.fn((name: string) => {
123
+ if (name === 'aiApiConfig') {
124
+ return {
125
+ findOne: vi.fn().mockResolvedValue({
126
+ get: (key: string) => (key === 'pdfRenderPagesAsImages' ? config.pdfRenderPagesAsImages : undefined),
127
+ pdfRenderPagesAsImages: config.pdfRenderPagesAsImages,
128
+ }),
129
+ };
130
+ }
131
+ return { findOne: vi.fn() };
132
+ }),
133
+ },
134
+ app: {
135
+ pm: {
136
+ get: vi.fn().mockReturnValue({
137
+ fileProcessorService: service,
138
+ }),
139
+ },
140
+ },
141
+ log: { warn: vi.fn() },
142
+ },
143
+ } as any;
144
+ }
145
+
146
+ it('forwards a PDF file block when pdfRenderPagesAsImages is disabled', async () => {
147
+ const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
148
+ const result = await pdfFileProcessor.process(block, createContext({ pdfRenderPagesAsImages: false }));
149
+ expect(result).toBe(block);
150
+ });
151
+
152
+ it('forwards a PDF and warns when rendering is enabled but no renderer is registered', async () => {
153
+ const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
154
+ const context = createContext({ pdfRenderPagesAsImages: true });
155
+ const result = await pdfFileProcessor.process(block, context);
156
+
157
+ expect(result).toBe(block);
158
+ expect(context.ctx.log.warn).toHaveBeenCalledWith(
159
+ expect.stringContaining('pdfRenderPagesAsImages is enabled but no PdfToImageRenderer is registered'),
160
+ );
161
+ });
162
+
163
+ it('converts a PDF file block into image_url blocks when a renderer is registered', async () => {
164
+ const renderer: PdfToImageRenderer = {
165
+ name: 'mockRenderer',
166
+ render: vi.fn().mockResolvedValue([Buffer.from('page1'), Buffer.from('page2')]),
167
+ };
168
+ const block = { type: 'file', file: { file_data: pdfDataUrl } } as any;
169
+ const result = await pdfFileProcessor.process(block, createContext({ pdfRenderPagesAsImages: true }, renderer));
170
+
171
+ expect(Array.isArray(result)).toBe(true);
172
+ expect(result).toHaveLength(2);
173
+ expect((result as any[])[0]).toMatchObject({ type: 'image_url' });
174
+ expect((result as any[])[0].image_url.url).toMatch(/^data:image\/png;base64,/);
175
+ });
176
+
177
+ it('does not convert non-PDF file blocks', async () => {
178
+ const block = {
179
+ type: 'file',
180
+ file: { file_data: 'data:text/plain;base64,SGVsbG8=', filename: 'hello.txt' },
181
+ } as any;
182
+ expect(pdfFileProcessor.canHandle(block)).toBe(false);
183
+ });
184
+ });