plugin-ai-api 1.0.24 → 1.0.28

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 (130) hide show
  1. package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
  2. package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
  3. package/dist/client/562.44b16aad4718b4c7.js +10 -0
  4. package/dist/client/685.ae483e17b6b49c98.js +10 -0
  5. package/dist/client/757.6568d3504ad29352.js +10 -0
  6. package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.3971233415999b2c.js +10 -0
  9. package/dist/client-v2/562.45d5c504433be38b.js +10 -0
  10. package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
  11. package/dist/client-v2/757.f2bc9cfba07004b0.js +10 -0
  12. package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
  13. package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +2 -5
  16. package/dist/externalVersion.js +8 -8
  17. package/dist/locale/en-US.json +27 -8
  18. package/dist/locale/vi-VN.json +27 -8
  19. package/dist/locale/zh-CN.json +27 -8
  20. package/dist/server/billing.js +31 -33
  21. package/dist/server/collections/ai-api-config.js +7 -7
  22. package/dist/server/collections/ai-api-group-members.js +62 -0
  23. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  24. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  25. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  26. package/dist/server/collections/ai-api-usage-records.js +2 -0
  27. package/dist/server/middleware/rate-limit.js +7 -6
  28. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  29. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  30. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  31. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  32. package/dist/server/plugin.js +100 -22
  33. package/dist/server/quota-groups.js +108 -0
  34. package/dist/server/resource/ai-api-config.js +5 -3
  35. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  36. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  37. package/dist/server/routes/agent-completions.js +2 -1
  38. package/dist/server/routes/chat-completions.js +121 -42
  39. package/dist/server/routes/completions.js +48 -29
  40. package/dist/server/routes/embeddings.js +2 -1
  41. package/dist/server/routes/models.js +2 -1
  42. package/dist/server/routes/router.js +3 -2
  43. package/dist/server/services/file-processor.js +426 -0
  44. package/dist/server/usage.js +37 -3
  45. package/dist/server/utils/direct-llm-context.js +163 -26
  46. package/dist/server/utils/openai-format.js +21 -2
  47. package/dist/server/utils/rate-limiter.js +1 -1
  48. package/dist/server/utils/request-cache.js +61 -0
  49. package/dist/server/utils/resolve-service.js +2 -1
  50. package/dist/server/utils/user-permissions.js +25 -39
  51. package/dist/server/validation.js +7 -0
  52. package/dist/swagger.js +48 -10
  53. package/package.json +1 -1
  54. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  55. package/src/client/plugin.tsx +5 -16
  56. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  57. package/src/client-v2/locale.ts +3 -1
  58. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  59. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  60. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  61. package/src/client-v2/pages/UsagePage.tsx +9 -0
  62. package/src/client-v2/plugin.tsx +4 -13
  63. package/src/constants.ts +0 -7
  64. package/src/locale/en-US.json +27 -8
  65. package/src/locale/vi-VN.json +27 -8
  66. package/src/locale/zh-CN.json +27 -8
  67. package/src/server/__tests__/billing-quota.test.ts +28 -9
  68. package/src/server/__tests__/direct-llm-context.test.ts +209 -10
  69. package/src/server/__tests__/file-processor.test.ts +225 -0
  70. package/src/server/__tests__/models.test.ts +1 -1
  71. package/src/server/__tests__/openai-format.test.ts +12 -2
  72. package/src/server/__tests__/permission-sync.test.ts +34 -35
  73. package/src/server/__tests__/request-body.test.ts +45 -2
  74. package/src/server/__tests__/usage-groups.test.ts +160 -0
  75. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  76. package/src/server/__tests__/usage-route.test.ts +382 -5
  77. package/src/server/__tests__/usage.test.ts +57 -0
  78. package/src/server/__tests__/user-permissions.test.ts +214 -133
  79. package/src/server/__tests__/validation.test.ts +11 -0
  80. package/src/server/billing.ts +36 -39
  81. package/src/server/collections/ai-api-config.ts +9 -7
  82. package/src/server/collections/ai-api-group-members.ts +41 -0
  83. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  84. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  85. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  86. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  87. package/src/server/collections/ai-api-usage-records.ts +2 -0
  88. package/src/server/index.ts +10 -10
  89. package/src/server/middleware/rate-limit.ts +68 -70
  90. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  91. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  92. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  93. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  94. package/src/server/plugin.ts +121 -30
  95. package/src/server/quota-groups.ts +117 -0
  96. package/src/server/resource/ai-api-config.ts +5 -3
  97. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  98. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  99. package/src/server/routes/agent-completions.ts +2 -1
  100. package/src/server/routes/chat-completions.ts +173 -47
  101. package/src/server/routes/completions.ts +50 -27
  102. package/src/server/routes/embeddings.ts +2 -1
  103. package/src/server/routes/models.ts +4 -3
  104. package/src/server/routes/router.ts +4 -3
  105. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  106. package/src/server/services/file-processor.ts +513 -0
  107. package/src/server/usage.ts +51 -1
  108. package/src/server/utils/direct-llm-context.ts +218 -31
  109. package/src/server/utils/openai-format.ts +25 -2
  110. package/src/server/utils/rate-limiter.ts +83 -83
  111. package/src/server/utils/request-cache.ts +59 -0
  112. package/src/server/utils/resolve-service.ts +83 -82
  113. package/src/server/utils/user-permissions.ts +49 -69
  114. package/src/server/validation.ts +7 -0
  115. package/src/swagger.ts +52 -11
  116. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  117. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  118. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  119. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  120. package/dist/client/902.e74518750f1e4201.js +0 -10
  121. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  122. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  123. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  124. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  125. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  126. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  127. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  128. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  129. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  130. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -26,13 +26,14 @@ import {
26
26
  isStreamingRequested,
27
27
  writeResponse,
28
28
  } from '../utils/streaming';
