plugin-ai-api 1.0.24 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.6568d3504ad29352.js +10 -0
- 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.f2bc9cfba07004b0.js +10 -0
- 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/externalVersion.js +8 -8
- package/dist/locale/en-US.json +27 -8
- package/dist/locale/vi-VN.json +27 -8
- package/dist/locale/zh-CN.json +27 -8
- package/dist/server/billing.js +31 -33
- package/dist/server/collections/ai-api-config.js +7 -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 +2 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
- 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 +100 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +5 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/resource/ai-api-usage-monitor.js +3 -1
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +121 -42
- package/dist/server/routes/completions.js +48 -29
- 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 +426 -0
- package/dist/server/usage.js +37 -3
- package/dist/server/utils/direct-llm-context.js +163 -26
- package/dist/server/utils/openai-format.js +21 -2
- 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 +48 -10
- 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/pages/UsagePage.tsx +9 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +27 -8
- package/src/locale/vi-VN.json +27 -8
- package/src/locale/zh-CN.json +27 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +209 -10
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/openai-format.test.ts +12 -2
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/request-body.test.ts +45 -2
- 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 +382 -5
- package/src/server/__tests__/usage.test.ts +57 -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 +36 -39
- package/src/server/collections/ai-api-config.ts +9 -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-role-permissions.ts +41 -41
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +2 -0
- package/src/server/index.ts +10 -10
- package/src/server/middleware/rate-limit.ts +68 -70
- package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
- 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 +121 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +5 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/resource/ai-api-usage-monitor.ts +3 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +173 -47
- package/src/server/routes/completions.ts +50 -27
- 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/__tests__/file-processor.test.ts +184 -0
- package/src/server/services/file-processor.ts +513 -0
- package/src/server/usage.ts +51 -1
- package/src/server/utils/direct-llm-context.ts +218 -31
- package/src/server/utils/openai-format.ts +25 -2
- package/src/server/utils/rate-limiter.ts +83 -83
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +83 -82
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +52 -11
- 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/757.a01403fb7a1bea01.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/757.a117ce1cf7119cea.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
package/src/server/usage.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type Usage = {
|
|
|
6
6
|
prompt_tokens: number | null;
|
|
7
7
|
completion_tokens: number | null;
|
|
8
8
|
total_tokens: number | null;
|
|
9
|
+
prompt_cache_tokens?: number | null;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
export type AiApiAuthType = 'apiKey' | 'bearer' | 'oidc' | 'unknown';
|
|
@@ -48,6 +49,33 @@ function normalizeTokenCount(value: unknown): number | null {
|
|
|
48
49
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Extract cached prompt tokens from a provider usage or response metadata object.
|
|
54
|
+
* Tolerates both LangChain-style (`input_token_details.cache_read`) and raw
|
|
55
|
+
* OpenAI-style (`prompt_tokens_details.cached_tokens`) shapes.
|
|
56
|
+
*/
|
|
57
|
+
function extractPromptCacheTokens(source: Record<string, unknown>): number | null {
|
|
58
|
+
const inputDetails = source.input_token_details;
|
|
59
|
+
if (inputDetails && typeof inputDetails === 'object') {
|
|
60
|
+
const cacheRead = (inputDetails as Record<string, unknown>).cache_read;
|
|
61
|
+
if (typeof cacheRead === 'number') return cacheRead;
|
|
62
|
+
const cachedTokens = (inputDetails as Record<string, unknown>).cached_tokens;
|
|
63
|
+
if (typeof cachedTokens === 'number') return cachedTokens;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const promptDetails = source.prompt_tokens_details;
|
|
67
|
+
if (promptDetails && typeof promptDetails === 'object') {
|
|
68
|
+
const cachedTokens = (promptDetails as Record<string, unknown>).cached_tokens;
|
|
69
|
+
if (typeof cachedTokens === 'number') return cachedTokens;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (typeof source.cached_tokens === 'number') return source.cached_tokens;
|
|
73
|
+
if (typeof source.cache_read_tokens === 'number') return source.cache_read_tokens;
|
|
74
|
+
if (typeof source.cache_read === 'number') return source.cache_read;
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
51
79
|
export function normalizeUsage(value: unknown): Usage | undefined {
|
|
52
80
|
if (!value || typeof value !== 'object') return undefined;
|
|
53
81
|
const source = value as Record<string, unknown>;
|
|
@@ -65,6 +93,10 @@ export function normalizeUsage(value: unknown): Usage | undefined {
|
|
|
65
93
|
prompt_tokens: prompt,
|
|
66
94
|
completion_tokens: completion,
|
|
67
95
|
total_tokens: total,
|
|
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),
|
|
68
100
|
};
|
|
69
101
|
}
|
|
70
102
|
|
|
@@ -72,8 +104,24 @@ export function setAiApiUsageResult(
|
|
|
72
104
|
ctx: Context,
|
|
73
105
|
value: unknown,
|
|
74
106
|
metadata: Pick<AiApiUsageResult, 'gatewayResponseId' | 'providerRequestId'> = {},
|
|
107
|
+
responseMetadata?: unknown,
|
|
75
108
|
): Usage | undefined {
|
|
76
|
-
|
|
109
|
+
let usage = normalizeUsage(value);
|
|
110
|
+
|
|
111
|
+
// Some providers only surface cached token details on response_metadata; fall
|
|
112
|
+
// back there when the usage object itself does not carry them.
|
|
113
|
+
if (usage?.prompt_cache_tokens === null && responseMetadata && typeof responseMetadata === 'object') {
|
|
114
|
+
const responseMetaRecord = responseMetadata as Record<string, unknown>;
|
|
115
|
+
let cacheTokens = extractPromptCacheTokens(responseMetaRecord);
|
|
116
|
+
// Providers sometimes nest the raw provider usage under response_metadata.usage.
|
|
117
|
+
if (cacheTokens === null && responseMetaRecord.usage && typeof responseMetaRecord.usage === 'object') {
|
|
118
|
+
cacheTokens = extractPromptCacheTokens(responseMetaRecord.usage as Record<string, unknown>);
|
|
119
|
+
}
|
|
120
|
+
if (cacheTokens !== null) {
|
|
121
|
+
usage = { ...usage, prompt_cache_tokens: cacheTokens };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
77
125
|
getAiApiState(ctx).aiApiUsageResult = usage
|
|
78
126
|
? { source: 'provider', usage, ...metadata }
|
|
79
127
|
: { source: 'unavailable', ...metadata };
|
|
@@ -174,6 +222,7 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
|
|
|
174
222
|
inputTokens: usage?.prompt_tokens ?? null,
|
|
175
223
|
outputTokens: usage?.completion_tokens ?? null,
|
|
176
224
|
totalTokens: usage?.total_tokens ?? null,
|
|
225
|
+
promptCacheTokens: usage?.prompt_cache_tokens ?? null,
|
|
177
226
|
resolvedService: state.aiApiLlmBilling?.resolution?.service ?? null,
|
|
178
227
|
resolvedProvider: state.aiApiLlmBilling?.resolution?.provider ?? null,
|
|
179
228
|
resolvedModel: state.aiApiLlmBilling?.resolution?.model ?? null,
|
|
@@ -182,6 +231,7 @@ export async function finishUsageRecord(ctx: Context, id: unknown, startedAt: nu
|
|
|
182
231
|
costStatus: billing.costStatus ?? null,
|
|
183
232
|
modelPriceId: billing.modelPriceId ?? null,
|
|
184
233
|
quotaPolicyId: billing.quotaPolicyId ?? null,
|
|
234
|
+
groupId: billing.groupId ?? null,
|
|
185
235
|
inputPricePerMillionTokens: billing.inputPricePerMillionTokens ?? null,
|
|
186
236
|
outputPricePerMillionTokens: billing.outputPricePerMillionTokens ?? null,
|
|
187
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 {
|
|
@@ -58,26 +60,211 @@ function positiveInteger(value: unknown): number | undefined {
|
|
|
58
60
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
59
61
|
}
|
|
60
62
|
|
|
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
63
|
function estimateValueTokens(value: unknown): number {
|
|
71
64
|
if (value === undefined) return 0;
|
|
72
65
|
return Math.ceil(Buffer.byteLength(JSON.stringify(value), 'utf8') / 3);
|
|
73
66
|
}
|
|
74
67
|
|
|
75
|
-
function
|
|
76
|
-
return
|
|
68
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
69
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Image dimension parsing ───
|
|
73
|
+
|
|
74
|
+
interface ImageDimensions {
|
|
75
|
+
width: number;
|
|
76
|
+
height: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readBigEndian(buf: Buffer, offset: number): number {
|
|
80
|
+
return buf.readUInt32BE(offset);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readUInt16BE(buf: Buffer, offset: number): number {
|
|
84
|
+
return buf.readUInt16BE(offset);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readLittleEndian(buf: Buffer, offset: number): number {
|
|
88
|
+
return buf.readUInt16LE(offset);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parsePngDimensions(buffer: Buffer): ImageDimensions | undefined {
|
|
92
|
+
if (buffer.length < 24) return undefined;
|
|
93
|
+
return { width: readBigEndian(buffer, 16), height: readBigEndian(buffer, 20) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseJpegDimensions(buffer: Buffer): ImageDimensions | undefined {
|
|
97
|
+
let offset = 2; // skip SOI
|
|
98
|
+
while (offset < buffer.length) {
|
|
99
|
+
if (buffer[offset] !== 0xff) {
|
|
100
|
+
offset++;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const marker = buffer[offset + 1];
|
|
104
|
+
// SOF0, SOF1, SOF2, SOF3, SOF5, SOF6, SOF7, SOF9, SOF10, SOF11, SOF13, SOF14, SOF15
|
|
105
|
+
if (
|
|
106
|
+
(marker >= 0xc0 && marker <= 0xc3) ||
|
|
107
|
+
(marker >= 0xc5 && marker <= 0xc7) ||
|
|
108
|
+
(marker >= 0xc9 && marker <= 0xcb) ||
|
|
109
|
+
(marker >= 0xcd && marker <= 0xcf)
|
|
110
|
+
) {
|
|
111
|
+
if (offset + 9 <= buffer.length) {
|
|
112
|
+
return { height: readUInt16BE(buffer, offset + 5), width: readUInt16BE(buffer, offset + 7) };
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
if (marker === 0xd9 || offset + 4 >= buffer.length) break;
|
|
117
|
+
const segmentLength = buffer.readUInt16BE(offset + 2);
|
|
118
|
+
offset += 2 + segmentLength;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseGifDimensions(buffer: Buffer): ImageDimensions | undefined {
|
|
124
|
+
if (buffer.length < 10) return undefined;
|
|
125
|
+
return { width: readLittleEndian(buffer, 6), height: readLittleEndian(buffer, 8) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseWebpDimensions(buffer: Buffer): ImageDimensions | undefined {
|
|
129
|
+
if (buffer.length < 30) return undefined;
|
|
130
|
+
const riff = buffer.toString('ascii', 0, 4);
|
|
131
|
+
const webp = buffer.toString('ascii', 8, 12);
|
|
132
|
+
if (riff !== 'RIFF' || webp !== 'WEBP') return undefined;
|
|
133
|
+
|
|
134
|
+
const chunkType = buffer.toString('ascii', 12, 16);
|
|
135
|
+
if (chunkType === 'VP8 ' && buffer.length >= 26) {
|
|
136
|
+
// Simple lossy WebP
|
|
137
|
+
return { width: readLittleEndian(buffer, 26), height: readLittleEndian(buffer, 28) };
|
|
138
|
+
}
|
|
139
|
+
if (chunkType === 'VP8L' && buffer.length >= 24) {
|
|
140
|
+
// Lossless WebP: dimensions packed into 32 bits at offset 21
|
|
141
|
+
const bits = buffer.readUInt32LE(21);
|
|
142
|
+
return {
|
|
143
|
+
width: (bits & 0x3fff) + 1,
|
|
144
|
+
height: ((bits >> 14) & 0x3fff) + 1,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
if (chunkType === 'VP8X' && buffer.length >= 30) {
|
|
148
|
+
// Extended WebP: width/height at offset 24, 27
|
|
149
|
+
return {
|
|
150
|
+
width: ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xffffff) + 1,
|
|
151
|
+
height: ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xffffff) + 1,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
77
155
|
}
|
|
78
156
|
|
|
79
|
-
function
|
|
80
|
-
|
|
157
|
+
export function parseImageDimensions(buffer: Buffer): ImageDimensions | undefined {
|
|
158
|
+
if (buffer.length < 12) return undefined;
|
|
159
|
+
// Use 'binary' (latin1) so high bytes such as PNG's 0x89 are preserved.
|
|
160
|
+
const header = buffer.toString('binary', 0, 4);
|
|
161
|
+
if (header === '\x89PNG') return parsePngDimensions(buffer);
|
|
162
|
+
if (header === 'GIF8') return parseGifDimensions(buffer);
|
|
163
|
+
if (header === 'RIFF') return parseWebpDimensions(buffer);
|
|
164
|
+
if (buffer[0] === 0xff && buffer[1] === 0xd8) return parseJpegDimensions(buffer);
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Vision token estimation ───
|
|
169
|
+
|
|
170
|
+
const VISION_TILE_SIZE = 512;
|
|
171
|
+
const VISION_LOW_DETAIL_TOKENS = 85;
|
|
172
|
+
const VISION_TILE_TOKENS = 170;
|
|
173
|
+
const VISION_HTTP_URL_ESTIMATE = 1024;
|
|
174
|
+
const FILE_BASE64_FALLBACK_TOKENS = 1024;
|
|
175
|
+
|
|
176
|
+
function estimateVisionTokensForDimensions(width: number, height: number): number {
|
|
177
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
178
|
+
return VISION_LOW_DETAIL_TOKENS;
|
|
179
|
+
}
|
|
180
|
+
const tilesX = Math.ceil(width / VISION_TILE_SIZE);
|
|
181
|
+
const tilesY = Math.ceil(height / VISION_TILE_SIZE);
|
|
182
|
+
return VISION_LOW_DETAIL_TOKENS + tilesX * tilesY * VISION_TILE_TOKENS;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function decodeBase64DataUrl(url: string): { mimeType: string; buffer: Buffer } | undefined {
|
|
186
|
+
const match = /^data:([^;]+);base64,([A-Za-z0-9+/]+=?=?)$/.exec(url);
|
|
187
|
+
if (!match) return undefined;
|
|
188
|
+
try {
|
|
189
|
+
const buffer = Buffer.from(match[2], 'base64');
|
|
190
|
+
return { mimeType: match[1].toLowerCase(), buffer };
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function estimateImageUrlTokens(imageUrl: unknown): number {
|
|
197
|
+
const url = typeof imageUrl === 'string' ? imageUrl : isRecord(imageUrl) ? String(imageUrl.url ?? '') : '';
|
|
198
|
+
if (!url) return 0;
|
|
199
|
+
|
|
200
|
+
if (url.startsWith('data:')) {
|
|
201
|
+
const decoded = decodeBase64DataUrl(url);
|
|
202
|
+
if (!decoded) return FILE_BASE64_FALLBACK_TOKENS;
|
|
203
|
+
if (!decoded.mimeType.startsWith('image/')) return FILE_BASE64_FALLBACK_TOKENS;
|
|
204
|
+
const dimensions = parseImageDimensions(decoded.buffer);
|
|
205
|
+
return dimensions
|
|
206
|
+
? estimateVisionTokensForDimensions(dimensions.width, dimensions.height)
|
|
207
|
+
: VISION_LOW_DETAIL_TOKENS;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
211
|
+
// We cannot fetch here because context enforcement runs before any
|
|
212
|
+
// network I/O. Use a conservative fixed estimate.
|
|
213
|
+
return VISION_HTTP_URL_ESTIMATE;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function estimateFileBlockTokens(block: Record<string, unknown>): number {
|
|
220
|
+
const file = isRecord(block.file) ? block.file : undefined;
|
|
221
|
+
if (!file) return 0;
|
|
222
|
+
|
|
223
|
+
const fileData = String(file.file_data ?? '');
|
|
224
|
+
if (fileData.startsWith('data:')) {
|
|
225
|
+
const decoded = decodeBase64DataUrl(fileData);
|
|
226
|
+
if (decoded) {
|
|
227
|
+
// Conservative upper-bound: one token per ~3 bytes of decoded binary.
|
|
228
|
+
return Math.max(1, Math.ceil(decoded.buffer.length / 3));
|
|
229
|
+
}
|
|
230
|
+
// Fallback when the data URL is malformed or cannot be decoded.
|
|
231
|
+
// The request is still forwarded; the provider will reject it if invalid.
|
|
232
|
+
return FILE_BASE64_FALLBACK_TOKENS;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return FILE_BASE64_FALLBACK_TOKENS;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function estimateContentBlockTokens(block: unknown): number {
|
|
239
|
+
if (!isRecord(block)) return estimateValueTokens(block);
|
|
240
|
+
|
|
241
|
+
const type = typeof block.type === 'string' ? block.type : undefined;
|
|
242
|
+
if (type === 'text') {
|
|
243
|
+
return typeof block.text === 'string' ? estimateValueTokens(block.text) + 4 : 4;
|
|
244
|
+
}
|
|
245
|
+
if (type === 'image_url') {
|
|
246
|
+
return estimateImageUrlTokens(block.image_url) + 4;
|
|
247
|
+
}
|
|
248
|
+
if (type === 'file') {
|
|
249
|
+
return estimateFileBlockTokens(block) + 4;
|
|
250
|
+
}
|
|
251
|
+
if (type === 'file_url') {
|
|
252
|
+
// Will be converted to a `file` block before reaching the model; use a
|
|
253
|
+
// conservative placeholder until processing runs.
|
|
254
|
+
return VISION_HTTP_URL_ESTIMATE + 4;
|
|
255
|
+
}
|
|
256
|
+
return estimateValueTokens(block) + 4;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function estimateMessageTokens(message: OpenAIMessage): number {
|
|
260
|
+
if (Array.isArray(message.content)) {
|
|
261
|
+
return message.content.reduce((total, block) => total + estimateContentBlockTokens(block), 4);
|
|
262
|
+
}
|
|
263
|
+
return estimateValueTokens(message) + 4;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function estimateMessagesTokens(messages: OpenAIMessage[]): number {
|
|
267
|
+
return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
|
|
81
268
|
}
|
|
82
269
|
|
|
83
270
|
function isInstruction(message: OpenAIMessage): boolean {
|
|
@@ -122,16 +309,16 @@ async function loadModelMetadata(ctx: Context, serviceName: string, modelId: str
|
|
|
122
309
|
`Context metadata is not configured for '${serviceName}/${modelId}'. Configure context window and max completion tokens.`,
|
|
123
310
|
);
|
|
124
311
|
}
|
|
125
|
-
|
|
312
|
+
const systemPromptValue = getValue<unknown>(row, 'systemPrompt');
|
|
313
|
+
const systemPrompt = typeof systemPromptValue === 'string' ? systemPromptValue.trim() : '';
|
|
314
|
+
return { contextWindow, maxCompletionTokens, ...(systemPrompt ? { systemPrompt } : {}) };
|
|
126
315
|
}
|
|
127
316
|
|
|
128
317
|
async function resolveOverflowBehavior(ctx: Context): Promise<ContextOverflowBehavior> {
|
|
129
318
|
const userId = ctx.state.currentUser?.id;
|
|
130
319
|
if (userId === null || userId === undefined) return 'reject';
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
});
|
|
134
|
-
return getValue<unknown>(policy, 'contextOverflowBehavior') === 'truncate' ? 'truncate' : 'reject';
|
|
320
|
+
const group = await resolveRequestUserGroup(ctx, userId);
|
|
321
|
+
return group.contextOverflowBehavior === 'truncate' ? 'truncate' : 'reject';
|
|
135
322
|
}
|
|
136
323
|
|
|
137
324
|
function resolveReservedOutputTokens(options: ContextPreparationOptions, metadata: ModelMetadata): number {
|
|
@@ -149,13 +336,6 @@ export async function prepareDirectLlmContext(
|
|
|
149
336
|
ctx: Context,
|
|
150
337
|
options: ContextPreparationOptions,
|
|
151
338
|
): 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
339
|
const [metadata, behavior] = await Promise.all([
|
|
160
340
|
loadModelMetadata(ctx, options.serviceName, options.modelId),
|
|
161
341
|
resolveOverflowBehavior(ctx),
|
|
@@ -170,10 +350,17 @@ export async function prepareDirectLlmContext(
|
|
|
170
350
|
}
|
|
171
351
|
|
|
172
352
|
const fixedOverheadTokens = estimateValueTokens(options.tools) + (options.tools === undefined ? 0 : 4);
|
|
173
|
-
|
|
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;
|
|
174
361
|
if (originalEstimate <= inputTokenBudget) {
|
|
175
362
|
return {
|
|
176
|
-
messages:
|
|
363
|
+
messages: baseMessages,
|
|
177
364
|
estimatedInputTokens: originalEstimate,
|
|
178
365
|
inputTokenBudget,
|
|
179
366
|
reservedOutputTokens,
|
|
@@ -188,14 +375,14 @@ export async function prepareDirectLlmContext(
|
|
|
188
375
|
);
|
|
189
376
|
}
|
|
190
377
|
|
|
191
|
-
const { turns } = splitTurns(
|
|
378
|
+
const { turns } = splitTurns(baseMessages);
|
|
192
379
|
let remainingTurns = turns;
|
|
193
|
-
let messages = messagesWithTurns(
|
|
380
|
+
let messages = messagesWithTurns(baseMessages, remainingTurns);
|
|
194
381
|
let estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
|
|
195
382
|
|
|
196
383
|
while (remainingTurns.length > 1 && estimatedInputTokens > inputTokenBudget) {
|
|
197
384
|
remainingTurns = remainingTurns.slice(1);
|
|
198
|
-
messages = messagesWithTurns(
|
|
385
|
+
messages = messagesWithTurns(baseMessages, remainingTurns);
|
|
199
386
|
estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
|
|
200
387
|
}
|
|
201
388
|
|
|
@@ -211,6 +398,6 @@ export async function prepareDirectLlmContext(
|
|
|
211
398
|
estimatedInputTokens,
|
|
212
399
|
inputTokenBudget,
|
|
213
400
|
reservedOutputTokens,
|
|
214
|
-
truncated: messages.length !==
|
|
401
|
+
truncated: messages.length !== baseMessages.length,
|
|
215
402
|
};
|
|
216
403
|
}
|
|
@@ -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
|
|
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 the user's usage group)
|
|
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
|
+
}
|