plugin-ai-api 1.0.25 → 1.1.0

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 (115) 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.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
  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.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
  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/locale/en-US.json +26 -8
  17. package/dist/locale/vi-VN.json +26 -8
  18. package/dist/locale/zh-CN.json +26 -8
  19. package/dist/server/billing.js +25 -32
  20. package/dist/server/collections/ai-api-config.js +1 -7
  21. package/dist/server/collections/ai-api-group-members.js +62 -0
  22. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  24. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  25. package/dist/server/collections/ai-api-usage-records.js +1 -0
  26. package/dist/server/middleware/rate-limit.js +7 -6
  27. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  28. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  29. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  30. package/dist/server/plugin.js +90 -22
  31. package/dist/server/quota-groups.js +108 -0
  32. package/dist/server/resource/ai-api-config.js +0 -3
  33. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  34. package/dist/server/routes/agent-completions.js +2 -1
  35. package/dist/server/routes/chat-completions.js +32 -32
  36. package/dist/server/routes/completions.js +16 -19
  37. package/dist/server/routes/embeddings.js +2 -1
  38. package/dist/server/routes/models.js +2 -1
  39. package/dist/server/routes/router.js +3 -2
  40. package/dist/server/services/file-processor.js +186 -22
  41. package/dist/server/usage.js +5 -1
  42. package/dist/server/utils/direct-llm-context.js +13 -11
  43. package/dist/server/utils/rate-limiter.js +1 -1
  44. package/dist/server/utils/request-cache.js +61 -0
  45. package/dist/server/utils/resolve-service.js +2 -1
  46. package/dist/server/utils/user-permissions.js +25 -39
  47. package/dist/server/validation.js +7 -0
  48. package/dist/swagger.js +6 -7
  49. package/package.json +1 -1
  50. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  51. package/src/client/plugin.tsx +5 -16
  52. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  53. package/src/client-v2/locale.ts +3 -1
  54. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  55. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  56. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  57. package/src/client-v2/plugin.tsx +4 -13
  58. package/src/constants.ts +0 -7
  59. package/src/locale/en-US.json +26 -8
  60. package/src/locale/vi-VN.json +26 -8
  61. package/src/locale/zh-CN.json +26 -8
  62. package/src/server/__tests__/billing-quota.test.ts +28 -9
  63. package/src/server/__tests__/direct-llm-context.test.ts +122 -4
  64. package/src/server/__tests__/file-processor.test.ts +225 -0
  65. package/src/server/__tests__/models.test.ts +1 -1
  66. package/src/server/__tests__/permission-sync.test.ts +34 -35
  67. package/src/server/__tests__/usage-groups.test.ts +160 -0
  68. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  69. package/src/server/__tests__/usage-route.test.ts +262 -2
  70. package/src/server/__tests__/usage.test.ts +38 -0
  71. package/src/server/__tests__/user-permissions.test.ts +214 -133
  72. package/src/server/__tests__/validation.test.ts +11 -0
  73. package/src/server/billing.ts +30 -38
  74. package/src/server/collections/ai-api-config.ts +1 -7
  75. package/src/server/collections/ai-api-group-members.ts +41 -0
  76. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  77. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  78. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  79. package/src/server/collections/ai-api-usage-records.ts +1 -0
  80. package/src/server/middleware/rate-limit.ts +10 -12
  81. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  82. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  83. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  84. package/src/server/plugin.ts +101 -30
  85. package/src/server/quota-groups.ts +117 -0
  86. package/src/server/resource/ai-api-config.ts +0 -3
  87. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  88. package/src/server/routes/agent-completions.ts +2 -1
  89. package/src/server/routes/chat-completions.ts +39 -36
  90. package/src/server/routes/completions.ts +18 -21
  91. package/src/server/routes/embeddings.ts +2 -1
  92. package/src/server/routes/models.ts +4 -3
  93. package/src/server/routes/router.ts +4 -3
  94. package/src/server/services/file-processor.ts +214 -24
  95. package/src/server/usage.ts +5 -1
  96. package/src/server/utils/direct-llm-context.ts +20 -11
  97. package/src/server/utils/rate-limiter.ts +1 -1
  98. package/src/server/utils/request-cache.ts +59 -0
  99. package/src/server/utils/resolve-service.ts +2 -1
  100. package/src/server/utils/user-permissions.ts +49 -69
  101. package/src/server/validation.ts +7 -0
  102. package/src/swagger.ts +7 -8
  103. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  104. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  105. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  106. package/dist/client/902.e74518750f1e4201.js +0 -10
  107. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  108. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  109. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  110. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  111. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  112. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  113. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  114. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  115. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -1,5 +1,8 @@