29
- import { checkEmployeeAccess } from '../middleware/role-permission';
30
29
  import { enforceModelAccess } from '../utils/user-permissions';
30
+ import { getAiApiConfig } from '../utils/request-cache';
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
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
@@ -130,7 +131,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
130
131
  }
131
132
 
132
133
  // ─── Check whitelist (global config ∩ per-user grant) ───
133
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
134
+ const config = await getAiApiConfig(ctx);
134
135
  if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
135
136
  return;
136
137
  }
@@ -165,35 +166,18 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
165
166
  if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
166
167
  if (body.stop !== undefined) modelOptions.stop = body.stop;
167
168
 
168
- // ─── Build system prompt from AI Employee ───
169
- let systemPrompt = '';
170
- if (config?.defaultAiEmployee) {
171
- // Check role is allowed to use this employee
172
- if (!checkEmployeeAccess(ctx, config.defaultAiEmployee)) {
173
- ctx.status = 403;
174
- ctx.body = toOpenAIError(
175
- 403,
176
- `Role is not permitted to use AI Employee '${config.defaultAiEmployee}'. ` +
177
- `An admin must grant access in Settings → Users & Permissions → [Role] → AI API.`,
178
- 'permission_denied',
179
- 'employee_not_permitted',
180
- );
181
- return;
182
- }
183
- const employee = await ctx.db.getRepository('aiEmployees').findOne({
184
- filter: { username: config.defaultAiEmployee },
185
- });
186
- if (employee) {
187
- systemPrompt = employee.about || employee.defaultPrompt || '';
188
- }
189
- }
190
-
191
- // ─── Build messages (inject system prompt if not provided by client) ───
169
+ // Direct LLM mode ignores the default AI Employee: its prompt belongs to agent
170
+ // mode only. The model metadata system prompt is still applied later by
171
+ // prepareDirectLlmContext.
192
172
  let messages: OpenAIMessage[] = [...body.messages];
193
- const hasSystemMessage = messages.some((m: any) => m.role === 'system');
194
- if (systemPrompt && !hasSystemMessage) {
195
- messages.unshift({ role: 'system', content: systemPrompt });
196
- }
173
+
174
+ // ─── Process file / file_url blocks through the file processor service ───
175
+ messages = await Promise.all(
176
+ messages.map(async (msg) => ({
177
+ ...msg,
178
+ content: await processMessageContentFileBlocks(msg.content, ctx, plugin),
179
+ })),
180
+ );
197
181
 
