plugin-ai-api 1.0.24 → 1.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/757.56952e321dc399b7.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/757.db678ca1aa6c422c.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +1 -0
- package/dist/locale/vi-VN.json +1 -0
- package/dist/locale/zh-CN.json +1 -0
- package/dist/server/billing.js +6 -1
- package/dist/server/collections/ai-api-config.js +6 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- package/dist/server/plugin.js +10 -0
- package/dist/server/resource/ai-api-config.js +5 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/chat-completions.js +89 -10
- package/dist/server/routes/completions.js +32 -10
- package/dist/server/services/file-processor.js +262 -0
- package/dist/server/usage.js +33 -3
- package/dist/server/utils/direct-llm-context.js +150 -15
- package/dist/server/utils/openai-format.js +21 -2
- package/dist/swagger.js +42 -3
- package/package.json +1 -1
- package/src/client-v2/pages/UsagePage.tsx +9 -0
- package/src/locale/en-US.json +1 -0
- package/src/locale/vi-VN.json +1 -0
- package/src/locale/zh-CN.json +1 -0
- package/src/server/__tests__/direct-llm-context.test.ts +87 -6
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/request-body.test.ts +45 -2
- package/src/server/__tests__/usage-route.test.ts +120 -3
- package/src/server/__tests__/usage.test.ts +19 -0
- package/src/server/billing.ts +6 -1
- package/src/server/collections/ai-api-config.ts +8 -0
- package/src/server/collections/ai-api-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +70 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- package/src/server/plugin.ts +20 -0
- package/src/server/resource/ai-api-config.ts +5 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/chat-completions.ts +134 -11
- package/src/server/routes/completions.ts +33 -7
- package/src/server/services/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +323 -0
- package/src/server/usage.ts +47 -1
- package/src/server/utils/direct-llm-context.ts +198 -20
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/resolve-service.ts +82 -82
- package/src/swagger.ts +45 -3
- package/dist/client/757.a01403fb7a1bea01.js +0 -10
- package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
|
@@ -58,26 +58,211 @@ function positiveInteger(value: unknown): number | undefined {
|
|
|
58
58
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function hasImageContent(value: unknown): boolean {
|
|
62
|
-
return (
|
|
63
|
-
Array.isArray(value) &&
|
|
64
|
-
value.some(
|
|
65
|
-
(block) => typeof block === 'object' && block !== null && (block as { type?: unknown }).type === 'image_url',
|
|
66
|
-
)
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
61
|
function estimateValueTokens(value: unknown): number {
|
|
71
62
|
if (value === undefined) return 0;
|
|
72
63
|
return Math.ceil(Buffer.byteLength(JSON.stringify(value), 'utf8') / 3);
|
|
73
64
|
}
|
|
74
65
|
|
|
75
|
-
function
|
|
76
|
-
return
|
|
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;
|
|
77
215
|
}
|
|
78
216
|
|
|
79
|
-
function
|
|
80
|
-
|
|
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);
|
|
81
266
|
}
|
|
82
267
|
|
|
83
268
|
function isInstruction(message: OpenAIMessage): boolean {
|
|
@@ -149,13 +334,6 @@ export async function prepareDirectLlmContext(
|
|
|
149
334
|
ctx: Context,
|
|
150
335
|
options: ContextPreparationOptions,
|
|
151
336
|
): Promise<PreparedDirectLlmContext> {
|
|
152
|
-
if (containsUnsupportedContent(options.messages)) {
|
|
153
|
-
throw new DirectLlmContextError(
|
|
154
|
-
'context_estimation_unsupported',
|
|
155
|
-
'Context enforcement does not support image_url content without a model-specific vision token estimator.',
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
337
|
const [metadata, behavior] = await Promise.all([
|
|
160
338
|
loadModelMetadata(ctx, options.serviceName, options.modelId),
|
|
161
339
|
resolveOverflowBehavior(ctx),
|
|
@@ -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
|
|
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
|
+
}
|
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* This file is part of the NocoBase (R) project.
|
|
3
|
-
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
-
* Authors: NocoBase Team.
|
|
5
|
-
*
|
|
6
|
-
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
-
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { Context } from '@nocobase/actions';
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Resolve an LLM service by name or title.
|
|
14
|
-
*/
|
|
15
|
-
export async function resolveLlmService(ctx: Context, serviceKey: string) {
|
|
16
|
-
const repo = ctx.db.getRepository('llmServices');
|
|
17
|
-
|
|
18
|
-
let service = await repo.findOne({ filter: { name: serviceKey } });
|
|
19
|
-
if (!service) {
|
|
20
|
-
service = await repo.findOne({ filter: { title: serviceKey } });
|
|
21
|
-
}
|
|
22
|
-
return service;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Resolve a model string to a service + modelId.
|
|
27
|
-
*
|
|
28
|
-
* Strategy (priority order):
|
|
29
|
-
* 1. Try splitting at each "/" position and match the left part against DB (name or title).
|
|
30
|
-
* This handles cases like "Custom LLM (OpenAI Compatible)/qwen/qwen3.6-plus-preview:free"
|
|
31
|
-
* 2. If no service match, use the defaultLlmService from config and treat the ENTIRE
|
|
32
|
-
* model string as the modelId. This allows clients to send just "qwen/qwen3.6-plus-preview:free"
|
|
33
|
-
* or "gpt-4o" without knowing the service name.
|
|
34
|
-
*/
|
|
35
|
-
export async function resolveModelString(
|
|
36
|
-
ctx: Context,
|
|
37
|
-
modelString: string,
|
|
38
|
-
): Promise<{ service: any; modelId: string } | null> {
|
|
39
|
-
const repo = ctx.db.getRepository('llmServices');
|
|
40
|
-
|
|
41
|
-
// ─── Strategy 1: Try splitting at "/" positions ───
|
|
42
|
-
const slashPositions: number[] = [];
|
|
43
|
-
for (let i = 0; i < modelString.length; i++) {
|
|
44
|
-
if (modelString[i] === '/') {
|
|
45
|
-
slashPositions.push(i);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (slashPositions.length > 0) {
|
|
50
|
-
for (const pos of slashPositions) {
|
|
51
|
-
const serviceKey = modelString.substring(0, pos);
|
|
52
|
-
const modelId = modelString.substring(pos + 1);
|
|
53
|
-
if (!serviceKey || !modelId) continue;
|
|
54
|
-
|
|
55
|
-
let service = await repo.findOne({ filter: { name: serviceKey } });
|
|
56
|
-
if (!service) {
|
|
57
|
-
service = await repo.findOne({ filter: { title: serviceKey } });
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (service) {
|
|
61
|
-
return { service, modelId };
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ─── Strategy 2: Use default LLM service from config ───
|
|
67
|
-
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
68
|
-
if (config?.defaultLlmService) {
|
|
69
|
-
const service = await repo.findOne({ filter: { name: config.defaultLlmService } });
|
|
70
|
-
if (service) {
|
|
71
|
-
return { service, modelId: modelString };
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// ─── Strategy 3: If only one service enabled, use it ───
|
|
76
|
-
const enabledServices = await repo.find({ filter: { enabled: true } });
|
|
77
|
-
if (enabledServices.length === 1) {
|
|
78
|
-
return { service: enabledServices[0], modelId: modelString };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* This file is part of the NocoBase (R) project.
|
|
3
|
+
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
|
|
4
|
+
* Authors: NocoBase Team.
|
|
5
|
+
*
|
|
6
|
+
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
|
|
7
|
+
* For more information, please refer to: https://www.nocobase.com/agreement.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Context } from '@nocobase/actions';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve an LLM service by name or title.
|
|
14
|
+
*/
|
|
15
|
+
export async function resolveLlmService(ctx: Context, serviceKey: string) {
|
|
16
|
+
const repo = ctx.db.getRepository('llmServices');
|
|
17
|
+
|
|
18
|
+
let service = await repo.findOne({ filter: { name: serviceKey } });
|
|
19
|
+
if (!service) {
|
|
20
|
+
service = await repo.findOne({ filter: { title: serviceKey } });
|
|
21
|
+
}
|
|
22
|
+
return service;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve a model string to a service + modelId.
|
|
27
|
+
*
|
|
28
|
+
* Strategy (priority order):
|
|
29
|
+
* 1. Try splitting at each "/" position and match the left part against DB (name or title).
|
|
30
|
+
* This handles cases like "Custom LLM (OpenAI Compatible)/qwen/qwen3.6-plus-preview:free"
|
|
31
|
+
* 2. If no service match, use the defaultLlmService from config and treat the ENTIRE
|
|
32
|
+
* model string as the modelId. This allows clients to send just "qwen/qwen3.6-plus-preview:free"
|
|
33
|
+
* or "gpt-4o" without knowing the service name.
|
|
34
|
+
*/
|
|
35
|
+
export async function resolveModelString(
|
|
36
|
+
ctx: Context,
|
|
37
|
+
modelString: string,
|
|
38
|
+
): Promise<{ service: any; modelId: string } | null> {
|
|
39
|
+
const repo = ctx.db.getRepository('llmServices');
|
|
40
|
+
|
|
41
|
+
// ─── Strategy 1: Try splitting at "/" positions ───
|
|
42
|
+
const slashPositions: number[] = [];
|
|
43
|
+
for (let i = 0; i < modelString.length; i++) {
|
|
44
|
+
if (modelString[i] === '/') {
|
|
45
|
+
slashPositions.push(i);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (slashPositions.length > 0) {
|
|
50
|
+
for (const pos of slashPositions) {
|
|
51
|
+
const serviceKey = modelString.substring(0, pos);
|
|
52
|
+
const modelId = modelString.substring(pos + 1);
|
|
53
|
+
if (!serviceKey || !modelId) continue;
|
|
54
|
+
|
|
55
|
+
let service = await repo.findOne({ filter: { name: serviceKey } });
|
|
56
|
+
if (!service) {
|
|
57
|
+
service = await repo.findOne({ filter: { title: serviceKey } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (service) {
|
|
61
|
+
return { service, modelId };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ─── Strategy 2: Use default LLM service from config ───
|
|
67
|
+
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
68
|
+
if (config?.defaultLlmService) {
|
|
69
|
+
const service = await repo.findOne({ filter: { name: config.defaultLlmService } });
|
|
70
|
+
if (service) {
|
|
71
|
+
return { service, modelId: modelString };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Strategy 3: If only one service enabled, use it ───
|
|
76
|
+
const enabledServices = await repo.find({ filter: { enabled: true } });
|
|
77
|
+
if (enabledServices.length === 1) {
|
|
78
|
+
return { service: enabledServices[0], modelId: modelString };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return null;
|
|
82
|
+
}
|