plugin-ai-api 1.0.25 → 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.
- package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
- package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
- package/dist/client/562.44b16aad4718b4c7.js +10 -0
- package/dist/client/685.ae483e17b6b49c98.js +10 -0
- package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
- package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.3971233415999b2c.js +10 -0
- package/dist/client-v2/562.45d5c504433be38b.js +10 -0
- package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
- package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
- package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
- package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +2 -5
- package/dist/locale/en-US.json +26 -8
- package/dist/locale/vi-VN.json +26 -8
- package/dist/locale/zh-CN.json +26 -8
- package/dist/server/billing.js +25 -32
- package/dist/server/collections/ai-api-config.js +1 -7
- package/dist/server/collections/ai-api-group-members.js +62 -0
- package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
- package/dist/server/collections/ai-api-model-metadata.js +6 -0
- package/dist/server/collections/ai-api-usage-groups.js +74 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
- package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
- package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
- package/dist/server/plugin.js +90 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +0 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +32 -32
- package/dist/server/routes/completions.js +16 -19
- package/dist/server/routes/embeddings.js +2 -1
- package/dist/server/routes/models.js +2 -1
- package/dist/server/routes/router.js +3 -2
- package/dist/server/services/file-processor.js +186 -22
- package/dist/server/usage.js +5 -1
- package/dist/server/utils/direct-llm-context.js +13 -11
- package/dist/server/utils/rate-limiter.js +1 -1
- package/dist/server/utils/request-cache.js +61 -0
- package/dist/server/utils/resolve-service.js +2 -1
- package/dist/server/utils/user-permissions.js +25 -39
- package/dist/server/validation.js +7 -0
- package/dist/swagger.js +6 -7
- package/package.json +1 -1
- package/src/client/__tests__/settings-registration.test.tsx +6 -29
- package/src/client/plugin.tsx +5 -16
- package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
- package/src/client-v2/locale.ts +3 -1
- package/src/client-v2/pages/GeneralPage.tsx +0 -5
- package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
- package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +26 -8
- package/src/locale/vi-VN.json +26 -8
- package/src/locale/zh-CN.json +26 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +122 -4
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/usage-groups.test.ts +160 -0
- package/src/server/__tests__/usage-monitor.test.ts +2 -0
- package/src/server/__tests__/usage-route.test.ts +262 -2
- package/src/server/__tests__/usage.test.ts +38 -0
- package/src/server/__tests__/user-permissions.test.ts +214 -133
- package/src/server/__tests__/validation.test.ts +11 -0
- package/src/server/billing.ts +30 -38
- package/src/server/collections/ai-api-config.ts +1 -7
- package/src/server/collections/ai-api-group-members.ts +41 -0
- package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
- package/src/server/collections/ai-api-model-metadata.ts +7 -0
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/middleware/rate-limit.ts +10 -12
- package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
- package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
- package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
- package/src/server/plugin.ts +101 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +0 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +39 -36
- package/src/server/routes/completions.ts +18 -21
- package/src/server/routes/embeddings.ts +2 -1
- package/src/server/routes/models.ts +4 -3
- package/src/server/routes/router.ts +4 -3
- package/src/server/services/file-processor.ts +214 -24
- package/src/server/usage.ts +5 -1
- package/src/server/utils/direct-llm-context.ts +20 -11
- package/src/server/utils/rate-limiter.ts +1 -1
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +2 -1
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +7 -8
- package/dist/client/123.e6fe04c856ce6417.js +0 -10
- package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
- package/dist/client/562.17a0a299d2e5152c.js +0 -10
- package/dist/client/902.e74518750f1e4201.js +0 -10
- package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
- package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
- package/dist/client-v2/902.c7c00a565085438a.js +0 -10
- package/dist/server/resource/ai-api-user-permissions.js +0 -75
- package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
- package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
- package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
- 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
|
|
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 '${
|
|
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 '${
|
|
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 '${
|
|
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(
|
|
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
|
|
485
|
+
const config = await getAiApiConfig(context.ctx);
|
|
296
486
|
if (!config?.pdfRenderPagesAsImages) {
|
|
297
487
|
return block;
|
|
298
488
|
}
|
package/src/server/usage.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
317
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
378
|
+
const { turns } = splitTurns(baseMessages);
|
|
370
379
|
let remainingTurns = turns;
|
|
371
|
-
let messages = messagesWithTurns(
|
|
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(
|
|
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 !==
|
|
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
|
|
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
|
|
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) {
|