198
182
  const preparedContext = await prepareDirectLlmContext(ctx, {
199
183
  serviceName: service.name,
@@ -271,13 +255,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
271
255
  if (!ctx.res?.headersSent) {
272
256
  const isQuotaError = err instanceof AiApiQuotaError;
273
257
  const isContextError = err instanceof DirectLlmContextError;
274
- ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
258
+ const isFileError = err instanceof FileProcessorError;
259
+ ctx.status = isQuotaError ? 429 : isContextError || isFileError ? 400 : 500;
275
260
  if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
276
261
  ctx.body = toOpenAIError(
277
262
  ctx.status,
278
263
  getErrorMessage(err, 'Internal server error'),
279
- isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
280
- isQuotaError || isContextError ? err.code : undefined,
264
+ isQuotaError ? 'quota_error' : isContextError || isFileError ? 'invalid_request_error' : 'server_error',
265
+ isQuotaError || isContextError || isFileError ? err.code : undefined,
281
266
  );
282
267
  }
283
268
  }
@@ -305,19 +290,26 @@ async function handleNonStreamingCompletion(
305
290
  }
306
291
 
307
292
  // Extract usage if available
308
- const usage = setAiApiUsageResult(ctx, result.usage_metadata, {
309
- gatewayResponseId: completionId,
310
- providerRequestId: extractProviderRequestId(result),
311
- });
293
+ const usage = setAiApiUsageResult(
294
+ ctx,
295
+ result.usage_metadata,
296
+ {
297
+ gatewayResponseId: completionId,
298
+ providerRequestId: extractProviderRequestId(result),
299
+ },
300
+ result.response_metadata,
301
+ );
312
302
 
313
303
  ctx.status = 200;
314
304
  const toolCalls = normalizeToolCalls(result.tool_calls);
305
+ const providerFinishReason = extractFinishReason(result);
315
306
  ctx.body = toOpenAIResponse({
316
307
  id: completionId,
317
308
  model: modelName,
318
309
  content,
319
310
  usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
320
311
  toolCalls,
312
+ ...(providerFinishReason ? { finishReason: providerFinishReason } : {}),
321
313
  });
322
314
  }
323
315
 
@@ -354,8 +346,10 @@ async function handleStreamingCompletion(
354
346
 
355
347
  const requestAbort = createRequestAbortController(ctx);
356
348
  let usage: Usage | undefined;
349
+ let usageResponseMetadata: unknown;
357
350
  let providerRequestId: string | undefined;
358
- let finishReason = 'stop';
351
+ let providerFinishReason: string | undefined;
352
+ let sawToolCalls = false;
359
353
  try {
360
354
  const stream = await chatModel.stream(messages, { ...providerRequestParameters, signal: requestAbort.signal });
361
355
 
@@ -386,7 +380,7 @@ async function handleStreamingCompletion(
386
380
  const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
387
381
  if (toolCallChunks.length) {
388
382
  markAiApiFirstProviderOutput(ctx);
389
- finishReason = 'tool_calls';
383
+ sawToolCalls = true;
390
384
  await writeResponse(
391
385
  ctx,
392
386
  formatSSE(
@@ -399,8 +393,14 @@ async function handleStreamingCompletion(
399
393
  );
400
394
  }
401
395
  if (chunk.usage_metadata) {
402
- usage = normalizeUsage(chunk.usage_metadata) ?? usage;
396
+ const normalized = normalizeUsage(chunk.usage_metadata);
397
+ if (normalized) {
398
+ usage = normalized;
399
+ usageResponseMetadata = chunk.response_metadata;
400
+ }
403
401
  }
402
+ const chunkFinishReason = extractFinishReason(chunk);
403
+ if (chunkFinishReason) providerFinishReason = chunkFinishReason;
404
404
  providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
405
405
  }
406
406
 
@@ -412,7 +412,7 @@ async function handleStreamingCompletion(
412
412
  id: completionId,
413
413
  model: modelName,
414
414
  delta: {},
415
- finishReason,
415
+ finishReason: providerFinishReason ?? (sawToolCalls ? 'tool_calls' : 'stop'),
416
416
  }),
417
417
  ),
418
418
  );
@@ -432,7 +432,7 @@ async function handleStreamingCompletion(
432
432
 
433
433
  // Send [DONE]
434
434
  await writeResponse(ctx, formatSSEDone());
435
- setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
435
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
436
436
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
437
437
  } catch (err) {
438
438
  const cancelled = isClientDisconnected(ctx, err);
@@ -449,7 +449,7 @@ async function handleStreamingCompletion(
449
449
  }),
450
450
  );
