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.
Files changed (54) hide show
  1. package/dist/client/757.56952e321dc399b7.js +10 -0
  2. package/dist/client/index.js +1 -1
  3. package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
  4. package/dist/client-v2/index.js +1 -1
  5. package/dist/externalVersion.js +8 -8
  6. package/dist/locale/en-US.json +1 -0
  7. package/dist/locale/vi-VN.json +1 -0
  8. package/dist/locale/zh-CN.json +1 -0
  9. package/dist/server/billing.js +6 -1
  10. package/dist/server/collections/ai-api-config.js +6 -0
  11. package/dist/server/collections/ai-api-usage-records.js +1 -0
  12. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  13. package/dist/server/plugin.js +10 -0
  14. package/dist/server/resource/ai-api-config.js +5 -0
  15. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  16. package/dist/server/routes/chat-completions.js +89 -10
  17. package/dist/server/routes/completions.js +32 -10
  18. package/dist/server/services/file-processor.js +262 -0
  19. package/dist/server/usage.js +33 -3
  20. package/dist/server/utils/direct-llm-context.js +150 -15
  21. package/dist/server/utils/openai-format.js +21 -2
  22. package/dist/swagger.js +42 -3
  23. package/package.json +1 -1
  24. package/src/client-v2/pages/UsagePage.tsx +9 -0
  25. package/src/locale/en-US.json +1 -0
  26. package/src/locale/vi-VN.json +1 -0
  27. package/src/locale/zh-CN.json +1 -0
  28. package/src/server/__tests__/direct-llm-context.test.ts +87 -6
  29. package/src/server/__tests__/openai-format.test.ts +12 -2
  30. package/src/server/__tests__/request-body.test.ts +45 -2
  31. package/src/server/__tests__/usage-route.test.ts +120 -3
  32. package/src/server/__tests__/usage.test.ts +19 -0
  33. package/src/server/billing.ts +6 -1
  34. package/src/server/collections/ai-api-config.ts +8 -0
  35. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  36. package/src/server/collections/ai-api-usage-records.ts +1 -0
  37. package/src/server/index.ts +10 -10
  38. package/src/server/middleware/rate-limit.ts +70 -70
  39. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  40. package/src/server/plugin.ts +20 -0
  41. package/src/server/resource/ai-api-config.ts +5 -0
  42. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  43. package/src/server/routes/chat-completions.ts +134 -11
  44. package/src/server/routes/completions.ts +33 -7
  45. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  46. package/src/server/services/file-processor.ts +323 -0
  47. package/src/server/usage.ts +47 -1
  48. package/src/server/utils/direct-llm-context.ts +198 -20
  49. package/src/server/utils/openai-format.ts +25 -2
  50. package/src/server/utils/rate-limiter.ts +83 -83
  51. package/src/server/utils/resolve-service.ts +82 -82
  52. package/src/swagger.ts +45 -3
  53. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  54. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