1
1
  import type { Context } from '@nocobase/actions';
2
+ import dns from 'dns';
3
+ import { isIP } from 'net';
2
4
  import { basename } from 'path';
5
+ import { getAiApiConfig } from '../utils/request-cache';
3
6
 
4
7
  export interface FileContentBlock {
5
8
  type: string;
@@ -29,9 +32,11 @@ export interface PdfToImageRenderer {
29
32
  export type FileProcessorErrorCode =
30
33
  | 'invalid_url'
31
34
  | 'unsupported_protocol'
35
+ | 'blocked_host'
32
36
  | 'fetch_failed'
33
37
  | 'file_too_large'
34
38
  | 'content_type_not_allowed'
39
+ | 'too_many_redirects'
35
40
  | 'missing_url';
36
41
 
37
42
  export class FileProcessorError extends Error {
@@ -47,13 +52,16 @@ export class FileProcessorError extends Error {
47
52
  export interface FetchFileOptions {
48
53
  maxSizeBytes?: number;
49
54
  timeoutMs?: number;
55
+ maxRedirects?: number;
50
56
  allowedProtocols?: string[];
51
57
  allowedContentTypes?: string[];
52
58
  }
53
59
 
54
60
  const DEFAULT_MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
55
61
  const DEFAULT_TIMEOUT_MS = 30_000;
62
+ const DEFAULT_MAX_REDIRECTS = 5;
56
63
  const ALLOWED_PROTOCOLS = new Set(['http:', 'https:']);
64
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
57
65
 
58
66
  export class FileProcessorService {
59
67
  private processors: FileProcessor[] = [];
@@ -106,12 +114,6 @@ function isRecord(value: unknown): value is Record<string, unknown> {
106
114
  return typeof value === 'object' && value !== null && !Array.isArray(value);
107
115
  }
108
116
 
109
- function getUrlString(value: unknown): string | undefined {
110
- if (typeof value === 'string') return value;
111
- if (isRecord(value) && typeof value.url === 'string') return value.url;
112
- return undefined;
113
- }
114
-
115
117
  function extractFilename(url: string, contentDisposition: string | null): string | undefined {
116
118
  if (contentDisposition) {
117
119
  const match = contentDisposition.match(/filename="?([^"]+)"?/);
@@ -126,44 +128,232 @@ function extractFilename(url: string, contentDisposition: string | null): string
126
128
  return undefined;
127
129
  }
128
130
 
131
+ function parseIpv4Octets(ip: string): number[] | undefined {
132
+ const parts = ip.split('.');
133
+ if (parts.length !== 4) return undefined;
134
+ const octets: number[] = [];
135
+ for (const part of parts) {
136
+ if (!/^\d{1,3}$/.test(part)) return undefined;
137
+ const value = Number(part);
138
+ if (value > 255) return undefined;
139
+ octets.push(value);
140
+ }
141
+ return octets;
142
+ }
143
+
144
+ function isBlockedIpv4(ip: string): boolean {
145
+ const octets = parseIpv4Octets(ip);
146
+ if (!octets) return true;
147
+ const [a, b] = octets;
148
+ if (a === 0) return true; // 0.0.0.0/8 "this" network
149
+ if (a === 10) return true; // 10.0.0.0/8 private
150
+ if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 carrier-grade NAT
151
+ if (a === 127) return true; // 127.0.0.0/8 loopback
152
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local (cloud metadata)
153
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 private
154
+ if (a === 192 && b === 0) return true; // 192.0.0.0/24 IETF protocol assignments
155
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16 private
156
+ if (a === 198 && (b === 18 || b === 19)) return true; // 198.18.0.0/15 benchmarking
157
+ if (a >= 224) return true; // multicast, reserved, broadcast
158
+ return false;
159
+ }
160
+
161
+ function parseHexGroup(group: string): number | undefined {
162
+ if (group.length < 1 || group.length > 4 || !/^[0-9a-fA-F]+$/.test(group)) return undefined;
163
+ return parseInt(group, 16);
164
+ }
165
+
166
+ function expandIpv6(input: string): number[] | undefined {
167
+ let address = input;
168
+ const zoneIndex = address.indexOf('%');
169
+ if (zoneIndex !== -1) address = address.slice(0, zoneIndex);
170
+ if (!address) return undefined;
171
+
172
+ // Rewrite an embedded IPv4 suffix (e.g. ::ffff:127.0.0.1) as two hex groups.
173
+ const lastColon = address.lastIndexOf(':');
174
+ if (lastColon !== -1 && address.includes('.', lastColon)) {
175
+ const octets = parseIpv4Octets(address.slice(lastColon + 1));
176
+ if (!octets) return undefined;
177
+ const high = ((octets[0] << 8) | octets[1]).toString(16);
178
+ const low = ((octets[2] << 8) | octets[3]).toString(16);
179
+ address = `${address.slice(0, lastColon + 1)}${high}:${low}`;
180
+ }
181
+
182
+ const groups: number[] = [];
183
+ const doubleColonIndex = address.indexOf('::');
184
+ if (doubleColonIndex !== -1) {
185
+ if (address.indexOf('::', doubleColonIndex + 1) !== -1) return undefined;
186
+ const head = address.slice(0, doubleColonIndex);
187
+ const tail = address.slice(doubleColonIndex + 2);
188
+ const headGroups = head === '' ? [] : head.split(':');
189
+ const tailGroups = tail === '' ? [] : tail.split(':');
190
+ const fillCount = 8 - headGroups.length - tailGroups.length;
191
+ if (fillCount < 1) return undefined;
192
+ const allGroups = [...headGroups];
193
+ for (let i = 0; i < fillCount; i += 1) allGroups.push('0');
194
+ allGroups.push(...tailGroups);
195
+ for (const group of allGroups) {
196
+ const parsed = parseHexGroup(group);
197
+ if (parsed === undefined) return undefined;
198
+ groups.push(parsed);
199
+ }
200
+ } else {
201
+ for (const group of address.split(':')) {
202
+ const parsed = parseHexGroup(group);
203
+ if (parsed === undefined) return undefined;
204
+ groups.push(parsed);
205
+ }
206
+ }
207
+
208
+ return groups.length === 8 ? groups : undefined;
209
+ }
210
+
211
+ function isBlockedIpv6(ip: string): boolean {
212
+ const groups = expandIpv6(ip);
213
+ if (!groups) return true;
214
+ const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
215
+
216
+ if (groups.every((group) => group === 0)) return true; // :: unspecified
217
+ if (g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0 && g6 === 0 && g7 === 1) {
218
+ return true; // ::1 loopback
219
+ }
220
+
221
+ // Embedded IPv4 forms: IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d),
222
+ // and NAT64 (64:ff9b::a.b.c.d) — judge by the embedded IPv4 address.
223
+ const isIpv4Mapped = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0xffff;
224
+ const isIpv4Compatible = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0;
225
+ const isNat64 = g0 === 0x64 && g1 === 0xff9b && g2 === 0 && g3 === 0 && g4 === 0 && g5 === 0;
226
+ if (isIpv4Mapped || isIpv4Compatible || isNat64) {
227
+ const embedded = `${(g6 >> 8) & 0xff}.${g6 & 0xff}.${(g7 >> 8) & 0xff}.${g7 & 0xff}`;
228
+ return isBlockedIpv4(embedded);
229
+ }
230
+
231
+ if ((g0 & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
232
+ if ((g0 & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
233
+ if ((g0 & 0xff00) === 0xff00) return true; // ff00::/8 multicast
234
+ return false;
235
+ }
236
+
237
+ /**
238
+ * Returns true when an IP literal must not be fetched: private, loopback,
239
+ * link-local, and other reserved ranges for both IPv4 and IPv6 (including IPv6
240
+ * forms that embed an IPv4 address). Unparseable input is blocked defensively.
241
+ */
242
+ export function isBlockedAddress(ip: string): boolean {
243
+ const version = isIP(ip);
244
+ if (version === 4) return isBlockedIpv4(ip);
245
+ if (version === 6) return isBlockedIpv6(ip);
246
+ return true;
247
+ }
248
+
249
+ async function assertHostAllowed(hostname: string): Promise<void> {
250
+ if (isIP(hostname) !== 0) {
251
+ if (isBlockedAddress(hostname)) {
252
+ throw new FileProcessorError('blocked_host', `Host '${hostname}' is a blocked address.`);
253
+ }
254
+ return;
255
+ }
256
+
257
+ let addresses: Array<{ address: string }>;
258
+ try {
259
+ addresses = await dns.promises.lookup(hostname, { all: true });
260
+ } catch (error) {
261
+ const message = error instanceof Error && error.message ? error.message : String(error);
262
+ throw new FileProcessorError('fetch_failed', `Could not resolve host '${hostname}': ${message}`);
263
+ }
264
+
265
+ if (!addresses.length) {
266
+ throw new FileProcessorError('fetch_failed', `Could not resolve host '${hostname}'.`);
267
+ }
268
+
269
+ for (const { address } of addresses) {
270
+ if (isBlockedAddress(address)) {
271
+ throw new FileProcessorError('blocked_host', `Host '${hostname}' resolves to blocked address '${address}'.`);
272
+ }
273
+ }
274
+ }
275
+
276
+ async function fetchWithSsrfGuard(
277
+ initialUrl: string,
278
+ options: { allowedProtocols: Set<string>; maxRedirects: number; signal: AbortSignal },
279
+ ): Promise<{ response: Response; finalUrl: string }> {
280
+ let currentUrl = initialUrl;
281
+ for (let hop = 0; ; hop += 1) {
282
+ let parsed: URL;
283
+ try {
284
+ parsed = new URL(currentUrl);
285
+ } catch {
286
+ throw new FileProcessorError('invalid_url', `File URL '${currentUrl}' is not a valid URL.`);
287
+ }
288
+
289
+ if (!options.allowedProtocols.has(parsed.protocol)) {
290
+ throw new FileProcessorError('unsupported_protocol', `File URL protocol '${parsed.protocol}' is not allowed.`);
291
+ }
292
+
293
+ // SSRF guard: resolve and validate the host before connecting. A malicious DNS
294
+ // server could still rebind between this check and the fetch; closing that gap
295
+ // fully would require pinning the resolved address into the connection.
296
+ const hostname =
297
+ parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']') ? parsed.hostname.slice(1, -1) : parsed.hostname;
298
+ await assertHostAllowed(hostname);
299
+
300
+ const response = await fetch(currentUrl, { signal: options.signal, redirect: 'manual' });
301
+ if (!REDIRECT_STATUSES.has(response.status)) {
302
+ return { response, finalUrl: currentUrl };
303
+ }
304
+
305
+ if (hop >= options.maxRedirects) {
306
+ throw new FileProcessorError(
307
+ 'too_many_redirects',
308
+ `File URL '${initialUrl}' exceeded the limit of ${options.maxRedirects} redirects.`,
309
+ );
310
+ }
311
+
312
+ const location = response.headers.get('location');
313
+ if (!location) {
314
+ throw new FileProcessorError('fetch_failed', `Redirect from '${currentUrl}' is missing a Location header.`);
315
+ }
316
+
317
+ try {
318
+ currentUrl = new URL(location, currentUrl).toString();
319
+ } catch {
320
+ throw new FileProcessorError(
321
+ 'invalid_url',
322
+ `Redirect Location '${location}' from '${currentUrl}' is not a valid URL.`,
323
+ );
324
+ }
325
+ }
326
+ }
327
+
129
328
  export async function fetchFileAsBase64(
130
329
  url: string,
131
330
  options: FetchFileOptions = {},
132
331
  ): Promise<{ fileData: string; mimeType: string | undefined; filename: string | undefined }> {
133
332
  const maxSize = options.maxSizeBytes ?? DEFAULT_MAX_FILE_SIZE;
134
333
  const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
334
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
135
335
  const allowedProtocols = options.allowedProtocols ? new Set(options.allowedProtocols) : ALLOWED_PROTOCOLS;
136
336
 
137
- let protocol: string;
138
- try {
139
- protocol = new URL(url).protocol;
140
- } catch {
141
- throw new FileProcessorError('invalid_url', `File URL '${url}' is not a valid URL.`);
142
- }
143
-
144
- if (!allowedProtocols.has(protocol)) {
145
- throw new FileProcessorError('unsupported_protocol', `File URL protocol '${protocol}' is not allowed.`);
146
- }
147
-
148
337
  const controller = new AbortController();
149
338
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
150
339
 
151
340
  try {
152
- const response = await fetch(url, {
341
+ const { response, finalUrl } = await fetchWithSsrfGuard(url, {
342
+ allowedProtocols,
343
+ maxRedirects,
153
344
  signal: controller.signal,
154
- redirect: 'follow',
155
345
  });
156
346
 
157
347
  if (!response.ok) {
158
348
  throw new FileProcessorError(
159
349
  'fetch_failed',
160
- `Failed to fetch file from '${url}': ${response.status} ${response.statusText}`,
350
+ `Failed to fetch file from '${finalUrl}': ${response.status} ${response.statusText}`,
161
351
  );
162
352
  }
163
353
 
164
354
  const contentLength = response.headers.get('content-length');
165
355
  if (contentLength && Number(contentLength) > maxSize) {
166
- throw new FileProcessorError('file_too_large', `File at '${url}' exceeds maximum allowed size.`);
356
+ throw new FileProcessorError('file_too_large', `File at '${finalUrl}' exceeds maximum allowed size.`);
167
357
  }
168
358
 
169
359
  const contentType = response.headers.get('content-type') || undefined;
@@ -177,12 +367,12 @@ export async function fetchFileAsBase64(
177
367
 
178
368
  const buffer = Buffer.from(await response.arrayBuffer());
179
369
  if (buffer.length > maxSize) {
180
- throw new FileProcessorError('file_too_large', `File at '${url}' exceeds maximum allowed size.`);
370
+ throw new FileProcessorError('file_too_large', `File at '${finalUrl}' exceeds maximum allowed size.`);
181
371
  }
182
372
 
183
373
  const mimeType = contentType?.split(';')[0].trim() ?? 'application/octet-stream';
184
374
  const contentDisposition = response.headers.get('content-disposition');
185
- const filename = extractFilename(url, contentDisposition) ?? 'file';
375
+ const filename = extractFilename(finalUrl, contentDisposition) ?? 'file';
186
376
 
187
377
  return {
188
378
  fileData: `data:${mimeType};base64,${buffer.toString('base64')}`,
@@ -292,7 +482,7 @@ export const pdfFileProcessor: FileProcessor = {
292
482
  block: FileContentBlock,
293
483
  context: FileProcessorContext,
294
484
  ): Promise<FileContentBlock | FileContentBlock[]> {
295
- const config = await context.ctx.db.getRepository('aiApiConfig').findOne();
485
+ const config = await getAiApiConfig(context.ctx);
296
486
  if (!config?.pdfRenderPagesAsImages) {
297
487
  return block;
298
488
  }
@@ -93,7 +93,10 @@ export function normalizeUsage(value: unknown): Usage | undefined {
93
93
  prompt_tokens: prompt,
94
94
  completion_tokens: completion,
95
95
  total_tokens: total,
96
- prompt_cache_tokens: extractPromptCacheTokens(source),
96
+ // Keep normalizeUsage idempotent: streaming routes normalize the chunk
97
+ // usage once and hand the result to setAiApiUsageResult, which normalizes
98
+ // again — an already-extracted prompt_cache_tokens must survive that pass.
99
+ prompt_cache_tokens: extractPromptCacheTokens(source) ?? normalizeTokenCount(source.prompt_cache_tokens),
97
100
  };
98
101
  }
99
102
 
@@ -228,6 +231,7 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
228
231
  costStatus: billing.costStatus ?? null,
229
232
  modelPriceId: billing.modelPriceId ?? null,
230
233
  quotaPolicyId: billing.quotaPolicyId ?? null,
234
+ groupId: billing.groupId ?? null,
231
235
  inputPricePerMillionTokens: billing.inputPricePerMillionTokens ?? null,
232
236
  outputPricePerMillionTokens: billing.outputPricePerMillionTokens ?? null,
233
237
  fixedCostPerRequest: billing.fixedCostPerRequest ?? null,
@@ -1,4 +1,5 @@
1
1
  import type { Context } from '@nocobase/actions';
2
+ import { resolveRequestUserGroup } from './request-cache';
2
3
 
3
4
  export type ContextOverflowBehavior = 'reject' | 'truncate';
4
5
 
@@ -13,6 +14,7 @@ export interface OpenAIMessage {
13
14
  interface ModelMetadata {
14
15
  contextWindow: number;
15
16
  maxCompletionTokens: number;
17
+ systemPrompt?: string;
16
18
  }
17
19
 
18
20
  interface ContextPreparationOptions {
@@ -307,16 +309,16 @@ async function loadModelMetadata(ctx: Context, serviceName: string, modelId: str
307
309
  `Context metadata is not configured for '${serviceName}/${modelId}'. Configure context window and max completion tokens.`,
308
310
  );
309
311
  }
310
- return { contextWindow, maxCompletionTokens };
312
+ const systemPromptValue = getValue<unknown>(row, 'systemPrompt');
313
+ const systemPrompt = typeof systemPromptValue === 'string' ? systemPromptValue.trim() : '';
314
+ return { contextWindow, maxCompletionTokens, ...(systemPrompt ? { systemPrompt } : {}) };
311
315
  }
312
316
 
313
317
  async function resolveOverflowBehavior(ctx: Context): Promise<ContextOverflowBehavior> {
314
318
  const userId = ctx.state.currentUser?.id;
315
319
  if (userId === null || userId === undefined) return 'reject';
316
- const policy = await ctx.db.getRepository('aiApiUserQuotaPolicies').findOne({
317
- filter: { userId, enabled: true },
318
- });
319
- return getValue<unknown>(policy, 'contextOverflowBehavior') === 'truncate' ? 'truncate' : 'reject';
320
+ const group = await resolveRequestUserGroup(ctx, userId);
321
+ return group.contextOverflowBehavior === 'truncate' ? 'truncate' : 'reject';
320
322
  }
321
323
 
322
324
  function resolveReservedOutputTokens(options: ContextPreparationOptions, metadata: ModelMetadata): number {
@@ -348,10 +350,17 @@ export async function prepareDirectLlmContext(
348
350
  }
349
351
 
350
352
  const fixedOverheadTokens = estimateValueTokens(options.tools) + (options.tools === undefined ? 0 : 4);
351
- const originalEstimate = estimateMessagesTokens(options.messages) + fixedOverheadTokens;
353
+ // The initial system prompt from model metadata is prepended before every
354
+ // client message — including the client's own system prompt, which is kept.
355
+ // Being a system message, it is a fixed instruction that truncation never
356
+ // drops, and it counts toward the input budget.
357
+ const baseMessages: OpenAIMessage[] = metadata.systemPrompt
358
+ ? [{ role: 'system', content: metadata.systemPrompt }, ...options.messages]
359
+ : options.messages;
360
+ const originalEstimate = estimateMessagesTokens(baseMessages) + fixedOverheadTokens;
352
361
  if (originalEstimate <= inputTokenBudget) {
353
362
  return {
354
- messages: options.messages,
363
+ messages: baseMessages,
355
364
  estimatedInputTokens: originalEstimate,
356
365
  inputTokenBudget,
357
366
  reservedOutputTokens,
@@ -366,14 +375,14 @@ export async function prepareDirectLlmContext(
366
375
  );
367
376
  }
368
377
 
369
- const { turns } = splitTurns(options.messages);
378
+ const { turns } = splitTurns(baseMessages);
370
379
  let remainingTurns = turns;
371
- let messages = messagesWithTurns(options.messages, remainingTurns);
380
+ let messages = messagesWithTurns(baseMessages, remainingTurns);
372
381
  let estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
373
382
 
374
383
  while (remainingTurns.length > 1 && estimatedInputTokens > inputTokenBudget) {
375
384
  remainingTurns = remainingTurns.slice(1);
376
- messages = messagesWithTurns(options.messages, remainingTurns);
385
+ messages = messagesWithTurns(baseMessages, remainingTurns);
377
386
  estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
378
387
  }
379
388
 
@@ -389,6 +398,6 @@ export async function prepareDirectLlmContext(
389
398
  estimatedInputTokens,
390
399
  inputTokenBudget,
391
400
  reservedOutputTokens,
392
- truncated: messages.length !== options.messages.length,
401
+ truncated: messages.length !== baseMessages.length,
393
402
  };
394
403
  }
@@ -26,7 +26,7 @@ export class RateLimiter {
26
26
  * Check and record a request for a user.
27
27
  *
28
28
  * @param userId The user ID (string or numeric)
29
- * @param limit Max allowed requests per window (from aiApiConfig.rateLimitPerMinute)
29
+ * @param limit Max allowed requests per window (from the user's usage group)
30
30
  * @returns { allowed: true } or { allowed: false, retryAfterMs: number }
31
31
  */
32
32
  check(userId: string | number, limit: number): { allowed: true } | { allowed: false; retryAfterMs: number } {
@@ -0,0 +1,59 @@
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 type { Context } from '@nocobase/actions';
11
+ import type { Model } from '@nocobase/database';
12
+ import { resolveUserGroup, type AiApiUsageGroup } from '../quota-groups';
13
+
14
+ interface AiApiRequestCache {
15
+ configLoaded: boolean;
16
+ config: Model | null;
17
+ groupKey?: string;
18
+ group?: AiApiUsageGroup;
19
+ }
20
+
21
+ function getCache(ctx: Context): AiApiRequestCache {
22
+ if (!ctx.state.aiApiRequestCache) {
23
+ ctx.state.aiApiRequestCache = { configLoaded: false, config: null };
24
+ }
25
+ return ctx.state.aiApiRequestCache;
26
+ }
27
+
28
+ /**
29
+ * Returns the aiApiConfig row, reading it at most once per request. The same row
30
+ * is consulted by the body limit, mode resolution, model whitelist, quota and
31
+ * billing steps of a single gateway request, so caching it on ctx.state removes
32
+ * several duplicate queries per request.
33
+ */
34
+ export async function getAiApiConfig(ctx: Context): Promise<Model | null> {
35
+ const cache = getCache(ctx);
36
+ if (!cache.configLoaded) {
37
+ cache.config = (await ctx.db.getRepository('aiApiConfig').findOne()) ?? null;
38
+ cache.configLoaded = true;
39
+ }
40
+ return cache.config;
41
+ }
42
+
43
+ /**
44
+ * Returns the caller's usage group, resolving it at most once per request per
45
+ * user id. Wraps resolveUserGroup, which otherwise runs membership queries on
46
+ * every call site (rate limiting, permissions, billing, context overflow).
47
+ */
48
+ export async function resolveRequestUserGroup(
49
+ ctx: Context,
50
+ userId: string | number | bigint | undefined | null,
51
+ ): Promise<AiApiUsageGroup> {
52
+ const cache = getCache(ctx);
53
+ const groupKey = userId === undefined || userId === null ? '' : String(userId);
54
+ if (!cache.group || cache.groupKey !== groupKey) {
55
+ cache.group = await resolveUserGroup(ctx, userId);
56
+ cache.groupKey = groupKey;
57
+ }
58
+ return cache.group;
59
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { Context } from '@nocobase/actions';
11
+ import { getAiApiConfig } from './request-cache';
11
12
 
12
13
  /**
13
14
  * Resolve an LLM service by name or title.
@@ -64,7 +65,7 @@ export async function resolveModelString(
64
65
  }
65
66
 
66
67
  // ─── Strategy 2: Use default LLM service from config ───
67
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
68
+ const config = await getAiApiConfig(ctx);
68
69
  if (config?.defaultLlmService) {
69
70
  const service = await repo.findOne({ filter: { name: config.defaultLlmService } });
70
71
  if (service) {