451
451
  }
452
- setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
452
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
453
453
  ctx.state.aiApiStreamResult = {
454
454
  succeeded: false,
455
455
  id: completionId,
@@ -465,6 +465,24 @@ function getErrorMessage(error: unknown, fallback: string) {
465
465
  return error instanceof Error && error.message ? error.message : fallback;
466
466
  }
467
467
 
468
+ /**
469
+ * Reads the provider's finish_reason from a LangChain result or stream chunk.
470
+ * LangChain surfaces it in response_metadata.finish_reason; some adapters put it
471
+ * in additional_kwargs.finish_reason instead. Returns undefined when the provider
472
+ * reported nothing, so callers can fall back to their own default.
473
+ */
474
+ export function extractFinishReason(value: unknown): string | undefined {
475
+ if (!isRecord(value)) return undefined;
476
+ const candidates = [
477
+ isRecord(value.response_metadata) ? value.response_metadata.finish_reason : undefined,
478
+ isRecord(value.additional_kwargs) ? value.additional_kwargs.finish_reason : undefined,
479
+ ];
480
+ for (const candidate of candidates) {
481
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate;
482
+ }
483
+ return undefined;
484
+ }
485
+
468
486
  /**
469
487
  * Content block types every provider adapter in `@nocobase/plugin-ai` maps to a
470
488
  * native equivalent.
@@ -475,7 +493,7 @@ function getErrorMessage(error: unknown, fallback: string) {
475
493
  * the model answers as if the attachment was never sent. A 400 is far easier to
476
494
  * debug than a confidently wrong completion.
477
495
  */
478
- const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
496
+ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url', 'file', 'file_url']);
479
497
 
480
498
  /**
481
499
  * Deliberately mirrors the exact grammar `@langchain/core`'s `parseBase64DataUrl`
@@ -486,6 +504,14 @@ const SUPPORTED_CONTENT_BLOCK_TYPES = new Set(['text', 'image_url']);
486
504
  */
487
505
  const BASE64_DATA_URL_PATTERN = /^data:(\w+\/\w+);base64,([A-Za-z0-9+/]+=*)$/;
488
506
 
507
+ /**
508
+ * File blocks accept a wider range of MIME types than `image_url` blocks:
509
+ * documents such as `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
510
+ * or `image/svg+xml` are valid attachments. The grammar still requires a proper
511
+ * `type/subtype` and standard base64 payload.
512
+ */
513
+ const FILE_BASE64_DATA_URL_PATTERN = /^data:([^;\s]+);base64,([A-Za-z0-9+/]+=*)$/;
514
+
489
515
  /**
490
516
  * The regex above is LangChain's, and LangChain's is lenient: `A===`, `A=`,
491
517
  * `AAAAA` and `AAAA=` all match it but are not decodable base64. LangChain then
@@ -591,8 +617,8 @@ function describeContentBlockProblem(block: unknown): string | undefined {
591
617
  if (!type) return "each content block requires a 'type' field";
592
618
  if (!SUPPORTED_CONTENT_BLOCK_TYPES.has(type)) {
593
619
  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`
620
+ `content block type '${type}' is not supported — this gateway forwards 'text', 'image_url', ` +
621
+ `'file', and 'file_url' only. Send documents as text, or inline them as a 'file' / 'file_url' block`
596
622
  );
597
623
  }
598
624
 
@@ -600,9 +626,56 @@ function describeContentBlockProblem(block: unknown): string | undefined {
600
626
  return typeof block.text === 'string' ? undefined : "a 'text' block requires a string 'text' field";
601
627
  }
602
628
 
629
+ if (type === 'file') {
630
+ return describeFileProblem(block.file);
631
+ }
632
+
633
+ if (type === 'file_url') {
634
+ return describeFileUrlProblem(block.file_url);
635
+ }
636
+
603
637
  return describeImageUrlProblem(block.image_url);
604
638
  }
605
639
 
640
+ function describeFileProblem(file: unknown): string | undefined {
641
+ if (!isRecord(file)) return "a 'file' block requires an object 'file' field";
642
+ const fileData = typeof file.file_data === 'string' ? file.file_data : undefined;
643
+ if (!fileData) return "a 'file' block requires a string 'file.file_data' field";
644
+ if (!fileData.startsWith('data:')) {
645
+ return "a 'file' block's 'file_data' must be a base64 data URL starting with 'data:'";
646
+ }
647
+ const match = FILE_BASE64_DATA_URL_PATTERN.exec(fileData);
648
+ if (!match || !match[1].includes('/')) {
649
+ return (
650
+ `malformed base64 data URL. Expected 'data:<mime-type>;base64,<base64>' ` +
651
+ `with a valid type/subtype and standard base64 (no whitespace or URL-safe characters)`
652
+ );
653
+ }
654
+ if (!isDecodableBase64(match[2])) {
655
+ return (
656
+ `base64 payload is not decodable. Check the padding and length — ` +
657
+ `the data must be a multiple of 4 characters with at most two trailing '='`
658
+ );
659
+ }
660
+ return undefined;
661
+ }
662
+
663
+ function describeFileUrlProblem(fileUrl: unknown): string | undefined {
664
+ if (!isRecord(fileUrl)) return "a 'file_url' block requires an object 'file_url' field";
665
+ const url = typeof fileUrl.url === 'string' ? fileUrl.url : undefined;
666
+ if (!url) return "a 'file_url' block requires a string 'file_url.url' field";
667
+ let protocol: string;
668
+ try {
669
+ protocol = new URL(url).protocol;
670
+ } catch {
671
+ return `'${url}' is not a valid URL. Use an http(s) URL`;
672
+ }
673
+ if (protocol !== 'http:' && protocol !== 'https:') {
674
+ return `URL protocol '${protocol}' is not supported. Use an http(s) URL`;
675
+ }
676
+ return undefined;
677
+ }
678
+
606
679
  function describeImageUrlProblem(imageUrl: unknown): string | undefined {
607
680
  const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? imageUrl.url : undefined;
608
681
  if (typeof url !== 'string' || url === '') {
@@ -681,7 +754,60 @@ export function normalizeMessageContent(content: unknown): MessageContent {
681
754
  return JSON.stringify(content);
682
755
  }
683
756
 
684
- const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'tools', 'tool_choice', 'stream', 'n']);
757
+ /**
758
+ * Run any `file` or `file_url` content blocks through the plugin's file
759
+ * processor service. Custom plugins can register processors to fetch URLs,
760
+ * extract text, OCR, etc. Other block types are left untouched.
761
+ *
762
+ * The service is invoked repeatedly if a processor returns another file-like
763
+ * block (e.g. a `file_url` becomes a `file` block, which may then be converted
764
+ * to images by the PDF processor).
765
+ */
766
+ async function processMessageContentFileBlocks(
767
+ content: unknown,
768
+ ctx: Context,
769
+ plugin: PluginAiApiServer,
770
+ ): Promise<unknown> {
771
+ if (!Array.isArray(content)) return content;
772
+ const processed: unknown[] = [];
773
+ for (const block of content) {
774
+ processed.push(...(await processFileBlockChain(block, ctx, plugin, 0)));
775
+ }
776
+ return processed;
777
+ }
778
+
779
+ const MAX_FILE_PROCESSOR_CHAIN_DEPTH = 3;
780
+
781
+ async function processFileBlockChain(
782
+ block: unknown,
783
+ ctx: Context,
784
+ plugin: PluginAiApiServer,
785
+ depth: number,
786
+ ): Promise<unknown[]> {
787
+ if (!isRecord(block) || (block.type !== 'file' && block.type !== 'file_url')) {
788
+ return [block];
789
+ }
790
+ if (depth > MAX_FILE_PROCESSOR_CHAIN_DEPTH) {
791
+ return [block];
792
+ }
793
+
794
+ const result = await plugin.fileProcessorService.process(block as FileContentBlock, { ctx });
795
+ const results = Array.isArray(result) ? result : [result];
796
+
797
+ const next: unknown[] = [];
798
+ for (const item of results) {
799
+ if (isRecord(item) && (item.type === 'file' || item.type === 'file_url')) {
800
+ // The output is still a file-like block; run it through the chain again
801
+ // so that a `file_url` -> `file` -> images pipeline can complete.
802
+ next.push(...(await processFileBlockChain(item, ctx, plugin, depth + 1)));
803
+ } else {
804
+ next.push(item);
805
+ }
806
+ }
807
+ return next;
808
+ }
809
+
810
+ const GATEWAY_MANAGED_PARAMETERS = new Set(['model', 'messages', 'prompt', 'tools', 'tool_choice', 'stream', 'n']);
685
811
 
686
812
  export function getProviderRequestParameters(body: Record<string, unknown>): Record<string, unknown> {
687
813
  return Object.fromEntries(
@@ -23,6 +23,8 @@ import {
23
23
  isStreamingRequested,
24
24
  writeResponse,
25
25
  } from '../utils/streaming';
26
+ import { getProviderRequestParameters, applyProviderRequestParameters, extractFinishReason } from './chat-completions';
27
+ import { getAiApiConfig } from '../utils/request-cache';
26
28
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
27
29
  import type PluginAiApiServer from '../plugin';
28
30
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
@@ -102,7 +104,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
102
104
  }
103
105
 
104
106
  // ─── Check whitelist (global config ∩ per-user grant) ───
105
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
107
+ const config = await getAiApiConfig(ctx);
106
108
  if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
107
109
  return;
108
110
  }
@@ -133,20 +135,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
133
135
  ? body.prompt.join('\n')
134
136
  : String(body.prompt);
135
137
 
136
- // Inject system prompt from AI Employee if configured
137
- const messages: OpenAIMessage[] = [];
138
- if (config?.defaultAiEmployee) {
139
- const employee = await ctx.db.getRepository('aiEmployees').findOne({
140
- filter: { username: config.defaultAiEmployee },
141
- });
142
- if (employee) {
143
- const systemPrompt = employee.about || employee.defaultPrompt || '';
144
- if (systemPrompt) {
145
- messages.push({ role: 'system', content: systemPrompt });
146
- }
147
- }
148
- }
149
- messages.push({ role: 'user', content: prompt });
138
+ // Direct LLM mode ignores the default AI Employee: its prompt belongs to agent mode only.
139
+ const messages: OpenAIMessage[] = [{ role: 'user', content: prompt }];
150
140
 
151
141
  const preparedContext = await prepareDirectLlmContext(ctx, {
152
142
  serviceName: service.name,
@@ -169,6 +159,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
169
159
 
170
160
  const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
171
161
  const chatModel = provider.createModel();
162
+ const providerRequestParameters = getProviderRequestParameters(body);
163
+ applyProviderRequestParameters(chatModel, providerRequestParameters);
172
164
  markLlmProviderAttempted(ctx);
173
165
 
174
166
  if (stream) {
@@ -179,9 +171,17 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
179
171
  completionId,
180
172
  body.model,
181
173
  body.stream_options,
174
+ providerRequestParameters,
182
175
  );
183
176
  } else {
184
- await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
177
+ await handleNonStreamingTextCompletion(
178
+ ctx,
179
+ chatModel,
180
+ langchainMessages,
181
+ completionId,
182
+ body.model,
183
+ providerRequestParameters,
184
+ );
185
185
  }
186
186
  } catch (err) {
187
187
  ctx.log.error('AI API completions error:', err);
@@ -208,8 +208,9 @@ async function handleNonStreamingTextCompletion(
208
208
  messages: [string, string][],
209
209
  completionId: string,
210
210
  modelName: string,
211
+ providerRequestParameters: Record<string, unknown>,
211
212
  ) {
212
- const result = await chatModel.invoke(messages);
213
+ const result = await chatModel.invoke(messages, providerRequestParameters);
213
214
 
214
215
  let text = '';
215
216
  if (typeof result.content === 'string') {
@@ -219,10 +220,15 @@ async function handleNonStreamingTextCompletion(
219
220
  text = textPart?.text || JSON.stringify(result.content);
220
221
  }
221
222
 
222
- const usage = setAiApiUsageResult(ctx, result.usage_metadata, {
223
- gatewayResponseId: completionId,
224
- providerRequestId: extractProviderRequestId(result),
225
- });
223
+ const usage = setAiApiUsageResult(
224
+ ctx,
225
+ result.usage_metadata,
226
+ {
227
+ gatewayResponseId: completionId,
228
+ providerRequestId: extractProviderRequestId(result),
229
+ },
230
+ result.response_metadata,
231
+ );
226
232
 
227
233
  ctx.status = 200;
228
234
  ctx.body = {
@@ -236,10 +242,17 @@ async function handleNonStreamingTextCompletion(
236
242
  text,
237
243
  index: 0,
238
244
  logprobs: null,
239
- finish_reason: 'stop',
245
+ finish_reason: extractFinishReason(result) ?? 'stop',
240
246
  },
241
247
  ],
242
- usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
248
+ usage: usage
249
+ ? {
250
+ prompt_tokens: usage.prompt_tokens,
251
+ completion_tokens: usage.completion_tokens,
252
+ total_tokens: usage.total_tokens,
253
+ prompt_tokens_details: { cached_tokens: usage.prompt_cache_tokens ?? null },
254
+ }
255
+ : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, prompt_tokens_details: { cached_tokens: null } },
243
256
  };
244
257
  }
245
258
 
@@ -252,6 +265,7 @@ async function handleStreamingTextCompletion(
252
265
  completionId: string,
253
266
  modelName: string,
254
267
  streamOptions: Record<string, unknown> | undefined,
268
+ providerRequestParameters: Record<string, unknown> | undefined,
255
269
  ) {
256
270
  ctx.set({
257
271
  'Content-Type': 'text/event-stream',
@@ -263,9 +277,12 @@ async function handleStreamingTextCompletion(
263
277
 
264
278
  const requestAbort = createRequestAbortController(ctx);
265
279
  let usage: Usage | undefined;
280
+ let usageResponseMetadata: unknown;
266
281
  let providerRequestId: string | undefined;
282
+ let providerFinishReason: string | undefined;
267
283
  try {
268
284
  const stream = await chatModel.stream(messages, {
285
+ ...providerRequestParameters,
269
286
  stream_options: { ...streamOptions, include_usage: true },
270
287
  signal: requestAbort.signal,
271
288
  });
@@ -303,8 +320,14 @@ async function handleStreamingTextCompletion(
303
320
  );
304
321
  }
305
322
  if (chunk.usage_metadata) {
306
- usage = normalizeUsage(chunk.usage_metadata) ?? usage;
323
+ const normalized = normalizeUsage(chunk.usage_metadata);
324
+ if (normalized) {
325
+ usage = normalized;
326
+ usageResponseMetadata = chunk.response_metadata;
327
+ }
307
328
  }
329
+ const chunkFinishReason = extractFinishReason(chunk);
330
+ if (chunkFinishReason) providerFinishReason = chunkFinishReason;
308
331
  providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
309
332
  }
310
333
 
@@ -322,7 +345,7 @@ async function handleStreamingTextCompletion(
322
345
  text: '',
323
346
  index: 0,
324
347
  logprobs: null,
325
- finish_reason: 'stop',
348
+ finish_reason: providerFinishReason ?? 'stop',
326
349
  },
327
350
  ],
328
351
  usage: null,
@@ -344,7 +367,7 @@ async function handleStreamingTextCompletion(
344
367
  }
345
368
 
346
369
  await writeResponse(ctx, formatSSEDone());
347
- setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
370
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
348
371
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
349
372
  } catch (err) {
350
373
  const cancelled = isClientDisconnected(ctx, err);
@@ -360,7 +383,7 @@ async function handleStreamingTextCompletion(
360
383
  }),
361
384
  );
362
385
  }
