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.
Files changed (130) hide show
  1. package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
  2. package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
  3. package/dist/client/562.44b16aad4718b4c7.js +10 -0
  4. package/dist/client/685.ae483e17b6b49c98.js +10 -0
  5. package/dist/client/757.6568d3504ad29352.js +10 -0
  6. package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.3971233415999b2c.js +10 -0
  9. package/dist/client-v2/562.45d5c504433be38b.js +10 -0
  10. package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
  11. package/dist/client-v2/757.f2bc9cfba07004b0.js +10 -0
  12. package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
  13. package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +2 -5
  16. package/dist/externalVersion.js +8 -8
  17. package/dist/locale/en-US.json +27 -8
  18. package/dist/locale/vi-VN.json +27 -8
  19. package/dist/locale/zh-CN.json +27 -8
  20. package/dist/server/billing.js +31 -33
  21. package/dist/server/collections/ai-api-config.js +7 -7
  22. package/dist/server/collections/ai-api-group-members.js +62 -0
  23. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  24. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  25. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  26. package/dist/server/collections/ai-api-usage-records.js +2 -0
  27. package/dist/server/middleware/rate-limit.js +7 -6
  28. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  29. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  30. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  31. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  32. package/dist/server/plugin.js +100 -22
  33. package/dist/server/quota-groups.js +108 -0
  34. package/dist/server/resource/ai-api-config.js +5 -3
  35. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  36. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  37. package/dist/server/routes/agent-completions.js +2 -1
  38. package/dist/server/routes/chat-completions.js +121 -42
  39. package/dist/server/routes/completions.js +48 -29
  40. package/dist/server/routes/embeddings.js +2 -1
  41. package/dist/server/routes/models.js +2 -1
  42. package/dist/server/routes/router.js +3 -2
  43. package/dist/server/services/file-processor.js +426 -0
  44. package/dist/server/usage.js +37 -3
  45. package/dist/server/utils/direct-llm-context.js +163 -26
  46. package/dist/server/utils/openai-format.js +21 -2
  47. package/dist/server/utils/rate-limiter.js +1 -1
  48. package/dist/server/utils/request-cache.js +61 -0
  49. package/dist/server/utils/resolve-service.js +2 -1
  50. package/dist/server/utils/user-permissions.js +25 -39
  51. package/dist/server/validation.js +7 -0
  52. package/dist/swagger.js +48 -10
  53. package/package.json +1 -1
  54. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  55. package/src/client/plugin.tsx +5 -16
  56. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  57. package/src/client-v2/locale.ts +3 -1
  58. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  59. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  60. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  61. package/src/client-v2/pages/UsagePage.tsx +9 -0
  62. package/src/client-v2/plugin.tsx +4 -13
  63. package/src/constants.ts +0 -7
  64. package/src/locale/en-US.json +27 -8
  65. package/src/locale/vi-VN.json +27 -8
  66. package/src/locale/zh-CN.json +27 -8
  67. package/src/server/__tests__/billing-quota.test.ts +28 -9
  68. package/src/server/__tests__/direct-llm-context.test.ts +209 -10
  69. package/src/server/__tests__/file-processor.test.ts +225 -0
  70. package/src/server/__tests__/models.test.ts +1 -1
  71. package/src/server/__tests__/openai-format.test.ts +12 -2
  72. package/src/server/__tests__/permission-sync.test.ts +34 -35
  73. package/src/server/__tests__/request-body.test.ts +45 -2
  74. package/src/server/__tests__/usage-groups.test.ts +160 -0
  75. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  76. package/src/server/__tests__/usage-route.test.ts +382 -5
  77. package/src/server/__tests__/usage.test.ts +57 -0
  78. package/src/server/__tests__/user-permissions.test.ts +214 -133
  79. package/src/server/__tests__/validation.test.ts +11 -0
  80. package/src/server/billing.ts +36 -39
  81. package/src/server/collections/ai-api-config.ts +9 -7
  82. package/src/server/collections/ai-api-group-members.ts +41 -0
  83. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  84. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  85. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  86. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  87. package/src/server/collections/ai-api-usage-records.ts +2 -0
  88. package/src/server/index.ts +10 -10
  89. package/src/server/middleware/rate-limit.ts +68 -70
  90. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  91. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  92. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  93. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  94. package/src/server/plugin.ts +121 -30
  95. package/src/server/quota-groups.ts +117 -0
  96. package/src/server/resource/ai-api-config.ts +5 -3
  97. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  98. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  99. package/src/server/routes/agent-completions.ts +2 -1
  100. package/src/server/routes/chat-completions.ts +173 -47
  101. package/src/server/routes/completions.ts +50 -27
  102. package/src/server/routes/embeddings.ts +2 -1
  103. package/src/server/routes/models.ts +4 -3
  104. package/src/server/routes/router.ts +4 -3
  105. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  106. package/src/server/services/file-processor.ts +513 -0
  107. package/src/server/usage.ts +51 -1
  108. package/src/server/utils/direct-llm-context.ts +218 -31
  109. package/src/server/utils/openai-format.ts +25 -2
  110. package/src/server/utils/rate-limiter.ts +83 -83
  111. package/src/server/utils/request-cache.ts +59 -0
  112. package/src/server/utils/resolve-service.ts +83 -82
  113. package/src/server/utils/user-permissions.ts +49 -69
  114. package/src/server/validation.ts +7 -0
  115. package/src/swagger.ts +52 -11
  116. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  117. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  118. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  119. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  120. package/dist/client/902.e74518750f1e4201.js +0 -10
  121. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  122. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  123. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  124. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  125. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  126. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  127. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  128. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  129. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  130. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -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
- const usage = normalizeUsage(value);
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 estimateMessagesTokens(messages: OpenAIMessage[]): number {
76
- return messages.reduce((total, message) => total + estimateValueTokens(message) + 4, 0);
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 containsUnsupportedContent(messages: OpenAIMessage[]): boolean {
80
- return messages.some((message) => hasImageContent(message.content));
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
- return { contextWindow, maxCompletionTokens };
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 policy = await ctx.db.getRepository('aiApiUserQuotaPolicies').findOne({
132
- filter: { userId, enabled: true },
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
- const originalEstimate = estimateMessagesTokens(options.messages) + fixedOverheadTokens;
353
+ // The initial system prompt from model metadata is prepended before every
354
+ // client message — including the client's own system prompt, which is kept.
355
+ // Being a system message, it is a fixed instruction that truncation never
356
+ // drops, and it counts toward the input budget.
357
+ const baseMessages: OpenAIMessage[] = metadata.systemPrompt
358
+ ? [{ role: 'system', content: metadata.systemPrompt }, ...options.messages]
359
+ : options.messages;
360
+ const originalEstimate = estimateMessagesTokens(baseMessages) + fixedOverheadTokens;
174
361
  if (originalEstimate <= inputTokenBudget) {
175
362
  return {
176
- messages: options.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(options.messages);
378
+ const { turns } = splitTurns(baseMessages);
192
379
  let remainingTurns = turns;
193
- let messages = messagesWithTurns(options.messages, remainingTurns);
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(options.messages, remainingTurns);
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 !== options.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 || { 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 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
+ }