plugin-ai-api 1.0.23 → 1.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/client/757.56952e321dc399b7.js +10 -0
  2. package/dist/client/902.e74518750f1e4201.js +10 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
  5. package/dist/client-v2/902.c7c00a565085438a.js +10 -0
  6. package/dist/client-v2/index.js +1 -1
  7. package/dist/externalVersion.js +8 -8
  8. package/dist/locale/en-US.json +4 -0
  9. package/dist/locale/vi-VN.json +4 -0
  10. package/dist/locale/zh-CN.json +4 -0
  11. package/dist/server/billing.js +6 -1
  12. package/dist/server/collections/ai-api-config.js +6 -0
  13. package/dist/server/collections/ai-api-usage-records.js +1 -0
  14. package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
  15. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  16. package/dist/server/plugin.js +10 -0
  17. package/dist/server/resource/ai-api-config.js +5 -0
  18. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  19. package/dist/server/routes/chat-completions.js +110 -19
  20. package/dist/server/routes/completions.js +59 -24
  21. package/dist/server/services/file-processor.js +262 -0
  22. package/dist/server/usage.js +33 -3
  23. package/dist/server/utils/direct-llm-context.js +319 -0
  24. package/dist/server/utils/openai-format.js +21 -2
  25. package/dist/server/validation.js +3 -0
  26. package/dist/swagger.js +42 -3
  27. package/package.json +1 -1
  28. package/src/client-v2/pages/UsagePage.tsx +9 -0
  29. package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
  30. package/src/locale/en-US.json +4 -0
  31. package/src/locale/vi-VN.json +4 -0
  32. package/src/locale/zh-CN.json +4 -0
  33. package/src/server/__tests__/direct-llm-context.test.ts +206 -0
  34. package/src/server/__tests__/openai-format.test.ts +12 -2
  35. package/src/server/__tests__/request-body.test.ts +45 -2
  36. package/src/server/__tests__/usage-route.test.ts +173 -9
  37. package/src/server/__tests__/usage.test.ts +19 -0
  38. package/src/server/__tests__/validation.test.ts +36 -0
  39. package/src/server/billing.ts +6 -1
  40. package/src/server/collections/ai-api-config.ts +8 -0
  41. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  42. package/src/server/collections/ai-api-usage-records.ts +1 -0
  43. package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
  44. package/src/server/index.ts +10 -10
  45. package/src/server/middleware/rate-limit.ts +70 -70
  46. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  47. package/src/server/plugin.ts +20 -0
  48. package/src/server/resource/ai-api-config.ts +5 -0
  49. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  50. package/src/server/routes/chat-completions.ts +157 -22
  51. package/src/server/routes/completions.ts +61 -23
  52. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  53. package/src/server/services/file-processor.ts +323 -0
  54. package/src/server/usage.ts +47 -1
  55. package/src/server/utils/direct-llm-context.ts +394 -0
  56. package/src/server/utils/openai-format.ts +25 -2
  57. package/src/server/utils/rate-limiter.ts +83 -83
  58. package/src/server/utils/resolve-service.ts +82 -82
  59. package/src/server/validation.ts +3 -0
  60. package/src/swagger.ts +45 -3
  61. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  62. package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
  63. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  64. package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
