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
@@ -27,9 +27,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
27
27
  var direct_llm_context_exports = {};
28
28
  __export(direct_llm_context_exports, {
29
29
  DirectLlmContextError: () => DirectLlmContextError,
30
+ parseImageDimensions: () => parseImageDimensions,
30
31
  prepareDirectLlmContext: () => prepareDirectLlmContext
31
32
  });
32
33
  module.exports = __toCommonJS(direct_llm_context_exports);
34
+ var import_request_cache = require("./request-cache");
33
35
  class DirectLlmContextError extends Error {
34
36
  constructor(code, message) {
35
37
  super(message);
@@ -45,20 +47,159 @@ function positiveInteger(value) {
45
47
  const parsed = Number(value);
46
48
  return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
47
49
  }
48
- function hasImageContent(value) {
49
- return Array.isArray(value) && value.some(
50
- (block) => typeof block === "object" && block !== null && block.type === "image_url"
51
- );
52
- }
53
50
  function estimateValueTokens(value) {
54
51
  if (value === void 0) return 0;
55
52
  return Math.ceil(Buffer.byteLength(JSON.stringify(value), "utf8") / 3);
56
53
  }
57
- function estimateMessagesTokens(messages) {
58
- return messages.reduce((total, message) => total + estimateValueTokens(message) + 4, 0);
54
+ function isRecord(value) {
55
+ return typeof value === "object" && value !== null && !Array.isArray(value);
56
+ }
57
+ function readBigEndian(buf, offset) {
58
+ return buf.readUInt32BE(offset);
59
+ }
60
+ function readUInt16BE(buf, offset) {
61
+ return buf.readUInt16BE(offset);
62
+ }
63
+ function readLittleEndian(buf, offset) {
64
+ return buf.readUInt16LE(offset);
65
+ }
66
+ function parsePngDimensions(buffer) {
67
+ if (buffer.length < 24) return void 0;
68
+ return { width: readBigEndian(buffer, 16), height: readBigEndian(buffer, 20) };
69
+ }
70
+ function parseJpegDimensions(buffer) {
71
+ let offset = 2;
72
+ while (offset < buffer.length) {
73
+ if (buffer[offset] !== 255) {
74
+ offset++;
75
+ continue;
76
+ }
77
+ const marker = buffer[offset + 1];
78
+ if (marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207) {
79
+ if (offset + 9 <= buffer.length) {
80
+ return { height: readUInt16BE(buffer, offset + 5), width: readUInt16BE(buffer, offset + 7) };
81
+ }
82
+ return void 0;
83
+ }
84
+ if (marker === 217 || offset + 4 >= buffer.length) break;
85
+ const segmentLength = buffer.readUInt16BE(offset + 2);
86
+ offset += 2 + segmentLength;
87
+ }
88
+ return void 0;
59
89
  }
60
- function containsUnsupportedContent(messages) {
61
- return messages.some((message) => hasImageContent(message.content));
90
+ function parseGifDimensions(buffer) {
91
+ if (buffer.length < 10) return void 0;
92
+ return { width: readLittleEndian(buffer, 6), height: readLittleEndian(buffer, 8) };
93
+ }
94
+ function parseWebpDimensions(buffer) {
95
+ if (buffer.length < 30) return void 0;
96
+ const riff = buffer.toString("ascii", 0, 4);
97
+ const webp = buffer.toString("ascii", 8, 12);
98
+ if (riff !== "RIFF" || webp !== "WEBP") return void 0;
99
+ const chunkType = buffer.toString("ascii", 12, 16);
100
+ if (chunkType === "VP8 " && buffer.length >= 26) {
101
+ return { width: readLittleEndian(buffer, 26), height: readLittleEndian(buffer, 28) };
102
+ }
103
+ if (chunkType === "VP8L" && buffer.length >= 24) {
104
+ const bits = buffer.readUInt32LE(21);
105
+ return {
106
+ width: (bits & 16383) + 1,
107
+ height: (bits >> 14 & 16383) + 1
108
+ };
109
+ }
110
+ if (chunkType === "VP8X" && buffer.length >= 30) {
111
+ return {
112
+ width: ((buffer[24] | buffer[25] << 8 | buffer[26] << 16) & 16777215) + 1,
113
+ height: ((buffer[27] | buffer[28] << 8 | buffer[29] << 16) & 16777215) + 1
114
+ };
115
+ }
116
+ return void 0;
117
+ }
118
+ function parseImageDimensions(buffer) {
119
+ if (buffer.length < 12) return void 0;
120
+ const header = buffer.toString("binary", 0, 4);
121
+ if (header === "\x89PNG") return parsePngDimensions(buffer);
122
+ if (header === "GIF8") return parseGifDimensions(buffer);
123
+ if (header === "RIFF") return parseWebpDimensions(buffer);
124
+ if (buffer[0] === 255 && buffer[1] === 216) return parseJpegDimensions(buffer);
125
+ return void 0;
126
+ }
127
+ const VISION_TILE_SIZE = 512;
128
+ const VISION_LOW_DETAIL_TOKENS = 85;
129
+ const VISION_TILE_TOKENS = 170;
130
+ const VISION_HTTP_URL_ESTIMATE = 1024;
131
+ const FILE_BASE64_FALLBACK_TOKENS = 1024;
132
+ function estimateVisionTokensForDimensions(width, height) {
133
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
134
+ return VISION_LOW_DETAIL_TOKENS;
135
+ }
136
+ const tilesX = Math.ceil(width / VISION_TILE_SIZE);
137
+ const tilesY = Math.ceil(height / VISION_TILE_SIZE);
138
+ return VISION_LOW_DETAIL_TOKENS + tilesX * tilesY * VISION_TILE_TOKENS;
139
+ }
140
+ function decodeBase64DataUrl(url) {
141
+ const match = /^data:([^;]+);base64,([A-Za-z0-9+/]+=?=?)$/.exec(url);
142
+ if (!match) return void 0;
143
+ try {
144
+ const buffer = Buffer.from(match[2], "base64");
145
+ return { mimeType: match[1].toLowerCase(), buffer };
146
+ } catch {
147
+ return void 0;
148
+ }
149
+ }
150
+ function estimateImageUrlTokens(imageUrl) {
151
+ const url = typeof imageUrl === "string" ? imageUrl : isRecord(imageUrl) ? String(imageUrl.url ?? "") : "";
152
+ if (!url) return 0;
153
+ if (url.startsWith("data:")) {
154
+ const decoded = decodeBase64DataUrl(url);
155
+ if (!decoded) return FILE_BASE64_FALLBACK_TOKENS;
156
+ if (!decoded.mimeType.startsWith("image/")) return FILE_BASE64_FALLBACK_TOKENS;
157
+ const dimensions = parseImageDimensions(decoded.buffer);
158
+ return dimensions ? estimateVisionTokensForDimensions(dimensions.width, dimensions.height) : VISION_LOW_DETAIL_TOKENS;
159
+ }
160
+ if (url.startsWith("http://") || url.startsWith("https://")) {
161
+ return VISION_HTTP_URL_ESTIMATE;
162
+ }
163
+ return 0;
164
+ }
165
+ function estimateFileBlockTokens(block) {
166
+ const file = isRecord(block.file) ? block.file : void 0;
167
+ if (!file) return 0;
168
+ const fileData = String(file.file_data ?? "");
169
+ if (fileData.startsWith("data:")) {
170
+ const decoded = decodeBase64DataUrl(fileData);
171
+ if (decoded) {
172
+ return Math.max(1, Math.ceil(decoded.buffer.length / 3));
173
+ }
174
+ return FILE_BASE64_FALLBACK_TOKENS;
175
+ }
176
+ return FILE_BASE64_FALLBACK_TOKENS;
177
+ }
178
+ function estimateContentBlockTokens(block) {
179
+ if (!isRecord(block)) return estimateValueTokens(block);
180
+ const type = typeof block.type === "string" ? block.type : void 0;
181
+ if (type === "text") {
182
+ return typeof block.text === "string" ? estimateValueTokens(block.text) + 4 : 4;
183
+ }
184
+ if (type === "image_url") {
185
+ return estimateImageUrlTokens(block.image_url) + 4;
186
+ }
187
+ if (type === "file") {
188
+ return estimateFileBlockTokens(block) + 4;
189
+ }
190
+ if (type === "file_url") {
191
+ return VISION_HTTP_URL_ESTIMATE + 4;
192
+ }
193
+ return estimateValueTokens(block) + 4;
194
+ }
195
+ function estimateMessageTokens(message) {
196
+ if (Array.isArray(message.content)) {
197
+ return message.content.reduce((total, block) => total + estimateContentBlockTokens(block), 4);
198
+ }
199
+ return estimateValueTokens(message) + 4;
200
+ }
201
+ function estimateMessagesTokens(messages) {
202
+ return messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
62
203
  }
63
204
  function isInstruction(message) {
64
205
  return message.role === "system" || message.role === "developer";
@@ -97,16 +238,16 @@ async function loadModelMetadata(ctx, serviceName, modelId) {
97
238
  `Context metadata is not configured for '${serviceName}/${modelId}'. Configure context window and max completion tokens.`
98
239
  );
99
240
  }
100
- return { contextWindow, maxCompletionTokens };
241
+ const systemPromptValue = getValue(row, "systemPrompt");
242
+ const systemPrompt = typeof systemPromptValue === "string" ? systemPromptValue.trim() : "";
243
+ return { contextWindow, maxCompletionTokens, ...systemPrompt ? { systemPrompt } : {} };
101
244
  }
102
245
  async function resolveOverflowBehavior(ctx) {
103
246
  var _a;
104
247
  const userId = (_a = ctx.state.currentUser) == null ? void 0 : _a.id;
105
248
  if (userId === null || userId === void 0) return "reject";
106
- const policy = await ctx.db.getRepository("aiApiUserQuotaPolicies").findOne({
107
- filter: { userId, enabled: true }
108
- });
109
- return getValue(policy, "contextOverflowBehavior") === "truncate" ? "truncate" : "reject";
249
+ const group = await (0, import_request_cache.resolveRequestUserGroup)(ctx, userId);
250
+ return group.contextOverflowBehavior === "truncate" ? "truncate" : "reject";
110
251
  }
111
252
  function resolveReservedOutputTokens(options, metadata) {
112
253
  const requested = positiveInteger(options.maxCompletionTokens ?? options.maxTokens);
@@ -119,12 +260,6 @@ function resolveReservedOutputTokens(options, metadata) {
119
260
  return requested ?? metadata.maxCompletionTokens;
120
261
  }
121
262
  async function prepareDirectLlmContext(ctx, options) {
122
- if (containsUnsupportedContent(options.messages)) {
123
- throw new DirectLlmContextError(
124
- "context_estimation_unsupported",
125
- "Context enforcement does not support image_url content without a model-specific vision token estimator."
126
- );
127
- }
128
263
  const [metadata, behavior] = await Promise.all([
129
264
  loadModelMetadata(ctx, options.serviceName, options.modelId),
130
265
  resolveOverflowBehavior(ctx)
@@ -138,10 +273,11 @@ async function prepareDirectLlmContext(ctx, options) {
138
273
  );
139
274
  }
140
275
  const fixedOverheadTokens = estimateValueTokens(options.tools) + (options.tools === void 0 ? 0 : 4);
141
- const originalEstimate = estimateMessagesTokens(options.messages) + fixedOverheadTokens;
276
+ const baseMessages = metadata.systemPrompt ? [{ role: "system", content: metadata.systemPrompt }, ...options.messages] : options.messages;
277
+ const originalEstimate = estimateMessagesTokens(baseMessages) + fixedOverheadTokens;
142
278
  if (originalEstimate <= inputTokenBudget) {
143
279
  return {
144
- messages: options.messages,
280
+ messages: baseMessages,
145
281
  estimatedInputTokens: originalEstimate,
146
282
  inputTokenBudget,
147
283
  reservedOutputTokens,
@@ -154,13 +290,13 @@ async function prepareDirectLlmContext(ctx, options) {
154
290
  `Estimated input tokens (${originalEstimate}) exceed the allowed input budget (${inputTokenBudget}).`
155
291
  );
156
292
  }
157
- const { turns } = splitTurns(options.messages);
293
+ const { turns } = splitTurns(baseMessages);
158
294
  let remainingTurns = turns;
159
- let messages = messagesWithTurns(options.messages, remainingTurns);
295
+ let messages = messagesWithTurns(baseMessages, remainingTurns);
160
296
  let estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
161
297
  while (remainingTurns.length > 1 && estimatedInputTokens > inputTokenBudget) {
162
298
  remainingTurns = remainingTurns.slice(1);
163
- messages = messagesWithTurns(options.messages, remainingTurns);
299
+ messages = messagesWithTurns(baseMessages, remainingTurns);
164
300
  estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
165
301
  }
166
302
  if (estimatedInputTokens > inputTokenBudget) {
@@ -174,11 +310,12 @@ async function prepareDirectLlmContext(ctx, options) {
174
310
  estimatedInputTokens,
175
311
  inputTokenBudget,
176
312
  reservedOutputTokens,
177
- truncated: messages.length !== options.messages.length
313
+ truncated: messages.length !== baseMessages.length
178
314
  };
179
315
  }
180
316
  // Annotate the CommonJS export names for ESM import in node:
181
317
  0 && (module.exports = {
182
318
  DirectLlmContextError,
319
+ parseImageDimensions,
183
320
  prepareDirectLlmContext
184
321
  });
@@ -99,7 +99,19 @@ function toOpenAIResponse(options) {
99
99
  finish_reason: finishReason
100
100
  }
101
101
  ],
102
- usage: usage || { prompt_tokens: null, completion_tokens: null, total_tokens: null }
102
+ usage: usage ? {
103
+ prompt_tokens: usage.prompt_tokens ?? null,
104
+ completion_tokens: usage.completion_tokens ?? null,
105
+ total_tokens: usage.total_tokens ?? null,
106
+ prompt_tokens_details: {
107
+ cached_tokens: usage.prompt_cache_tokens ?? null
108
+ }
109
+ } : {
110
+ prompt_tokens: null,
111
+ completion_tokens: null,
112
+ total_tokens: null,
113
+ prompt_tokens_details: { cached_tokens: null }
114
+ }
103
115
  };
104
116
  }
105
117
  function toOpenAIStreamChunk(options) {
@@ -129,7 +141,14 @@ function toOpenAIUsageChunk(options) {
129
141
  created: Math.floor(Date.now() / 1e3),
130
142
  model,
131
143
  choices: [],
132
- usage
144
+ usage: {
145
+ prompt_tokens: usage.prompt_tokens,
146
+ completion_tokens: usage.completion_tokens,
147
+ total_tokens: usage.total_tokens,
148
+ prompt_tokens_details: {
149
+ cached_tokens: usage.prompt_cache_tokens ?? null
150
+ }
151
+ }
133
152
  };
134
153
  }
135
154
  function toOpenAIEmbeddingsResponse(options) {
@@ -39,7 +39,7 @@ class RateLimiter {
39
39
  * Check and record a request for a user.
40
40
  *
41
41
  * @param userId The user ID (string or numeric)
42
- * @param limit Max allowed requests per window (from aiApiConfig.rateLimitPerMinute)
42
+ * @param limit Max allowed requests per window (from the user's usage group)
43
43
  * @returns { allowed: true } or { allowed: false, retryAfterMs: number }
44
44
  */
45
45
  check(userId, limit) {
@@ -0,0 +1,61 @@
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
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
12
+ var __getOwnPropNames = Object.getOwnPropertyNames;
13
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
14
+ var __export = (target, all) => {
15
+ for (var name in all)
16
+ __defProp(target, name, { get: all[name], enumerable: true });
17
+ };
18
+ var __copyProps = (to, from, except, desc) => {
19
+ if (from && typeof from === "object" || typeof from === "function") {
20
+ for (let key of __getOwnPropNames(from))
21
+ if (!__hasOwnProp.call(to, key) && key !== except)
22
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
23
+ }
24
+ return to;
25
+ };
26
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
27
+ var request_cache_exports = {};
28
+ __export(request_cache_exports, {
29
+ getAiApiConfig: () => getAiApiConfig,
30
+ resolveRequestUserGroup: () => resolveRequestUserGroup
31
+ });
32
+ module.exports = __toCommonJS(request_cache_exports);
33
+ var import_quota_groups = require("../quota-groups");
34
+ function getCache(ctx) {
35
+ if (!ctx.state.aiApiRequestCache) {
36
+ ctx.state.aiApiRequestCache = { configLoaded: false, config: null };
37
+ }
38
+ return ctx.state.aiApiRequestCache;
39
+ }
40
+ async function getAiApiConfig(ctx) {
41
+ const cache = getCache(ctx);
42
+ if (!cache.configLoaded) {
43
+ cache.config = await ctx.db.getRepository("aiApiConfig").findOne() ?? null;
44
+ cache.configLoaded = true;
45
+ }
46
+ return cache.config;
47
+ }
48
+ async function resolveRequestUserGroup(ctx, userId) {
49
+ const cache = getCache(ctx);
50
+ const groupKey = userId === void 0 || userId === null ? "" : String(userId);
51
+ if (!cache.group || cache.groupKey !== groupKey) {
52
+ cache.group = await (0, import_quota_groups.resolveUserGroup)(ctx, userId);
53
+ cache.groupKey = groupKey;
54
+ }
55
+ return cache.group;
56
+ }
57
+ // Annotate the CommonJS export names for ESM import in node:
58
+ 0 && (module.exports = {
59
+ getAiApiConfig,
60
+ resolveRequestUserGroup
61
+ });
@@ -30,6 +30,7 @@ __export(resolve_service_exports, {
30
30
  resolveModelString: () => resolveModelString
31
31
  });
32
32
  module.exports = __toCommonJS(resolve_service_exports);
33
+ var import_request_cache = require("./request-cache");
33
34
  async function resolveLlmService(ctx, serviceKey) {
34
35
  const repo = ctx.db.getRepository("llmServices");
35
36
  let service = await repo.findOne({ filter: { name: serviceKey } });
@@ -60,7 +61,7 @@ async function resolveModelString(ctx, modelString) {
60
61
  }
61
62
  }
62
63
  }
63
- const config = await ctx.db.getRepository("aiApiConfig").findOne();
64
+ const config = await (0, import_request_cache.getAiApiConfig)(ctx);
64
65
  if (config == null ? void 0 : config.defaultLlmService) {
65
66
  const service = await repo.findOne({ filter: { name: config.defaultLlmService } });
66
67
  if (service) {
@@ -28,73 +28,60 @@ var user_permissions_exports = {};
28
28
  __export(user_permissions_exports, {
29
29
  buildAccessScope: () => buildAccessScope,
30
30
  enforceModelAccess: () => enforceModelAccess,
31
- invalidateUserPermissionCache: () => invalidateUserPermissionCache,
31
+ invalidateGroupAccessCache: () => invalidateGroupAccessCache,
32
32
  isModelAllowed: () => isModelAllowed,
33
33
  isServiceAllowed: () => isServiceAllowed,
34
34
  resolveUserAccessScope: () => resolveUserAccessScope
35
35
  });
36
36
  module.exports = __toCommonJS(user_permissions_exports);
37
37
  var import_openai_format = require("./openai-format");
38
+ var import_request_cache = require("./request-cache");
38
39
  const SCOPE_TTL_MS = 15e3;
39
40
  const scopeCache = /* @__PURE__ */ new Map();
40
- const NO_RECORD_SCOPE = {
41
- hasUserRecord: false,
42
- denyAll: false,
43
- allowedServices: null,
41
+ const OPEN_SCOPE = {
42
+ allowedServices: [],
44
43
  allowAllModels: true,
45
44
  allowedModels: /* @__PURE__ */ new Set(),
46
45
  lookupFailed: false
47
46
  };
48
- const LOOKUP_FAILED_SCOPE = { ...NO_RECORD_SCOPE, denyAll: true, lookupFailed: true };
49
- function invalidateUserPermissionCache(userId) {
50
- if (userId === void 0 || userId === null) {
47
+ const LOOKUP_FAILED_SCOPE = { ...OPEN_SCOPE, lookupFailed: true };
48
+ function invalidateGroupAccessCache(groupId) {
49
+ if (groupId === void 0 || groupId === null) {
51
50
  scopeCache.clear();
52
51
  return;
53
52
  }
54
- const suffix = `:${userId}`;
53
+ const suffix = `:group:${groupId}`;
55
54
  for (const key of scopeCache.keys()) {
56
55
  if (key.endsWith(suffix)) scopeCache.delete(key);
57
56
  }
58
57
  }
59
- function valueOf(row, name) {
60
- if (!row) return void 0;
61
- const candidate = row;
62
- if (typeof candidate.get === "function") return candidate.get(name);
63
- return row[name];
64
- }
65
58
  function toStringArray(value) {
66
59
  if (!Array.isArray(value)) return [];
67
60
  return value.filter((item) => typeof item === "string" && item.length > 0);
68
61
  }
69
- function buildAccessScope(row) {
70
- if (!row) return NO_RECORD_SCOPE;
71
- if (valueOf(row, "enabled") === false) {
72
- return { ...NO_RECORD_SCOPE, hasUserRecord: true, denyAll: true, allowedServices: [] };
73
- }
62
+ function buildAccessScope(group) {
74
63
  return {
75
- hasUserRecord: true,
76
- denyAll: false,
77
- allowedServices: toStringArray(valueOf(row, "allowedLlmServices")),
78
- allowAllModels: valueOf(row, "allowAllModels") !== false,
79
- allowedModels: new Set(toStringArray(valueOf(row, "allowedModels"))),
64
+ groupId: group.id,
65
+ allowedServices: toStringArray(group.allowedLlmServices),
66
+ allowAllModels: group.allowAllModels !== false,
67
+ allowedModels: new Set(toStringArray(group.allowedModels)),
80
68
  lookupFailed: false
81
69
  };
82
70
  }
83
71
  async function resolveUserAccessScope(ctx) {
84
72
  var _a, _b, _c, _d;
85
73
  const userId = (_a = ctx.state.currentUser) == null ? void 0 : _a.id;
86
- if (userId === void 0 || userId === null) return NO_RECORD_SCOPE;
87
- const key = `${((_b = ctx.app) == null ? void 0 : _b.name) ?? "main"}:${userId}`;
88
- const cached = scopeCache.get(key);
89
- if (cached && cached.expiresAt > Date.now()) return cached.scope;
90
- let scope;
74
+ let group;
91
75
  try {
92
- const row = await ctx.db.getRepository("aiApiUserPermissions").findOne({ filter: { userId } });
93
- scope = buildAccessScope(row);
76
+ group = await (0, import_request_cache.resolveRequestUserGroup)(ctx, userId);
94
77
  } catch (err) {
95
- (_d = (_c = ctx.log) == null ? void 0 : _c.error) == null ? void 0 : _d.call(_c, "AI API user permissions lookup failed, denying access:", err);
78
+ (_c = (_b = ctx.log) == null ? void 0 : _b.error) == null ? void 0 : _c.call(_b, "AI API group access lookup failed, denying access:", err);
96
79
  return LOOKUP_FAILED_SCOPE;
97
80
  }
81
+ const key = `${((_d = ctx.app) == null ? void 0 : _d.name) ?? "main"}:group:${group.id}`;
82
+ const cached = scopeCache.get(key);
83
+ if (cached && cached.expiresAt > Date.now()) return cached.scope;
84
+ const scope = buildAccessScope(group);
98
85
  scopeCache.set(key, { scope, expiresAt: Date.now() + SCOPE_TTL_MS });
99
86
  return scope;
100
87
  }
@@ -102,15 +89,14 @@ function matchesService(list, serviceName, serviceTitle) {
102
89
  return list.some((entry) => entry === serviceName || entry === serviceTitle);
103
90
  }
104
91
  function isServiceAllowed(scope, globalEnabledServices, service) {
105
- if (scope.denyAll) return false;
92
+ if (scope.lookupFailed) return false;
106
93
  const globalList = toStringArray(globalEnabledServices);
107
94
  if (globalList.length && !matchesService(globalList, service.name, service.title)) return false;
108
- if (!scope.hasUserRecord) return true;
109
- return matchesService(scope.allowedServices ?? [], service.name, service.title);
95
+ if (!scope.allowedServices.length) return true;
96
+ return matchesService(scope.allowedServices, service.name, service.title);
110
97
  }
111
98
  function isModelAllowed(scope, fullModelId) {
112
- if (scope.denyAll) return false;
113
- if (!scope.hasUserRecord) return true;
99
+ if (scope.lookupFailed) return false;
114
100
  if (scope.allowAllModels) return true;
115
101
  return scope.allowedModels.has(fullModelId);
116
102
  }
@@ -153,7 +139,7 @@ async function enforceModelAccess(ctx, globalEnabledServices, service, modelId)
153
139
  0 && (module.exports = {
154
140
  buildAccessScope,
155
141
  enforceModelAccess,
156
- invalidateUserPermissionCache,
142
+ invalidateGroupAccessCache,
157
143
  isModelAllowed,
158
144
  isServiceAllowed,
159
145
  resolveUserAccessScope
@@ -88,6 +88,10 @@ function validateModelMetadata(model) {
88
88
  if (!String(model.get("model") ?? "").trim()) throw new Error("model is required.");
89
89
  requirePositiveIntegerOrNull(model.get("contextWindow"), "contextWindow");
90
90
  requirePositiveIntegerOrNull(model.get("maxCompletionTokens"), "maxCompletionTokens");
91
+ const systemPrompt = model.get("systemPrompt");
92
+ if (systemPrompt !== null && systemPrompt !== void 0 && typeof systemPrompt !== "string") {
93
+ throw new Error("systemPrompt must be a string.");
94
+ }
91
95
  const contextWindow = model.get("contextWindow");
92
96
  const maxCompletionTokens = model.get("maxCompletionTokens");
93
97
  if (contextWindow !== null && contextWindow !== void 0 && contextWindow !== "" && maxCompletionTokens !== null && maxCompletionTokens !== void 0 && maxCompletionTokens !== "" && Number(maxCompletionTokens) > Number(contextWindow)) {
@@ -98,6 +102,9 @@ function validateQuotaPolicy(model) {
98
102
  if (!["daily", "monthly"].includes(String(model.get("periodType")))) {
99
103
  throw new Error("periodType must be daily or monthly.");
100
104
  }
105
+ if (!["share", "per_user"].includes(String(model.get("quotaMode")))) {
106
+ throw new Error("quotaMode must be share or per_user.");
107
+ }
101
108
  if (!["allow", "use_reserved"].includes(String(model.get("missingUsageBehavior")))) {
102
109
  throw new Error("missingUsageBehavior must be allow or use_reserved.");
103
110
  }
package/dist/swagger.js CHANGED
@@ -78,7 +78,7 @@ var swagger_default = {
78
78
  get: {
79
79
  tags: ["ai-llm"],
80
80
  summary: "List available models",
81
- description: "Returns the LLM models available to the authenticated caller across registered services. Model IDs are formatted as `serviceName/modelId`.\n\nThe catalog is user-scoped: it starts from `enabledLlmServices` in the AI API configuration, then narrows to the caller's `aiApiUserPermissions` record when one exists. A user grant can only narrow the global whitelist, never widen it, so two users may receive different lists from the same request.",
81
+ description: "Returns the LLM models available to the authenticated caller across registered services. Model IDs are formatted as `serviceName/modelId`.\n\nThe catalog is user-scoped: it starts from `enabledLlmServices` in the AI API configuration, then narrows to the caller's usage group settings (`allowedLlmServices` / `allowedModels`). Group settings can only narrow the global whitelist, never widen it, so two users may receive different lists from the same request.",
82
82
  security: [{ BearerAuth: [] }],
83
83
  responses: {
84
84
  200: {
@@ -268,16 +268,15 @@ var swagger_default = {
268
268
  enum: ["llm", "agent"],
269
269
  description: "Default AI mode"
270
270
  },
271
- defaultAiEmployee: { type: "string", description: "Default AI employee name" },
271
+ defaultAiEmployee: {
272
+ type: "string",
273
+ description: "Default AI employee name (agent mode only; direct LLM mode ignores it)"
274
+ },
272
275
  defaultLlmService: { type: "string", description: "Default LLM service name" },
273
276
  enabledLlmServices: {
274
277
  type: "array",
275
278
  items: { type: "string" },
276
- description: "List of enabled LLM service names. This is the outer bound for every caller; per-user `aiApiUserPermissions` records can only narrow it further."
277
- },
278
- rateLimitPerMinute: {
279
- type: "integer",
280
- description: "Max requests per minute per user (0 = unlimited)"
279
+ description: "List of enabled LLM service names. This is the outer bound for every caller; usage group settings can only narrow it further."
281
280
  },
282
281
  maxRequestBodyMb: {
283
282
  type: "integer",
@@ -285,6 +284,11 @@ var swagger_default = {
285
284
  maximum: 100,
286
285
  default: 10,
287
286
  description: "Max request body size in megabytes. Requests above this return 413. The gateway buffers each body in memory, so values above 100 are rejected."
287
+ },
288
+ pdfRenderPagesAsImages: {
289
+ type: "boolean",
290
+ default: false,
291
+ description: "When true, PDF file/file_url blocks are rendered to per-page PNG images and sent as image_url blocks. Requires a registered PdfToImageRenderer. When false or no renderer is available, PDFs are forwarded as file blocks."
288
292
  }
289
293
  }
290
294
  },
@@ -299,9 +303,9 @@ var swagger_default = {
299
303
  },
300
304
  ContentBlock: {
301
305
  type: "object",
302
- description: "A multimodal content block. Only text and image_url blocks are forwarded to the provider; any other type is rejected with 400 unsupported_content_block.",
306
+ description: "A multimodal content block. text, image_url, file and file_url blocks are forwarded; file and file_url blocks are first run through the configurable file processor service.",
303
307
  properties: {
304
- type: { type: "string", enum: ["text", "image_url"] },
308
+ type: { type: "string", enum: ["text", "image_url", "file", "file_url"] },
305
309
  text: { type: "string" },
306
310
  image_url: {
307
311
  type: "object",
@@ -314,6 +318,34 @@ var swagger_default = {
314
318
  detail: { type: "string", enum: ["auto", "low", "high"] }
315
319
  },
316
320
  required: ["url"]
321
+ },
322
+ file: {
323
+ type: "object",
324
+ properties: {
325
+ file_data: {
326
+ type: "string",
327
+ description: "A base64 data URL, e.g. data:application/pdf;base64,JVBERi0...",
328
+ example: "data:application/pdf;base64,JVBERi0..."
329
+ },
330
+ filename: { type: "string" },
331
+ mime_type: {
332
+ type: "string",
333
+ description: "MIME type of the file, e.g. application/pdf",
334
+ example: "application/pdf"
335
+ }
336
+ },
337
+ required: ["file_data"]
338
+ },
339
+ file_url: {
340
+ type: "object",
341
+ properties: {
342
+ url: {
343
+ type: "string",
344
+ description: "An http(s) URL pointing to a file. The gateway downloads the file and converts it to a file block.",
345
+ example: "https://example.com/document.pdf"
346
+ }
347
+ },
348
+ required: ["url"]
317
349
  }
318
350
  },
319
351
  required: ["type"]
@@ -374,7 +406,13 @@ var swagger_default = {
374
406
  properties: {
375
407
  prompt_tokens: { type: "integer" },
376
408
  completion_tokens: { type: "integer" },
377
- total_tokens: { type: "integer" }
409
+ total_tokens: { type: "integer" },
410
+ prompt_tokens_details: {
411
+ type: "object",
412
+ properties: {
413
+ cached_tokens: { type: "integer" }
414
+ }
415
+ }
378
416
  }
379
417
  }
380
418
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-ai-api",
3
- "version": "1.0.24",
3
+ "version": "1.0.28",
4
4
  "main": "dist/server/index.js",
5
5
  "dependencies": {},
6
6
  "peerDependencies": {