363
- setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
386
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
364
387
  ctx.state.aiApiStreamResult = {
365
388
  succeeded: false,
366
389
  id: completionId,
@@ -11,6 +11,7 @@ import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
12
12
  import { resolveModelString } from '../utils/resolve-service';
13
13
  import { enforceModelAccess } from '../utils/user-permissions';
14
+ import { getAiApiConfig } from '../utils/request-cache';
14
15
  import { setAiApiUsageUnavailable } from '../usage';
15
16
  import type PluginAiApiServer from '../plugin';
16
17
 
@@ -118,7 +119,7 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
118
119
  // still enforced: an explicit deny must never be bypassed by an unreadable config.
119
120
  let globalEnabledServices: unknown = [];
120
121
  try {
121
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
122
+ const config = await getAiApiConfig(ctx);
122
123
  globalEnabledServices = config?.enabledLlmServices ?? [];
123
124
  } catch {
124
125
  // Config read failure: fail open on the global whitelist only.
@@ -10,6 +10,7 @@
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError } from '../utils/openai-format';
12
12
  import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
13
+ import { getAiApiConfig } from '../utils/request-cache';
13
14
  import type PluginAiApiServer from '../plugin';
14
15
 
15
16
  /**
@@ -20,8 +21,8 @@ import type PluginAiApiServer from '../plugin';
20
21
  * so clients can copy-paste the ID directly into POST /v1/chat/completions
21
22
  * without needing to configure a defaultLlmService.
22
23
  *
23
- * The catalog is scoped to the caller: a user with an aiApiUserPermissions row only
24
- * sees the intersection of the global whitelist and their own grant.
24
+ * The catalog is scoped to the caller: it starts from the global whitelist and is
25
+ * narrowed by the caller's usage group settings (allowedLlmServices / allowedModels).
25
26
  *
26
27
  * Backward compatibility: resolveModelString() in resolve-service.ts still
27
28
  * accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
@@ -251,7 +252,7 @@ function toPositiveInt(value: unknown): number | null {
251
252
  }
252
253
 
253
254
  async function getPluginConfig(ctx: Context) {
254
- return ctx.db.getRepository('aiApiConfig').findOne();
255
+ return getAiApiConfig(ctx);
255
256
  }
256
257
 
257
258
  /**
@@ -20,6 +20,7 @@ import { createRateLimitMiddleware } from '../middleware/rate-limit';
20
20
  import { checkRolePermission } from '../middleware/role-permission';
21
21
  import { startUsageRecord, finishUsageRecord } from '../usage';
22
22
  import { isStreamingRequested } from '../utils/streaming';
23
+ import { getAiApiConfig } from '../utils/request-cache';
23
24
  import type PluginAiApiServer from '../plugin';
24
25
  import { finalizeLlmBilling } from '../billing';
25
26
  import { finishAiApiObservation, startAiApiObservation } from '../utils/app-observability';
@@ -43,7 +44,7 @@ type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
43
44
  * - OPTIONS preflight handling (204)
44
45
  * - X-Request-Id on every response
45
46
  * - Bearer token authentication
46
- * - Sliding window rate limiting (enforces rateLimitPerMinute from config)
47
+ * - Sliding window rate limiting (enforces rateLimitPerMinute from the user's usage group)
47
48
  * - Structured request logging via app.logger
48
49
  *
49
50
  * Supported endpoints:
@@ -353,7 +354,7 @@ export function normalizeMaxRequestBodyMb(value: unknown): number {
353
354
  async function resolveMaxBodyBytes(ctx: Context): Promise<number> {
354
355
  let configuredMb: unknown;
355
356
  try {
356
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
357
+ const config = await getAiApiConfig(ctx);
357
358
  configuredMb = config?.get('maxRequestBodyMb');
358
359
  } catch (err) {
359
360
  ctx.log?.warn?.('AI API: could not read maxRequestBodyMb, using default:', err);
@@ -431,7 +432,7 @@ async function resolveMode(ctx: Context): Promise<'llm' | 'agent'> {
431
432
  }
432
433
 
433
434
  try {
434
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
435
+ const config = await getAiApiConfig(ctx);
435
436
  if (config) {
436
437
  const dbMode = config.get('mode') || config.mode;
437
438
  if (dbMode === 'agent' || dbMode === 'llm') {