@@ -0,0 +1,394 @@
1
+ import type { Context } from '@nocobase/actions';
2
+
3
+ export type ContextOverflowBehavior = 'reject' | 'truncate';
4
+
5
+ export interface OpenAIMessage {
6
+ role: string;
7
+ content?: unknown;
8
+ tool_calls?: unknown;
9
+ tool_call_id?: string;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ interface ModelMetadata {
14
+ contextWindow: number;
15
+ maxCompletionTokens: number;
16
+ }
17
+
18
+ interface ContextPreparationOptions {
19
+ serviceName: string;
20
+ modelId: string;
21
+ messages: OpenAIMessage[];
22
+ tools?: unknown;
23
+ maxCompletionTokens?: unknown;
24
+ maxTokens?: unknown;
25
+ }
26
+
27
+ export interface PreparedDirectLlmContext {
28
+ messages: OpenAIMessage[];
29
+ estimatedInputTokens: number;
30
+ inputTokenBudget: number;
31
+ reservedOutputTokens: number;
32
+ truncated: boolean;
33
+ }
34
+
35
+ export class DirectLlmContextError extends Error {
36
+ constructor(
37
+ readonly code:
38
+ | 'context_length_exceeded'
39
+ | 'context_estimation_unsupported'
40
+ | 'max_completion_tokens_exceeds_model_limit'
41
+ | 'model_context_metadata_not_configured',
42
+ message: string,
43
+ ) {
44
+ super(message);
45
+ this.name = 'DirectLlmContextError';
46
+ }
47
+ }
48
+
49
+ function getValue<T>(
50
+ record: { get?: (key: string) => unknown; [key: string]: unknown } | null,
51
+ key: string,
52
+ ): T | undefined {
53
+ return (record?.get?.(key) as T | undefined) ?? (record?.[key] as T | undefined);
54
+ }
55
+
56
+ function positiveInteger(value: unknown): number | undefined {
57
+ const parsed = Number(value);
58
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
59
+ }
60
+
61
+ function estimateValueTokens(value: unknown): number {
62
+ if (value === undefined) return 0;
63
+ return Math.ceil(Buffer.byteLength(JSON.stringify(value), 'utf8') / 3);
64
+ }
65
+
66
+ function isRecord(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ // ─── Image dimension parsing ───
71
+
72
+ interface ImageDimensions {
73
+ width: number;
74
+ height: number;
75
+ }
76
+
77
+ function readBigEndian(buf: Buffer, offset: number): number {
78
+ return buf.readUInt32BE(offset);
79
+ }
80
+
81
+ function readUInt16BE(buf: Buffer, offset: number): number {
82
+ return buf.readUInt16BE(offset);
83
+ }
84
+
85
+ function readLittleEndian(buf: Buffer, offset: number): number {
86
+ return buf.readUInt16LE(offset);
87
+ }
88
+
89
+ function parsePngDimensions(buffer: Buffer): ImageDimensions | undefined {
90
+ if (buffer.length < 24) return undefined;
91
+ return { width: readBigEndian(buffer, 16), height: readBigEndian(buffer, 20) };
92
+ }
93
+
94
+ function parseJpegDimensions(buffer: Buffer): ImageDimensions | undefined {
95
+ let offset = 2; // skip SOI
96
+ while (offset < buffer.length) {
97
+ if (buffer[offset] !== 0xff) {
98
+ offset++;
99
+ continue;
100
+ }
101
+ const marker = buffer[offset + 1];
102
+ // SOF0, SOF1, SOF2, SOF3, SOF5, SOF6, SOF7, SOF9, SOF10, SOF11, SOF13, SOF14, SOF15
103
+ if (
104
+ (marker >= 0xc0 && marker <= 0xc3) ||
105
+ (marker >= 0xc5 && marker <= 0xc7) ||
106
+ (marker >= 0xc9 && marker <= 0xcb) ||
107
+ (marker >= 0xcd && marker <= 0xcf)
108
+ ) {
109
+ if (offset + 9 <= buffer.length) {
110
+ return { height: readUInt16BE(buffer, offset + 5), width: readUInt16BE(buffer, offset + 7) };
111
+ }
112
+ return undefined;
113
+ }
114
+ if (marker === 0xd9 || offset + 4 >= buffer.length) break;
115
+ const segmentLength = buffer.readUInt16BE(offset + 2);
116
+ offset += 2 + segmentLength;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function parseGifDimensions(buffer: Buffer): ImageDimensions | undefined {
122
+ if (buffer.length < 10) return undefined;
123
+ return { width: readLittleEndian(buffer, 6), height: readLittleEndian(buffer, 8) };
124
+ }
125
+
126
+ function parseWebpDimensions(buffer: Buffer): ImageDimensions | undefined {
127
+ if (buffer.length < 30) return undefined;
128
+ const riff = buffer.toString('ascii', 0, 4);
129
+ const webp = buffer.toString('ascii', 8, 12);
130
+ if (riff !== 'RIFF' || webp !== 'WEBP') return undefined;
131
+
132
+ const chunkType = buffer.toString('ascii', 12, 16);
133
+ if (chunkType === 'VP8 ' && buffer.length >= 26) {
134
+ // Simple lossy WebP
135
+ return { width: readLittleEndian(buffer, 26), height: readLittleEndian(buffer, 28) };
136
+ }
137
+ if (chunkType === 'VP8L' && buffer.length >= 24) {
138
+ // Lossless WebP: dimensions packed into 32 bits at offset 21
139
+ const bits = buffer.readUInt32LE(21);
140
+ return {
141
+ width: (bits & 0x3fff) + 1,
142
+ height: ((bits >> 14) & 0x3fff) + 1,
143
+ };
144
+ }
145
+ if (chunkType === 'VP8X' && buffer.length >= 30) {
146
+ // Extended WebP: width/height at offset 24, 27
147
+ return {
148
+ width: ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xffffff) + 1,
149
+ height: ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xffffff) + 1,
150
+ };
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ export function parseImageDimensions(buffer: Buffer): ImageDimensions | undefined {
156
+ if (buffer.length < 12) return undefined;
157
+ // Use 'binary' (latin1) so high bytes such as PNG's 0x89 are preserved.
158
+ const header = buffer.toString('binary', 0, 4);
159
+ if (header === '\x89PNG') return parsePngDimensions(buffer);
160
+ if (header === 'GIF8') return parseGifDimensions(buffer);
161
+ if (header === 'RIFF') return parseWebpDimensions(buffer);
162
+ if (buffer[0] === 0xff && buffer[1] === 0xd8) return parseJpegDimensions(buffer);
163
+ return undefined;
164
+ }
165
+
166
+ // ─── Vision token estimation ───
167
+
168
+ const VISION_TILE_SIZE = 512;
169
+ const VISION_LOW_DETAIL_TOKENS = 85;
170
+ const VISION_TILE_TOKENS = 170;
171
+ const VISION_HTTP_URL_ESTIMATE = 1024;
172
+ const FILE_BASE64_FALLBACK_TOKENS = 1024;
173
+
174
+ function estimateVisionTokensForDimensions(width: number, height: number): number {
175
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
176
+ return VISION_LOW_DETAIL_TOKENS;
177
+ }
178
+ const tilesX = Math.ceil(width / VISION_TILE_SIZE);
179
+ const tilesY = Math.ceil(height / VISION_TILE_SIZE);
180
+ return VISION_LOW_DETAIL_TOKENS + tilesX * tilesY * VISION_TILE_TOKENS;
181
+ }
182
+
183
+ function decodeBase64DataUrl(url: string): { mimeType: string; buffer: Buffer } | undefined {
184
+ const match = /^data:([^;]+);base64,([A-Za-z0-9+/]+=?=?)$/.exec(url);
185
+ if (!match) return undefined;
186
+ try {
187
+ const buffer = Buffer.from(match[2], 'base64');
188
+ return { mimeType: match[1].toLowerCase(), buffer };
189
+ } catch {
190
+ return undefined;
191
+ }
192
+ }
193
+
194
+ function estimateImageUrlTokens(imageUrl: unknown): number {
195
+ const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? String(imageUrl.url ?? '') : '';
196
+ if (!url) return 0;
197
+
198
+ if (url.startsWith('data:')) {
199
+ const decoded = decodeBase64DataUrl(url);
200
+ if (!decoded) return FILE_BASE64_FALLBACK_TOKENS;
201
+ if (!decoded.mimeType.startsWith('image/')) return FILE_BASE64_FALLBACK_TOKENS;
202
+ const dimensions = parseImageDimensions(decoded.buffer);
203
+ return dimensions
204
+ ? estimateVisionTokensForDimensions(dimensions.width, dimensions.height)
205
+ : VISION_LOW_DETAIL_TOKENS;
206
+ }
207
+
208
+ if (url.startsWith('http://') || url.startsWith('https://')) {
209
+ // We cannot fetch here because context enforcement runs before any
210
+ // network I/O. Use a conservative fixed estimate.
211
+ return VISION_HTTP_URL_ESTIMATE;
212
+ }
213
+
214
+ return 0;
215
+ }
216
+
217
+ function estimateFileBlockTokens(block: Record<string, unknown>): number {
218
+ const file = isRecord(block.file) ? block.file : undefined;
219
+ if (!file) return 0;
220
+
221
+ const fileData = String(file.file_data ?? '');
222
+ if (fileData.startsWith('data:')) {
223
+ const decoded = decodeBase64DataUrl(fileData);
224
+ if (decoded) {
225
+ // Conservative upper-bound: one token per ~3 bytes of decoded binary.
226
+ return Math.max(1, Math.ceil(decoded.buffer.length / 3));
227
+ }
228
+ // Fallback when the data URL is malformed or cannot be decoded.
229
+ // The request is still forwarded; the provider will reject it if invalid.
230
+ return FILE_BASE64_FALLBACK_TOKENS;
231
+ }
232
+
233
+ return FILE_BASE64_FALLBACK_TOKENS;
234
+ }
235
+
236
+ function estimateContentBlockTokens(block: unknown): number {
237
+ if (!isRecord(block)) return estimateValueTokens(block);
238
+
239
+ const type = typeof block.type === 'string' ? block.type : undefined;
240
+ if (type === 'text') {
241
+ return typeof block.text === 'string' ? estimateValueTokens(block.text) + 4 : 4;
242
+ }
243
+ if (type === 'image_url') {
244
+ return estimateImageUrlTokens(block.image_url) + 4;
245
+ }
246
+ if (type === 'file') {
247
+ return estimateFileBlockTokens(block) + 4;
248
+ }
249
+ if (type === 'file_url') {
250
+ // Will be converted to a `file` block before reaching the model; use a
251
+ // conservative placeholder until processing runs.
252
+ return VISION_HTTP_URL_ESTIMATE + 4;
253
+ }
254
+ return estimateValueTokens(block) + 4;
255
+ }
256
+
257
+ function estimateMessageTokens(message: OpenAIMessage): number {
258
+ if (Array.isArray(message.content)) {
259
+ return message.content.reduce((total, block) => total + estimateContentBlockTokens(block), 4);
260
+ }
261
+ return estimateValueTokens(message) + 4;
262
+ }
263
+
264
+ function estimateMessagesTokens(messages: OpenAIMessage[]): number {
265
+ return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
266
+ }
267
+
268
+ function isInstruction(message: OpenAIMessage): boolean {
269
+ return message.role === 'system' || message.role === 'developer';
270
+ }
271
+
272
+ function splitTurns(messages: OpenAIMessage[]): { fixed: OpenAIMessage[]; turns: OpenAIMessage[][] } {
273
+ const fixed: OpenAIMessage[] = [];
274
+ const turns: OpenAIMessage[][] = [];
275
+ let currentTurn: OpenAIMessage[] | undefined;
276
+
277
+ for (const message of messages) {
278
+ if (isInstruction(message)) {
279
+ fixed.push(message);
280
+ continue;
281
+ }
282
+ if (message.role === 'user' || !currentTurn) {
283
+ currentTurn = [message];
284
+ turns.push(currentTurn);
285
+ continue;
286
+ }
287
+ currentTurn.push(message);
288
+ }
289
+
290
+ return { fixed, turns };
291
+ }
292
+
293
+ function messagesWithTurns(messages: OpenAIMessage[], turns: OpenAIMessage[][]): OpenAIMessage[] {
294
+ const retainedMessages = new Set(turns.flat());
295
+ return messages.filter((message) => isInstruction(message) || retainedMessages.has(message));
296
+ }
297
+
298
+ async function loadModelMetadata(ctx: Context, serviceName: string, modelId: string): Promise<ModelMetadata> {
299
+ const row = await ctx.db.getRepository('aiApiModelMetadata').findOne({
300
+ filter: { llmService: serviceName, model: modelId, enabled: true },
301
+ });
302
+ const contextWindow = positiveInteger(getValue<unknown>(row, 'contextWindow'));
303
+ const maxCompletionTokens = positiveInteger(getValue<unknown>(row, 'maxCompletionTokens'));
304
+ if (!contextWindow || !maxCompletionTokens) {
305
+ throw new DirectLlmContextError(
306
+ 'model_context_metadata_not_configured',
307
+ `Context metadata is not configured for '${serviceName}/${modelId}'. Configure context window and max completion tokens.`,
308
+ );
309
+ }
310
+ return { contextWindow, maxCompletionTokens };
311
+ }
312
+
313
+ async function resolveOverflowBehavior(ctx: Context): Promise<ContextOverflowBehavior> {
314
+ const userId = ctx.state.currentUser?.id;
315
+ 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
+ }
321
+
322
+ function resolveReservedOutputTokens(options: ContextPreparationOptions, metadata: ModelMetadata): number {
323
+ const requested = positiveInteger(options.maxCompletionTokens ?? options.maxTokens);
324
+ if (requested && requested > metadata.maxCompletionTokens) {
325
+ throw new DirectLlmContextError(
326
+ 'max_completion_tokens_exceeds_model_limit',
327
+ `Requested max completion tokens (${requested}) exceeds the model limit (${metadata.maxCompletionTokens}).`,
328
+ );
329
+ }
330
+ return requested ?? metadata.maxCompletionTokens;
331
+ }
332
+
333
+ export async function prepareDirectLlmContext(
334
+ ctx: Context,
335
+ options: ContextPreparationOptions,
336
+ ): Promise<PreparedDirectLlmContext> {
337
+ const [metadata, behavior] = await Promise.all([
338
+ loadModelMetadata(ctx, options.serviceName, options.modelId),
339
+ resolveOverflowBehavior(ctx),
340
+ ]);
341
+ const reservedOutputTokens = resolveReservedOutputTokens(options, metadata);
342
+ const inputTokenBudget = metadata.contextWindow - reservedOutputTokens;
343
+ if (inputTokenBudget <= 0) {
344
+ throw new DirectLlmContextError(
345
+ 'context_length_exceeded',
346
+ `The model context window (${metadata.contextWindow}) leaves no input capacity after reserving ${reservedOutputTokens} output tokens.`,
347
+ );
348
+ }
349
+
350
+ const fixedOverheadTokens = estimateValueTokens(options.tools) + (options.tools === undefined ? 0 : 4);
351
+ const originalEstimate = estimateMessagesTokens(options.messages) + fixedOverheadTokens;
352
+ if (originalEstimate <= inputTokenBudget) {
353
+ return {
354
+ messages: options.messages,
355
+ estimatedInputTokens: originalEstimate,
356
+ inputTokenBudget,
357
+ reservedOutputTokens,
358
+ truncated: false,
359
+ };
360
+ }
361
+
362
+ if (behavior === 'reject') {
363
+ throw new DirectLlmContextError(
364
+ 'context_length_exceeded',
365
+ `Estimated input tokens (${originalEstimate}) exceed the allowed input budget (${inputTokenBudget}).`,
366
+ );
367
+ }
368
+
369
+ const { turns } = splitTurns(options.messages);
370
+ let remainingTurns = turns;
371
+ let messages = messagesWithTurns(options.messages, remainingTurns);
372
+ let estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
373
+
374
+ while (remainingTurns.length > 1 && estimatedInputTokens > inputTokenBudget) {
375
+ remainingTurns = remainingTurns.slice(1);
376
+ messages = messagesWithTurns(options.messages, remainingTurns);
377
+ estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
378
+ }
379
+
380
+ if (estimatedInputTokens > inputTokenBudget) {
381
+ throw new DirectLlmContextError(
382
+ 'context_length_exceeded',
383
+ `The fixed instructions, tools, and newest conversation turn require ${estimatedInputTokens} input tokens, exceeding the allowed budget (${inputTokenBudget}).`,
384
+ );
385
+ }
386
+
387
+ return {
388
+ messages,
389
+ estimatedInputTokens,
390
+ inputTokenBudget,
391
+ reservedOutputTokens,
392
+ truncated: messages.length !== options.messages.length,
393
+ };
394
+ }
@@ -62,6 +62,7 @@ export function toOpenAIResponse(options: {
62
62
  prompt_tokens?: number | null;
63
63
  completion_tokens?: number | null;
64
64
  total_tokens?: number | null;
65
+ prompt_cache_tokens?: number | null;
65
66
  };
66
67
  toolCalls?: OpenAIToolCall[];
67
68
  }) {
@@ -91,7 +92,21 @@ export function toOpenAIResponse(options: {
91
92
  finish_reason: finishReason,
92
93
  },
93
94
  ],
94
- usage: usage || { prompt_tokens: null, completion_tokens: null, total_tokens: null },
95
+ usage: usage
96
+ ? {
97
+ prompt_tokens: usage.prompt_tokens ?? null,
98
+ completion_tokens: usage.completion_tokens ?? null,
99
+ total_tokens: usage.total_tokens ?? null,
100
+ prompt_tokens_details: {
101
+ cached_tokens: usage.prompt_cache_tokens ?? null,
102
+ },
103
+ }
104
+ : {
105
+ prompt_tokens: null,
106
+ completion_tokens: null,
107
+ total_tokens: null,
108
+ prompt_tokens_details: { cached_tokens: null },
109
+ },
95
110
  };
96
111
  }
97
112
 
@@ -101,6 +116,7 @@ export type OpenAIUsage = {
101
116
  prompt_tokens: number | null;
102
117
  completion_tokens: number | null;
103
118
  total_tokens: number | null;
119
+ prompt_cache_tokens?: number | null;
104
120
  };
105
121
 
106
122
  export type OpenAIStreamObject = 'chat.completion.chunk' | 'text_completion';
@@ -143,7 +159,14 @@ export function toOpenAIUsageChunk(options: {
143
159
  created: Math.floor(Date.now() / 1000),
144
160
  model,
145
161
  choices: [],
146
- usage,
162
+ usage: {
163
+ prompt_tokens: usage.prompt_tokens,
164
+ completion_tokens: usage.completion_tokens,
165
+ total_tokens: usage.total_tokens,
166
+ prompt_tokens_details: {
167
+ cached_tokens: usage.prompt_cache_tokens ?? null,
168
+ },
169
+ },
147
170
  };
148
171
  }
149
172
 
@@ -1,83 +1,83 @@
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
- /**
11
- * In-memory sliding window rate limiter.
12
- *
13
- * Stores per-user request timestamps. On each check: prunes timestamps
14
- * older than the window, counts the remainder, and accepts/rejects.
15
- *
16
- * Single-process safe (Node.js event loop). Not distributed.
17
- * For multi-process deployments, replace with a Redis-backed implementation.
18
- */
19
- export class RateLimiter {
20
- /** Map<userId, sorted array of request timestamps in ms> */
21
- private readonly store = new Map<string | number, number[]>();
22
-
23
- constructor(private readonly windowMs = 60_000) {}
24
-
25
- /**
26
- * Check and record a request for a user.
27
- *
28
- * @param userId The user ID (string or numeric)
29
- * @param limit Max allowed requests per window (from aiApiConfig.rateLimitPerMinute)
30
- * @returns { allowed: true } or { allowed: false, retryAfterMs: number }
31
- */
32
- check(userId: string | number, limit: number): { allowed: true } | { allowed: false; retryAfterMs: number } {
33
- const now = Date.now();
34
- const windowStart = now - this.windowMs;
35
-
36
- let timestamps = this.store.get(userId);
37
- if (!timestamps) {
38
- timestamps = [];
39
- this.store.set(userId, timestamps);
40
- }
41
-
42
- // Binary-search prune: drop all timestamps before the window start.
43
- // O(log n + k) vs O(n) for a simple filter.
44
- let lo = 0,
45
- hi = timestamps.length;
46
- while (lo < hi) {
47
- const mid = (lo + hi) >>> 1;
48
- if (timestamps[mid] < windowStart) {
49
- lo = mid + 1;
50
- } else {
51
- hi = mid;
52
- }
53
- }
54
- if (lo > 0) timestamps.splice(0, lo);
55
-
56
- if (timestamps.length >= limit) {
57
- // retryAfterMs is the time until the oldest request falls out of the window
58
- const retryAfterMs = Math.max(0, timestamps[0] + this.windowMs - now);
59
- return { allowed: false, retryAfterMs };
60
- }
61
-
62
- timestamps.push(now);
63
- return { allowed: true };
64
- }
65
-
66
- /**
67
- * Garbage collect entries for inactive users.
68
- * Call every ~5 minutes to prevent unbounded memory growth in long-running servers.
69
- */
70
- gc(): void {
71
- const cutoff = Date.now() - this.windowMs;
72
- for (const [userId, timestamps] of this.store) {
73
- if (!timestamps.length || timestamps[timestamps.length - 1] < cutoff) {
74
- this.store.delete(userId);
75
- }
76
- }
77
- }
78
-
79
- /** Clear all state (useful in tests). */
80
- clear(): void {
81
- this.store.clear();
82
- }
83
- }
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
+ /**
11
+ * In-memory sliding window rate limiter.
12
+ *
13
+ * Stores per-user request timestamps. On each check: prunes timestamps
14
+ * older than the window, counts the remainder, and accepts/rejects.
15
+ *
16
+ * Single-process safe (Node.js event loop). Not distributed.
17
+ * For multi-process deployments, replace with a Redis-backed implementation.
18
+ */
19
+ export class RateLimiter {
20
+ /** Map<userId, sorted array of request timestamps in ms> */
21
+ private readonly store = new Map<string | number, number[]>();
22
+
23
+ constructor(private readonly windowMs = 60_000) {}
24
+
25
+ /**
26
+ * Check and record a request for a user.
27
+ *
28
+ * @param userId The user ID (string or numeric)
29
+ * @param limit Max allowed requests per window (from aiApiConfig.rateLimitPerMinute)
30
+ * @returns { allowed: true } or { allowed: false, retryAfterMs: number }
31
+ */
32
+ check(userId: string | number, limit: number): { allowed: true } | { allowed: false; retryAfterMs: number } {
33
+ const now = Date.now();
34
+ const windowStart = now - this.windowMs;
35
+
36
+ let timestamps = this.store.get(userId);
37
+ if (!timestamps) {
38
+ timestamps = [];
39
+ this.store.set(userId, timestamps);
40
+ }
41
+
42
+ // Binary-search prune: drop all timestamps before the window start.
43
+ // O(log n + k) vs O(n) for a simple filter.
44
+ let lo = 0,
45
+ hi = timestamps.length;
46
+ while (lo < hi) {
47
+ const mid = (lo + hi) >>> 1;
48
+ if (timestamps[mid] < windowStart) {
49
+ lo = mid + 1;
50
+ } else {
51
+ hi = mid;
52
+ }
53
+ }
54
+ if (lo > 0) timestamps.splice(0, lo);
55
+
56
+ if (timestamps.length >= limit) {
57
+ // retryAfterMs is the time until the oldest request falls out of the window
58
+ const retryAfterMs = Math.max(0, timestamps[0] + this.windowMs - now);
59
+ return { allowed: false, retryAfterMs };
60
+ }
61
+
62
+ timestamps.push(now);
63
+ return { allowed: true };
64
+ }
65
+
66
+ /**
67
+ * Garbage collect entries for inactive users.
68
+ * Call every ~5 minutes to prevent unbounded memory growth in long-running servers.
69
+ */
70
+ gc(): void {
71
+ const cutoff = Date.now() - this.windowMs;
72
+ for (const [userId, timestamps] of this.store) {
73
+ if (!timestamps.length || timestamps[timestamps.length - 1] < cutoff) {
74
+ this.store.delete(userId);
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Clear all state (useful in tests). */
80
+ clear(): void {
81
+ this.store.clear();
82
+ }
83
+ }