@@ -32,6 +32,7 @@ export default defineCollection({
32
32
  { name: 'inputTokens', type: 'integer', allowNull: true },
33
33
  { name: 'outputTokens', type: 'integer', allowNull: true },
34
34
  { name: 'totalTokens', type: 'integer', allowNull: true },
35
+ { name: 'promptCacheTokens', type: 'integer', allowNull: true },
35
36
  { name: 'estimatedCost', type: 'decimal', allowNull: true, precision: 20, scale: 8 },
36
37
  { name: 'currency', type: 'string', allowNull: true },
37
38
  { name: 'costStatus', type: 'string', allowNull: true, index: true },
@@ -1,10 +1,10 @@
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
- export { default } from './plugin';
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
+ export { default } from './plugin';
@@ -1,70 +1,70 @@
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
- import { Context } from '@nocobase/actions';
11
- import { RateLimiter } from '../utils/rate-limiter';
12
- import { toOpenAIError } from '../utils/openai-format';
13
-
14
- /**
15
- * Creates a rate-limiting check function for use in the AI API router.
16
- *
17
- * Must be called AFTER authenticateBearer() so ctx.state.currentUser is set.
18
- * Reads rateLimitPerMinute from aiApiConfig on each request (cheap single-row read,
19
- * allows config changes to take effect immediately without restart).
20
- * Falls back to 60 req/min if the config record is missing or the field is 0/null.
21
- *
22
- * Returns false (and writes the 429 response) when the rate limit is exceeded.
23
- * Returns true when the request is allowed.
24
- *
25
- * Sets OpenAI-compatible rate limit response headers on every request:
26
- * X-RateLimit-Limit: <limit>
27
- * X-RateLimit-Remaining: <remaining> (on 429: 0)
28
- * Retry-After: <seconds> (on 429 only)
29
- */
30
- export function createRateLimitMiddleware(limiter: RateLimiter) {
31
- return async (ctx: Context): Promise<boolean> => {
32
- const userId = ctx.state.currentUser?.id;
33
- // Auth runs before this; if somehow missing, fail open (don't block the request).
34
- if (userId === undefined || userId === null) return true;
35
-
36
- let limit = 60;
37
- try {
38
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
39
- const configLimit = config?.rateLimitPerMinute;
40
- if (configLimit && configLimit > 0) {
41
- limit = configLimit;
42
- }
43
- } catch (configErr) {
44
- // Config read failure: fail open — don't block legitimate requests
45
- // Log at WARN so admins can detect DB connectivity issues
46
- ctx.app?.logger?.warn('[ai-api] Rate limit config read failed, using default (60/min)', configErr);
47
- }
48
-
49
- const result = limiter.check(userId, limit);
50
-
51
- if (!result.allowed) {
52
- const retryAfterSec = Math.ceil((result as any).retryAfterMs / 1000);
53
- ctx.set('Retry-After', String(retryAfterSec));
54
- ctx.set('X-RateLimit-Limit', String(limit));
55
- ctx.set('X-RateLimit-Remaining', '0');
56
- ctx.status = 429;
57
- ctx.body = toOpenAIError(
58
- 429,
59
- `Rate limit exceeded. You have used all ${limit} requests allowed per minute. ` +
60
- `Please wait ${retryAfterSec} second${retryAfterSec !== 1 ? 's' : ''} before retrying.`,
61
- 'requests',
62
- 'rate_limit_exceeded',
63
- );
64
- return false;
65
- }
66
-
67
- ctx.set('X-RateLimit-Limit', String(limit));
68
- return true;
69
- };
70
- }
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
+ import { Context } from '@nocobase/actions';
11
+ import { RateLimiter } from '../utils/rate-limiter';
12
+ import { toOpenAIError } from '../utils/openai-format';
13
+
14
+ /**
15
+ * Creates a rate-limiting check function for use in the AI API router.
16
+ *
17
+ * Must be called AFTER authenticateBearer() so ctx.state.currentUser is set.
18
+ * Reads rateLimitPerMinute from aiApiConfig on each request (cheap single-row read,
19
+ * allows config changes to take effect immediately without restart).
20
+ * Falls back to 60 req/min if the config record is missing or the field is 0/null.
21
+ *
22
+ * Returns false (and writes the 429 response) when the rate limit is exceeded.
23
+ * Returns true when the request is allowed.
24
+ *
25
+ * Sets OpenAI-compatible rate limit response headers on every request:
26
+ * X-RateLimit-Limit: <limit>
27
+ * X-RateLimit-Remaining: <remaining> (on 429: 0)
28
+ * Retry-After: <seconds> (on 429 only)
29
+ */
30
+ export function createRateLimitMiddleware(limiter: RateLimiter) {
31
+ return async (ctx: Context): Promise<boolean> => {
32
+ const userId = ctx.state.currentUser?.id;
33
+ // Auth runs before this; if somehow missing, fail open (don't block the request).
34
+ if (userId === undefined || userId === null) return true;
35
+
36
+ let limit = 60;
37
+ try {
38
+ const config = await ctx.db.getRepository('aiApiConfig').findOne();
39
+ const configLimit = config?.rateLimitPerMinute;
40
+ if (configLimit && configLimit > 0) {
41
+ limit = configLimit;
42
+ }
43
+ } catch (configErr) {
44
+ // Config read failure: fail open — don't block legitimate requests
45
+ // Log at WARN so admins can detect DB connectivity issues
46
+ ctx.app?.logger?.warn('[ai-api] Rate limit config read failed, using default (60/min)', configErr);
47
+ }
48
+
49
+ const result = limiter.check(userId, limit);
50
+
51
+ if (!result.allowed) {
52
+ const retryAfterSec = Math.ceil((result as any).retryAfterMs / 1000);
53
+ ctx.set('Retry-After', String(retryAfterSec));
54
+ ctx.set('X-RateLimit-Limit', String(limit));
55
+ ctx.set('X-RateLimit-Remaining', '0');
56
+ ctx.status = 429;
57
+ ctx.body = toOpenAIError(
58
+ 429,
59
+ `Rate limit exceeded. You have used all ${limit} requests allowed per minute. ` +
60
+ `Please wait ${retryAfterSec} second${retryAfterSec !== 1 ? 's' : ''} before retrying.`,
61
+ 'requests',
62
+ 'rate_limit_exceeded',
63
+ );
64
+ return false;
65
+ }
66
+
67
+ ctx.set('X-RateLimit-Limit', String(limit));
68
+ return true;
69
+ };
70
+ }
@@ -0,0 +1,46 @@
1
+ import { Migration } from '@nocobase/server';
2
+
3
+ export default class AddPromptCacheTokensToUsageRecords extends Migration {
4
+ on = 'beforeLoad' as const;
5
+
6
+ async up() {
7
+ const collection = this.db.getCollection('aiApiUsageRecords');
8
+ if (!collection) return;
9
+
10
+ const field = collection.getField('promptCacheTokens');
11
+ if (!field) {
12
+ collection.addField('promptCacheTokens', { type: 'integer', allowNull: true });
13
+ }
14
+
15
+ if (await collection.existsInDb()) {
16
+ const tableName = collection.getTableNameWithSchema();
17
+ const exists = await this.tableColumnExists(tableName, 'promptCacheTokens');
18
+ if (!exists) {
19
+ await this.queryInterface.addColumn(tableName, 'promptCacheTokens', {
20
+ type: this.db.sequelize.getDialect() === 'sqlite' ? 'INTEGER' : 'INTEGER',
21
+ allowNull: true,
22
+ });
23
+ }
24
+ }
25
+ }
26
+
27
+ async down() {
28
+ const collection = this.db.getCollection('aiApiUsageRecords');
29
+ if (!collection) return;
30
+
31
+ if (await collection.existsInDb()) {
32
+ const tableName = collection.getTableNameWithSchema();
33
+ const exists = await this.tableColumnExists(tableName, 'promptCacheTokens');
34
+ if (exists) {
35
+ await this.queryInterface.removeColumn(tableName, 'promptCacheTokens');
36
+ }
37
+ }
38
+
39
+ collection.removeField('promptCacheTokens');
40
+ }
41
+
42
+ private async tableColumnExists(tableName: string, columnName: string): Promise<boolean> {
43
+ const columns = await this.queryInterface.describeTable(tableName);
44
+ return Object.prototype.hasOwnProperty.call(columns, columnName);
45
+ }
46
+ }
@@ -18,6 +18,12 @@ import { invalidateRolePermissionCache } from './middleware/role-permission';
18
18
  import { invalidateUserPermissionCache } from './utils/user-permissions';
19
19
  import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
20
20
  import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
21
+ import {
22
+ FileProcessorService,
23
+ base64FileForwarder,
24
+ httpFileUrlFetcher,
25
+ pdfFileProcessor,
26
+ } from './services/file-processor';
21
27
 
22
28
  // Ensure dayjs timezone + utc plugins are loaded.
23
29
  // Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
@@ -39,6 +45,12 @@ export class PluginAiApiServer extends Plugin {
39
45
  */
40
46
  rateLimiter = new RateLimiter(60_000);
41
47
 
48
+ /**
49
+ * Extensible file processor service. Other plugins can register custom processors
50
+ * to transform file/file_url content blocks before they reach the LLM.
51
+ */
52
+ fileProcessorService = new FileProcessorService();
53
+
42
54
  private gcInterval: NodeJS.Timeout | null = null;
43
55
 
44
56
  async afterAdd() {}
@@ -56,6 +68,13 @@ export class PluginAiApiServer extends Plugin {
56
68
  }
57
69
 
58
70
  async load() {
71
+ // Register default file processors. Custom plugins can register additional
72
+ // processors by retrieving this plugin instance and calling
73
+ // `fileProcessorService.register(processor)`.
74
+ this.fileProcessorService.register(base64FileForwarder);
75
+ this.fileProcessorService.register(httpFileUrlFetcher);
76
+ this.fileProcessorService.register(pdfFileProcessor);
77
+
59
78
  // 1. Claim body parsing for our own routes before the core bodyParser runs.
60
79
  // Core registers koa-bodyparser with a global REQUEST_BODY_LIMIT (10mb by
61
80
  // default) much earlier in the stack, so without this the gateway's own
@@ -159,6 +178,7 @@ export class PluginAiApiServer extends Plugin {
159
178
  enabledLlmServices: [],
160
179
  rateLimitPerMinute: 60,
161
180
  quotaEnabled: false,
181
+ pdfRenderPagesAsImages: false,
162
182
  defaultReservationOutputTokens: 4096,
163
183
  },
164
184
  });
@@ -48,6 +48,7 @@ const aiApiConfigResource: ResourceOptions = {
48
48
  enabledLlmServices: [],
49
49
  rateLimitPerMinute: 60,
50
50
  maxRequestBodyMb: 10,
51
+ pdfRenderPagesAsImages: false,
51
52
  quotaEnabled: false,
52
53
  defaultReservationOutputTokens: 4096,
53
54
  options: {},
@@ -72,6 +73,7 @@ const aiApiConfigResource: ResourceOptions = {
72
73
  enabledLlmServices: values.enabledLlmServices ?? [],
73
74
  rateLimitPerMinute: values.rateLimitPerMinute ?? 60,
74
75
  maxRequestBodyMb: coerceMaxRequestBodyMb(values.maxRequestBodyMb ?? DEFAULT_MAX_REQUEST_BODY_MB),
76
+ pdfRenderPagesAsImages: values.pdfRenderPagesAsImages ?? false,
75
77
  quotaEnabled: values.quotaEnabled ?? false,
76
78
  defaultReservationOutputTokens: values.defaultReservationOutputTokens ?? 4096,
77
79
  options: values.options ?? {},
@@ -87,6 +89,9 @@ const aiApiConfigResource: ResourceOptions = {
87
89
  if (values.maxRequestBodyMb !== undefined) {
88
90
  updateData.maxRequestBodyMb = coerceMaxRequestBodyMb(values.maxRequestBodyMb);
89
91
  }
92
+ if (values.pdfRenderPagesAsImages !== undefined) {
93
+ updateData.pdfRenderPagesAsImages = Boolean(values.pdfRenderPagesAsImages);
94
+ }
90
95
  if (values.quotaEnabled !== undefined) updateData.quotaEnabled = values.quotaEnabled;
91
96
  if (values.defaultReservationOutputTokens !== undefined) {
92
97
  updateData.defaultReservationOutputTokens = values.defaultReservationOutputTokens;
@@ -7,6 +7,7 @@ interface UsageSummaryRow {
7
7
  inputTokens?: string | number;
8
8
  outputTokens?: string | number;
9
9
  totalTokens?: string | number;
10
+ promptCacheTokens?: string | number;
10
11
  }
11
12
 
12
13
  interface CostSummaryRow {
@@ -45,6 +46,7 @@ const aiApiUsageMonitorResource: ResourceOptions = {
45
46
  [fn('COALESCE', fn('SUM', col('inputTokens')), 0), 'inputTokens'],
46
47
  [fn('COALESCE', fn('SUM', col('outputTokens')), 0), 'outputTokens'],
47
48
  [fn('COALESCE', fn('SUM', col('totalTokens')), 0), 'totalTokens'],
49
+ [fn('COALESCE', fn('SUM', col('promptCacheTokens')), 0), 'promptCacheTokens'],
48
50
  ],
49
51
  where,
50
52
  raw: true,
@@ -61,6 +63,7 @@ const aiApiUsageMonitorResource: ResourceOptions = {
61
63
  inputTokens: Number(totals?.inputTokens ?? 0),
62
64
  outputTokens: Number(totals?.outputTokens ?? 0),
63
65
  totalTokens: Number(totals?.totalTokens ?? 0),
66
+ promptCacheTokens: Number(totals?.promptCacheTokens ?? 0),
64
67
  costsByCurrency: costs.map((item) => ({
65
68
  currency: item.currency || 'USD',
66
69
  totalCost: String(item.totalCost ?? 0),
@@ -33,6 +33,7 @@ import type PluginAiApiServer from '../plugin';
33
33
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
34
34
  import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
35
35
  import { markAiApiFirstProviderOutput } from '../utils/app-observability';
36
+ import { FileContentBlock, FileProcessorError } from '../services/file-processor';
36
37
 
37
38
  /**
38
39
  * POST /api/ai-llm/v1/chat/completions
@@ -195,6 +196,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
195
196
  messages.unshift({ role: 'system', content: systemPrompt });
196
197
  }
197
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
+
198
207
  const preparedContext = await prepareDirectLlmContext(ctx, {
199
208
  serviceName: service.name,
200
209
  modelId,
@@ -271,13 +280,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
271
280
  if (!ctx.res?.headersSent) {
272
281
  const isQuotaError = err instanceof AiApiQuotaError;
273
282
  const isContextError = err instanceof DirectLlmContextError;
274
- ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
283
+ const isFileError = err instanceof FileProcessorError;
284
+ ctx.status = isQuotaError ? 429 : isContextError || isFileError ? 400 : 500;
275
285
  if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
276
286
  ctx.body = toOpenAIError(
277
287
  ctx.status,
278
288
  getErrorMessage(err, 'Internal server error'),
279
- isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
280
- isQuotaError || isContextError ? err.code : undefined,
289
+ isQuotaError ? 'quota_error' : isContextError || isFileError ? 'invalid_request_error' : 'server_error',
290
+ isQuotaError || isContextError || isFileError ? err.code : undefined,
281
291
  );
282
292
  }
283
293
  }
@@ -305,10 +315,15 @@ async function handleNonStreamingCompletion(
305
315
  }
306
316
 
307
317
  // Extract usage if available
308
- const usage = setAiApiUsageResult(ctx, result.usage_metadata, {
309
- gatewayResponseId: completionId,
310
- providerRequestId: extractProviderRequestId(result),
311
- });
318
+ const usage = setAiApiUsageResult(
319
+ ctx,
320
+ result.usage_metadata,
321
+ {
322
+ gatewayResponseId: completionId,
323
+ providerRequestId: extractProviderRequestId(result),
324
+ },
325
+ result.response_metadata,
326
+ );
312
327
 
313
328
  ctx.status = 200;
314
329
  const toolCalls = normalizeToolCalls(result.tool_calls);
@@ -475,7 +490,7 @@ function getErrorMessage(error: unknown, fallback: string) {
475
490
  * the model answers as if the attachment was never sent. A 400 is far easier to
476
491
  * debug than a confidently wrong completion.
477
492
  */
478
- const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
493
+ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url', 'file', 'file_url']);
479
494
 
480
495
  /**
481
496
  * Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
@@ -486,6 +501,14 @@ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
486
501
  */
487
502
  const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
488
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
+
489
512
  /**
490
513
  * The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
491
514
  * `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
@@ -591,8 +614,8 @@ function describeContentBlockProblem(block: unknown): string | undefined {
591
614
  if (!type) return "each content block requires a 'type' field";
592
615
  if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
593
616
  return (
594
- `content block type '${type}' is not supported — this gateway forwards 'text' and 'image_url' only. ` +
595
- `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`
596
619
  );
597
620
  }
598
621
 
@@ -600,9 +623,56 @@ function describeContentBlockProblem(block: unknown): string | undefined {
600
623
  return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
601
624
  }
602
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
+
603
634
  return describeImageUrlProblem(block.image_url);
604
635
  }
605
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
+
606
676
  function describeImageUrlProblem(imageUrl: unknown): string | undefined {
607
677
  const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
608
678
  if (typeof url !== 'string' || url === '') {
@@ -681,7 +751,60 @@ export function normalizeMessageContent(content: unknown): MessageContent {
681
751
  return JSON.stringify(content);
682
752
  }
683
753
 
684
- 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']);
685
808
 
686
809
  export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
687
810
  return Object.fromEntries(
@@ -23,6 +23,7 @@ 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';
@@ -169,6 +170,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
169
170
 
170
171
  const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
171
172
  const chatModel = provider.createModel();
173
+ const providerRequestParameters = getProviderRequestParameters(body);
174
+ applyProviderRequestParameters(chatModel, providerRequestParameters);
172
175
  markLlmProviderAttempted(ctx);
173
176
 
174
177
  if (stream) {
@@ -179,9 +182,17 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
179
182
  completionId,
180
183
  body.model,
181
184
  body.stream_options,
185
+ providerRequestParameters,
182
186
  );
183
187
  } else {
184
- 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
+ );
185
196
  }
186
197
  } catch (err) {
187
198
  ctx.log.error('AI API completions error:', err);
@@ -208,8 +219,9 @@ async function handleNonStreamingTextCompletion(
208
219
  messages: [string, string][],
209
220
  completionId: string,
210
221
  modelName: string,
222
+ providerRequestParameters: Record<string, unknown>,
211
223
  ) {
212
- const result = await chatModel.invoke(messages);
224
+ const result = await chatModel.invoke(messages, providerRequestParameters);
213
225
 
214
226
  let text = '';
215
227
  if (typeof result.content === 'string') {
@@ -219,10 +231,15 @@ async function handleNonStreamingTextCompletion(
219
231
  text = textPart?.text || JSON.stringify(result.content);
220
232
  }
221
233
 
222
- const usage = setAiApiUsageResult(ctx, result.usage_metadata, {
223
- gatewayResponseId: completionId,
224
- providerRequestId: extractProviderRequestId(result),
225
- });
234
+ const usage = setAiApiUsageResult(
235
+ ctx,
236
+ result.usage_metadata,
237
+ {
238
+ gatewayResponseId: completionId,
239
+ providerRequestId: extractProviderRequestId(result),
240
+ },
241
+ result.response_metadata,
242
+ );
226
243
 
227
244
  ctx.status = 200;
228
245
  ctx.body = {
@@ -239,7 +256,14 @@ async function handleNonStreamingTextCompletion(
239
256
  finish_reason: 'stop',
240
257
  },
241
258
  ],
242
- 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 } },
243
267
  };
244
268
  }
245
269
 
@@ -252,6 +276,7 @@ async function handleStreamingTextCompletion(
252
276
  completionId: string,
253
277
  modelName: string,
254
278
  streamOptions: Record<string, unknown> | undefined,
279
+ providerRequestParameters: Record<string, unknown> | undefined,
255
280
  ) {
256
281
  ctx.set({
257
282
  'Content-Type': 'text/event-stream',
@@ -266,6 +291,7 @@ async function handleStreamingTextCompletion(
266
291
  let providerRequestId: string | undefined;
267
292
  try {
268
293
  const stream = await chatModel.stream(messages, {
294
+ ...providerRequestParameters,
269
295
  stream_options: { ...streamOptions, include_usage: true },
270
296
  signal: requestAbort.signal,
271
297
  });