plugin-ai-api 1.0.21 → 1.0.24
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/123.e6fe04c856ce6417.js +10 -0
- package/dist/client/902.e74518750f1e4201.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
- package/dist/client-v2/902.c7c00a565085438a.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +5 -2
- package/dist/locale/en-US.json +15 -1
- package/dist/locale/vi-VN.json +15 -1
- package/dist/locale/zh-CN.json +15 -1
- package/dist/server/collections/ai-api-user-permissions.js +67 -0
- package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
- package/dist/server/plugin.js +32 -0
- package/dist/server/resource/ai-api-user-permissions.js +75 -0
- package/dist/server/routes/agent-completions.js +5 -0
- package/dist/server/routes/chat-completions.js +52 -27
- package/dist/server/routes/completions.js +59 -33
- package/dist/server/routes/embeddings.js +6 -14
- package/dist/server/routes/models.js +24 -0
- package/dist/server/utils/direct-llm-context.js +184 -0
- package/dist/server/utils/openai-format.js +17 -3
- package/dist/server/utils/user-permissions.js +160 -0
- package/dist/server/validation.js +3 -0
- package/dist/swagger.js +4 -3
- package/package.json +2 -2
- package/src/client/__tests__/settings-registration.test.tsx +69 -0
- package/src/client/plugin.tsx +14 -3
- package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
- package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
- package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
- package/src/client-v2/plugin.tsx +12 -3
- package/src/constants.ts +7 -0
- package/src/locale/en-US.json +15 -1
- package/src/locale/vi-VN.json +15 -1
- package/src/locale/zh-CN.json +15 -1
- package/src/server/__tests__/direct-llm-context.test.ts +125 -0
- package/src/server/__tests__/models.test.ts +44 -2
- package/src/server/__tests__/openai-format.test.ts +52 -1
- package/src/server/__tests__/permission-sync.test.ts +109 -0
- package/src/server/__tests__/usage-route.test.ts +265 -5
- package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
- package/src/server/__tests__/user-permissions.test.ts +284 -0
- package/src/server/__tests__/validation.test.ts +36 -0
- package/src/server/collections/ai-api-user-permissions.ts +46 -0
- package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
- package/src/server/plugin.ts +42 -1
- package/src/server/resource/ai-api-user-permissions.ts +76 -0
- package/src/server/routes/agent-completions.ts +7 -0
- package/src/server/routes/chat-completions.ts +58 -30
- package/src/server/routes/completions.ts +68 -34
- package/src/server/routes/embeddings.ts +10 -15
- package/src/server/routes/models.ts +28 -0
- package/src/server/utils/direct-llm-context.ts +216 -0
- package/src/server/utils/openai-format.ts +26 -0
- package/src/server/utils/user-permissions.ts +218 -0
- package/src/server/validation.ts +3 -0
- package/src/swagger.ts +9 -3
- package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
- package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
|
@@ -38,8 +38,10 @@ var import_openai_format = require("../utils/openai-format");
|
|
|
38
38
|
var import_resolve_service = require("../utils/resolve-service");
|
|
39
39
|
var import_streaming = require("../utils/streaming");
|
|
40
40
|
var import_role_permission = require("../middleware/role-permission");
|
|
41
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
41
42
|
var import_usage = require("../usage");
|
|
42
43
|
var import_billing = require("../billing");
|
|
44
|
+
var import_direct_llm_context = require("../utils/direct-llm-context");
|
|
43
45
|
var import_app_observability = require("../utils/app-observability");
|
|
44
46
|
async function handleChatCompletions(ctx, plugin) {
|
|
45
47
|
var _a;
|
|
@@ -117,20 +119,8 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
117
119
|
return;
|
|
118
120
|
}
|
|
119
121
|
const config = await ctx.db.getRepository("aiApiConfig").findOne();
|
|
120
|
-
if ((
|
|
121
|
-
|
|
122
|
-
const serviceTitle = service.title;
|
|
123
|
-
const isAllowed = config.enabledLlmServices.some((s) => s === serviceName || s === serviceTitle);
|
|
124
|
-
if (!isAllowed) {
|
|
125
|
-
ctx.status = 403;
|
|
126
|
-
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
127
|
-
403,
|
|
128
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
129
|
-
"invalid_request_error",
|
|
130
|
-
"model_not_available"
|
|
131
|
-
);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
122
|
+
if (!await (0, import_user_permissions.enforceModelAccess)(ctx, config == null ? void 0 : config.enabledLlmServices, service, modelId)) {
|
|
123
|
+
return;
|
|
134
124
|
}
|
|
135
125
|
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
136
126
|
if (!providerMeta) {
|
|
@@ -138,8 +128,14 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
138
128
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
|
|
139
129
|
return;
|
|
140
130
|
}
|
|
141
|
-
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
142
131
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
132
|
+
if (stream) {
|
|
133
|
+
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
|
|
134
|
+
providerRequestParameters.stream_options = {
|
|
135
|
+
...streamOptions,
|
|
136
|
+
include_usage: true
|
|
137
|
+
};
|
|
138
|
+
}
|
|
143
139
|
const modelOptions = {
|
|
144
140
|
model: modelId,
|
|
145
141
|
llmService: service.name
|
|
@@ -151,12 +147,6 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
151
147
|
if (body.frequency_penalty !== void 0) modelOptions.frequencyPenalty = body.frequency_penalty;
|
|
152
148
|
if (body.presence_penalty !== void 0) modelOptions.presencePenalty = body.presence_penalty;
|
|
153
149
|
if (body.stop !== void 0) modelOptions.stop = body.stop;
|
|
154
|
-
const Provider = providerMeta.provider;
|
|
155
|
-
const provider = new Provider({
|
|
156
|
-
app: ctx.app,
|
|
157
|
-
serviceOptions: service.options,
|
|
158
|
-
modelOptions
|
|
159
|
-
});
|
|
160
150
|
let systemPrompt = "";
|
|
161
151
|
if (config == null ? void 0 : config.defaultAiEmployee) {
|
|
162
152
|
if (!(0, import_role_permission.checkEmployeeAccess)(ctx, config.defaultAiEmployee)) {
|
|
@@ -176,11 +166,27 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
176
166
|
systemPrompt = employee.about || employee.defaultPrompt || "";
|
|
177
167
|
}
|
|
178
168
|
}
|
|
179
|
-
|
|
169
|
+
let messages = [...body.messages];
|
|
180
170
|
const hasSystemMessage = messages.some((m) => m.role === "system");
|
|
181
171
|
if (systemPrompt && !hasSystemMessage) {
|
|
182
172
|
messages.unshift({ role: "system", content: systemPrompt });
|
|
183
173
|
}
|
|
174
|
+
const preparedContext = await (0, import_direct_llm_context.prepareDirectLlmContext)(ctx, {
|
|
175
|
+
serviceName: service.name,
|
|
176
|
+
modelId,
|
|
177
|
+
messages,
|
|
178
|
+
tools: body.tools,
|
|
179
|
+
maxCompletionTokens: body.max_completion_tokens,
|
|
180
|
+
maxTokens: body.max_tokens
|
|
181
|
+
});
|
|
182
|
+
messages = preparedContext.messages;
|
|
183
|
+
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
184
|
+
const Provider = providerMeta.provider;
|
|
185
|
+
const provider = new Provider({
|
|
186
|
+
app: ctx.app,
|
|
187
|
+
serviceOptions: service.options,
|
|
188
|
+
modelOptions
|
|
189
|
+
});
|
|
184
190
|
const langchainMessages = messages.map((msg) => {
|
|
185
191
|
const role = msg.role === "assistant" ? "ai" : msg.role;
|
|
186
192
|
const content = normalizeMessageContent(msg.content);
|
|
@@ -223,15 +229,16 @@ async function handleChatCompletions(ctx, plugin) {
|
|
|
223
229
|
}
|
|
224
230
|
} catch (err) {
|
|
225
231
|
ctx.log.error("AI API chat completions error:", err);
|
|
226
|
-
if (!ctx.res.headersSent) {
|
|
232
|
+
if (!((_a = ctx.res) == null ? void 0 : _a.headersSent)) {
|
|
227
233
|
const isQuotaError = err instanceof import_billing.AiApiQuotaError;
|
|
228
|
-
|
|
234
|
+
const isContextError = err instanceof import_direct_llm_context.DirectLlmContextError;
|
|
235
|
+
ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
|
|
229
236
|
if (isQuotaError) ctx.set("X-RateLimit-Reason", err.code);
|
|
230
237
|
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
231
238
|
ctx.status,
|
|
232
239
|
getErrorMessage(err, "Internal server error"),
|
|
233
|
-
isQuotaError ? "quota_error" : "server_error",
|
|
234
|
-
isQuotaError ? err.code : void 0
|
|
240
|
+
isQuotaError ? "quota_error" : isContextError ? "invalid_request_error" : "server_error",
|
|
241
|
+
isQuotaError || isContextError ? err.code : void 0
|
|
235
242
|
);
|
|
236
243
|
}
|
|
237
244
|
}
|
|
@@ -312,7 +319,13 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
312
319
|
finishReason = "tool_calls";
|
|
313
320
|
await (0, import_streaming.writeResponse)(
|
|
314
321
|
ctx,
|
|
315
|
-
(0, import_openai_format.formatSSE)(
|
|
322
|
+
(0, import_openai_format.formatSSE)(
|
|
323
|
+
(0, import_openai_format.toOpenAIStreamChunk)({
|
|
324
|
+
id: completionId,
|
|
325
|
+
model: modelName,
|
|
326
|
+
delta: { tool_calls: toolCallChunks }
|
|
327
|
+
})
|
|
328
|
+
)
|
|
316
329
|
);
|
|
317
330
|
}
|
|
318
331
|
if (chunk.usage_metadata) {
|
|
@@ -331,6 +344,18 @@ async function handleStreamingCompletion(ctx, chatModel, messages, completionId,
|
|
|
331
344
|
})
|
|
332
345
|
)
|
|
333
346
|
);
|
|
347
|
+
if (usage) {
|
|
348
|
+
await (0, import_streaming.writeResponse)(
|
|
349
|
+
ctx,
|
|
350
|
+
(0, import_openai_format.formatSSE)(
|
|
351
|
+
(0, import_openai_format.toOpenAIUsageChunk)({
|
|
352
|
+
id: completionId,
|
|
353
|
+
model: modelName,
|
|
354
|
+
usage
|
|
355
|
+
})
|
|
356
|
+
)
|
|
357
|
+
);
|
|
358
|
+
}
|
|
334
359
|
await (0, import_streaming.writeResponse)(ctx, (0, import_openai_format.formatSSEDone)());
|
|
335
360
|
(0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
336
361
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
@@ -31,9 +31,11 @@ __export(completions_exports, {
|
|
|
31
31
|
module.exports = __toCommonJS(completions_exports);
|
|
32
32
|
var import_openai_format = require("../utils/openai-format");
|
|
33
33
|
var import_resolve_service = require("../utils/resolve-service");
|
|
34
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
34
35
|
var import_streaming = require("../utils/streaming");
|
|
35
36
|
var import_usage = require("../usage");
|
|
36
37
|
var import_billing = require("../billing");
|
|
38
|
+
var import_direct_llm_context = require("../utils/direct-llm-context");
|
|
37
39
|
var import_app_observability = require("../utils/app-observability");
|
|
38
40
|
async function handleCompletions(ctx, plugin) {
|
|
39
41
|
var _a;
|
|
@@ -89,20 +91,8 @@ async function handleCompletions(ctx, plugin) {
|
|
|
89
91
|
return;
|
|
90
92
|
}
|
|
91
93
|
const config = await ctx.db.getRepository("aiApiConfig").findOne();
|
|
92
|
-
if ((
|
|
93
|
-
|
|
94
|
-
const serviceTitle = service.title;
|
|
95
|
-
const isAllowed = config.enabledLlmServices.some((s) => s === serviceName || s === serviceTitle);
|
|
96
|
-
if (!isAllowed) {
|
|
97
|
-
ctx.status = 403;
|
|
98
|
-
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
99
|
-
403,
|
|
100
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
101
|
-
"invalid_request_error",
|
|
102
|
-
"model_not_available"
|
|
103
|
-
);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
94
|
+
if (!await (0, import_user_permissions.enforceModelAccess)(ctx, config == null ? void 0 : config.enabledLlmServices, service, modelId)) {
|
|
95
|
+
return;
|
|
106
96
|
}
|
|
107
97
|
const providerMeta = aiPlugin.aiManager.llmProviders.get(service.provider);
|
|
108
98
|
if (!providerMeta) {
|
|
@@ -110,7 +100,6 @@ async function handleCompletions(ctx, plugin) {
|
|
|
110
100
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, `Provider '${service.provider}' not registered`, "server_error");
|
|
111
101
|
return;
|
|
112
102
|
}
|
|
113
|
-
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
114
103
|
const modelOptions = {
|
|
115
104
|
model: modelId,
|
|
116
105
|
llmService: service.name
|
|
@@ -119,14 +108,8 @@ async function handleCompletions(ctx, plugin) {
|
|
|
119
108
|
if (body.top_p !== void 0) modelOptions.topP = body.top_p;
|
|
120
109
|
if (body.max_tokens !== void 0) modelOptions.maxTokens = body.max_tokens;
|
|
121
110
|
if (body.stop !== void 0) modelOptions.stop = body.stop;
|
|
122
|
-
const Provider = providerMeta.provider;
|
|
123
|
-
const provider = new Provider({
|
|
124
|
-
app: ctx.app,
|
|
125
|
-
serviceOptions: service.options,
|
|
126
|
-
modelOptions
|
|
127
|
-
});
|
|
128
111
|
const prompt = typeof body.prompt === "string" ? body.prompt : Array.isArray(body.prompt) ? body.prompt.join("\n") : String(body.prompt);
|
|
129
|
-
const
|
|
112
|
+
const messages = [];
|
|
130
113
|
if (config == null ? void 0 : config.defaultAiEmployee) {
|
|
131
114
|
const employee = await ctx.db.getRepository("aiEmployees").findOne({
|
|
132
115
|
filter: { username: config.defaultAiEmployee }
|
|
@@ -134,30 +117,55 @@ async function handleCompletions(ctx, plugin) {
|
|
|
134
117
|
if (employee) {
|
|
135
118
|
const systemPrompt = employee.about || employee.defaultPrompt || "";
|
|
136
119
|
if (systemPrompt) {
|
|
137
|
-
|
|
120
|
+
messages.push({ role: "system", content: systemPrompt });
|
|
138
121
|
}
|
|
139
122
|
}
|
|
140
123
|
}
|
|
141
|
-
|
|
124
|
+
messages.push({ role: "user", content: prompt });
|
|
125
|
+
const preparedContext = await (0, import_direct_llm_context.prepareDirectLlmContext)(ctx, {
|
|
126
|
+
serviceName: service.name,
|
|
127
|
+
modelId,
|
|
128
|
+
messages,
|
|
129
|
+
maxTokens: body.max_tokens
|
|
130
|
+
});
|
|
131
|
+
await (0, import_billing.prepareLlmBilling)(ctx, resolved);
|
|
132
|
+
const Provider = providerMeta.provider;
|
|
133
|
+
const provider = new Provider({
|
|
134
|
+
app: ctx.app,
|
|
135
|
+
serviceOptions: service.options,
|
|
136
|
+
modelOptions
|
|
137
|
+
});
|
|
138
|
+
const langchainMessages = preparedContext.messages.map((message) => [
|
|
139
|
+
message.role === "user" ? "human" : message.role,
|
|
140
|
+
String(message.content ?? "")
|
|
141
|
+
]);
|
|
142
142
|
const completionId = (0, import_openai_format.generateCompletionId)().replace("chatcmpl-", "cmpl-");
|
|
143
143
|
const chatModel = provider.createModel();
|
|
144
144
|
(0, import_billing.markLlmProviderAttempted)(ctx);
|
|
145
145
|
if (stream) {
|
|
146
|
-
await handleStreamingTextCompletion(
|
|
146
|
+
await handleStreamingTextCompletion(
|
|
147
|
+
ctx,
|
|
148
|
+
chatModel,
|
|
149
|
+
langchainMessages,
|
|
150
|
+
completionId,
|
|
151
|
+
body.model,
|
|
152
|
+
body.stream_options
|
|
153
|
+
);
|
|
147
154
|
} else {
|
|
148
155
|
await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
149
156
|
}
|
|
150
157
|
} catch (err) {
|
|
151
158
|
ctx.log.error("AI API completions error:", err);
|
|
152
|
-
if (!ctx.res.headersSent) {
|
|
159
|
+
if (!((_a = ctx.res) == null ? void 0 : _a.headersSent)) {
|
|
153
160
|
const isQuotaError = err instanceof import_billing.AiApiQuotaError;
|
|
154
|
-
|
|
161
|
+
const isContextError = err instanceof import_direct_llm_context.DirectLlmContextError;
|
|
162
|
+
ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
|
|
155
163
|
if (isQuotaError) ctx.set("X-RateLimit-Reason", err.code);
|
|
156
164
|
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
157
165
|
ctx.status,
|
|
158
166
|
getErrorMessage(err, "Internal server error"),
|
|
159
|
-
isQuotaError ? "quota_error" : "server_error",
|
|
160
|
-
isQuotaError ? err.code : void 0
|
|
167
|
+
isQuotaError ? "quota_error" : isContextError ? "invalid_request_error" : "server_error",
|
|
168
|
+
isQuotaError || isContextError ? err.code : void 0
|
|
161
169
|
);
|
|
162
170
|
}
|
|
163
171
|
}
|
|
@@ -193,7 +201,7 @@ async function handleNonStreamingTextCompletion(ctx, chatModel, messages, comple
|
|
|
193
201
|
usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
194
202
|
};
|
|
195
203
|
}
|
|
196
|
-
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName) {
|
|
204
|
+
async function handleStreamingTextCompletion(ctx, chatModel, messages, completionId, modelName, streamOptions) {
|
|
197
205
|
ctx.set({
|
|
198
206
|
"Content-Type": "text/event-stream",
|
|
199
207
|
"Cache-Control": "no-cache",
|
|
@@ -205,7 +213,10 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
|
|
|
205
213
|
let usage;
|
|
206
214
|
let providerRequestId;
|
|
207
215
|
try {
|
|
208
|
-
const stream = await chatModel.stream(messages, {
|
|
216
|
+
const stream = await chatModel.stream(messages, {
|
|
217
|
+
stream_options: { ...streamOptions, include_usage: true },
|
|
218
|
+
signal: requestAbort.signal
|
|
219
|
+
});
|
|
209
220
|
for await (const chunk of stream) {
|
|
210
221
|
if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
|
|
211
222
|
let text = "";
|
|
@@ -232,7 +243,8 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
|
|
|
232
243
|
logprobs: null,
|
|
233
244
|
finish_reason: null
|
|
234
245
|
}
|
|
235
|
-
]
|
|
246
|
+
],
|
|
247
|
+
usage: null
|
|
236
248
|
})
|
|
237
249
|
);
|
|
238
250
|
}
|
|
@@ -256,9 +268,23 @@ async function handleStreamingTextCompletion(ctx, chatModel, messages, completio
|
|
|
256
268
|
logprobs: null,
|
|
257
269
|
finish_reason: "stop"
|
|
258
270
|
}
|
|
259
|
-
]
|
|
271
|
+
],
|
|
272
|
+
usage: null
|
|
260
273
|
})
|
|
261
274
|
);
|
|
275
|
+
if (usage) {
|
|
276
|
+
await (0, import_streaming.writeResponse)(
|
|
277
|
+
ctx,
|
|
278
|
+
(0, import_openai_format.formatSSE)(
|
|
279
|
+
(0, import_openai_format.toOpenAIUsageChunk)({
|
|
280
|
+
id: completionId,
|
|
281
|
+
model: modelName,
|
|
282
|
+
object: "text_completion",
|
|
283
|
+
usage
|
|
284
|
+
})
|
|
285
|
+
)
|
|
286
|
+
);
|
|
287
|
+
}
|
|
262
288
|
await (0, import_streaming.writeResponse)(ctx, (0, import_openai_format.formatSSEDone)());
|
|
263
289
|
(0, import_usage.setAiApiUsageResult)(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
264
290
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
@@ -31,9 +31,9 @@ __export(embeddings_exports, {
|
|
|
31
31
|
module.exports = __toCommonJS(embeddings_exports);
|
|
32
32
|
var import_openai_format = require("../utils/openai-format");
|
|
33
33
|
var import_resolve_service = require("../utils/resolve-service");
|
|
34
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
34
35
|
var import_usage = require("../usage");
|
|
35
36
|
async function handleEmbeddings(ctx, plugin) {
|
|
36
|
-
var _a;
|
|
37
37
|
const body = ctx.request.body;
|
|
38
38
|
if (!(body == null ? void 0 : body.model)) {
|
|
39
39
|
ctx.status = 400;
|
|
@@ -102,23 +102,15 @@ async function handleEmbeddings(ctx, plugin) {
|
|
|
102
102
|
);
|
|
103
103
|
return;
|
|
104
104
|
}
|
|
105
|
+
let globalEnabledServices = [];
|
|
105
106
|
try {
|
|
106
107
|
const config = await ctx.db.getRepository("aiApiConfig").findOne();
|
|
107
|
-
|
|
108
|
-
const allowed = config.enabledLlmServices.some((s) => s === service.name || s === service.title);
|
|
109
|
-
if (!allowed) {
|
|
110
|
-
ctx.status = 403;
|
|
111
|
-
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
112
|
-
403,
|
|
113
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
114
|
-
"invalid_request_error",
|
|
115
|
-
"model_not_available"
|
|
116
|
-
);
|
|
117
|
-
return;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
108
|
+
globalEnabledServices = (config == null ? void 0 : config.enabledLlmServices) ?? [];
|
|
120
109
|
} catch {
|
|
121
110
|
}
|
|
111
|
+
if (!await (0, import_user_permissions.enforceModelAccess)(ctx, globalEnabledServices, service, modelId)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
122
114
|
const aiPlugin = ctx.app.pm.get("ai");
|
|
123
115
|
if (!aiPlugin) {
|
|
124
116
|
ctx.status = 500;
|
|
@@ -32,6 +32,7 @@ __export(models_exports, {
|
|
|
32
32
|
});
|
|
33
33
|
module.exports = __toCommonJS(models_exports);
|
|
34
34
|
var import_openai_format = require("../utils/openai-format");
|
|
35
|
+
var import_user_permissions = require("../utils/user-permissions");
|
|
35
36
|
async function handleListModels(ctx, plugin) {
|
|
36
37
|
var _a;
|
|
37
38
|
try {
|
|
@@ -50,15 +51,22 @@ async function handleListModels(ctx, plugin) {
|
|
|
50
51
|
filter,
|
|
51
52
|
sort: "sort"
|
|
52
53
|
});
|
|
54
|
+
const scope = await (0, import_user_permissions.resolveUserAccessScope)(ctx);
|
|
55
|
+
if (scope.lookupFailed) {
|
|
56
|
+
respondPermissionCheckFailed(ctx);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
53
59
|
const metadataMap = await loadModelMetadata(ctx);
|
|
54
60
|
const now = Math.floor(Date.now() / 1e3);
|
|
55
61
|
const models = [];
|
|
56
62
|
for (const service of services) {
|
|
57
63
|
if (service.enabled === false) continue;
|
|
64
|
+
if (!(0, import_user_permissions.isServiceAllowed)(scope, config == null ? void 0 : config.enabledLlmServices, service)) continue;
|
|
58
65
|
const enabledModels = resolveEnabledModels(service);
|
|
59
66
|
const serviceLabel = service.title || service.name;
|
|
60
67
|
for (const model of enabledModels) {
|
|
61
68
|
const fullId = `${service.name}/${model.value}`;
|
|
69
|
+
if (!(0, import_user_permissions.isModelAllowed)(scope, fullId)) continue;
|
|
62
70
|
const meta = metadataMap.get(fullId);
|
|
63
71
|
if (meta && meta.enabled === false) continue;
|
|
64
72
|
models.push(buildModelObject(fullId, now, serviceLabel, meta));
|
|
@@ -87,16 +95,23 @@ async function handleGetModel(ctx, modelId, plugin) {
|
|
|
87
95
|
filter,
|
|
88
96
|
sort: "sort"
|
|
89
97
|
});
|
|
98
|
+
const scope = await (0, import_user_permissions.resolveUserAccessScope)(ctx);
|
|
99
|
+
if (scope.lookupFailed) {
|
|
100
|
+
respondPermissionCheckFailed(ctx);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
90
103
|
const metadataMap = await loadModelMetadata(ctx);
|
|
91
104
|
const now = Math.floor(Date.now() / 1e3);
|
|
92
105
|
let found = null;
|
|
93
106
|
for (const service of services) {
|
|
94
107
|
if (service.enabled === false) continue;
|
|
108
|
+
if (!(0, import_user_permissions.isServiceAllowed)(scope, config == null ? void 0 : config.enabledLlmServices, service)) continue;
|
|
95
109
|
const enabledModels = resolveEnabledModels(service);
|
|
96
110
|
const serviceLabel = service.title || service.name;
|
|
97
111
|
for (const model of enabledModels) {
|
|
98
112
|
const fullId = `${service.name}/${model.value}`;
|
|
99
113
|
if (fullId === modelId || model.value === modelId) {
|
|
114
|
+
if (!(0, import_user_permissions.isModelAllowed)(scope, fullId)) continue;
|
|
100
115
|
const meta = metadataMap.get(fullId);
|
|
101
116
|
if (meta && meta.enabled === false) continue;
|
|
102
117
|
found = buildModelObject(fullId, now, serviceLabel, meta);
|
|
@@ -118,6 +133,15 @@ async function handleGetModel(ctx, modelId, plugin) {
|
|
|
118
133
|
ctx.body = (0, import_openai_format.toOpenAIError)(500, "Failed to retrieve model", "server_error");
|
|
119
134
|
}
|
|
120
135
|
}
|
|
136
|
+
function respondPermissionCheckFailed(ctx) {
|
|
137
|
+
ctx.status = 503;
|
|
138
|
+
ctx.body = (0, import_openai_format.toOpenAIError)(
|
|
139
|
+
503,
|
|
140
|
+
"Unable to verify LLM permissions for this user. Please retry shortly.",
|
|
141
|
+
"service_unavailable",
|
|
142
|
+
"permission_check_failed"
|
|
143
|
+
);
|
|
144
|
+
}
|
|
121
145
|
async function loadModelMetadata(ctx) {
|
|
122
146
|
var _a, _b;
|
|
123
147
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -0,0 +1,184 @@
|
|
|
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 direct_llm_context_exports = {};
|
|
28
|
+
__export(direct_llm_context_exports, {
|
|
29
|
+
DirectLlmContextError: () => DirectLlmContextError,
|
|
30
|
+
prepareDirectLlmContext: () => prepareDirectLlmContext
|
|
31
|
+
});
|
|
32
|
+
module.exports = __toCommonJS(direct_llm_context_exports);
|
|
33
|
+
class DirectLlmContextError extends Error {
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.name = "DirectLlmContextError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function getValue(record, key) {
|
|
41
|
+
var _a;
|
|
42
|
+
return ((_a = record == null ? void 0 : record.get) == null ? void 0 : _a.call(record, key)) ?? (record == null ? void 0 : record[key]);
|
|
43
|
+
}
|
|
44
|
+
function positiveInteger(value) {
|
|
45
|
+
const parsed = Number(value);
|
|
46
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
|
|
47
|
+
}
|
|
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
|
+
function estimateValueTokens(value) {
|
|
54
|
+
if (value === void 0) return 0;
|
|
55
|
+
return Math.ceil(Buffer.byteLength(JSON.stringify(value), "utf8") / 3);
|
|
56
|
+
}
|
|
57
|
+
function estimateMessagesTokens(messages) {
|
|
58
|
+
return messages.reduce((total, message) => total + estimateValueTokens(message) + 4, 0);
|
|
59
|
+
}
|
|
60
|
+
function containsUnsupportedContent(messages) {
|
|
61
|
+
return messages.some((message) => hasImageContent(message.content));
|
|
62
|
+
}
|
|
63
|
+
function isInstruction(message) {
|
|
64
|
+
return message.role === "system" || message.role === "developer";
|
|
65
|
+
}
|
|
66
|
+
function splitTurns(messages) {
|
|
67
|
+
const fixed = [];
|
|
68
|
+
const turns = [];
|
|
69
|
+
let currentTurn;
|
|
70
|
+
for (const message of messages) {
|
|
71
|
+
if (isInstruction(message)) {
|
|
72
|
+
fixed.push(message);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (message.role === "user" || !currentTurn) {
|
|
76
|
+
currentTurn = [message];
|
|
77
|
+
turns.push(currentTurn);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
currentTurn.push(message);
|
|
81
|
+
}
|
|
82
|
+
return { fixed, turns };
|
|
83
|
+
}
|
|
84
|
+
function messagesWithTurns(messages, turns) {
|
|
85
|
+
const retainedMessages = new Set(turns.flat());
|
|
86
|
+
return messages.filter((message) => isInstruction(message) || retainedMessages.has(message));
|
|
87
|
+
}
|
|
88
|
+
async function loadModelMetadata(ctx, serviceName, modelId) {
|
|
89
|
+
const row = await ctx.db.getRepository("aiApiModelMetadata").findOne({
|
|
90
|
+
filter: { llmService: serviceName, model: modelId, enabled: true }
|
|
91
|
+
});
|
|
92
|
+
const contextWindow = positiveInteger(getValue(row, "contextWindow"));
|
|
93
|
+
const maxCompletionTokens = positiveInteger(getValue(row, "maxCompletionTokens"));
|
|
94
|
+
if (!contextWindow || !maxCompletionTokens) {
|
|
95
|
+
throw new DirectLlmContextError(
|
|
96
|
+
"model_context_metadata_not_configured",
|
|
97
|
+
`Context metadata is not configured for '${serviceName}/${modelId}'. Configure context window and max completion tokens.`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return { contextWindow, maxCompletionTokens };
|
|
101
|
+
}
|
|
102
|
+
async function resolveOverflowBehavior(ctx) {
|
|
103
|
+
var _a;
|
|
104
|
+
const userId = (_a = ctx.state.currentUser) == null ? void 0 : _a.id;
|
|
105
|
+
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";
|
|
110
|
+
}
|
|
111
|
+
function resolveReservedOutputTokens(options, metadata) {
|
|
112
|
+
const requested = positiveInteger(options.maxCompletionTokens ?? options.maxTokens);
|
|
113
|
+
if (requested && requested > metadata.maxCompletionTokens) {
|
|
114
|
+
throw new DirectLlmContextError(
|
|
115
|
+
"max_completion_tokens_exceeds_model_limit",
|
|
116
|
+
`Requested max completion tokens (${requested}) exceeds the model limit (${metadata.maxCompletionTokens}).`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return requested ?? metadata.maxCompletionTokens;
|
|
120
|
+
}
|
|
121
|
+
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
|
+
const [metadata, behavior] = await Promise.all([
|
|
129
|
+
loadModelMetadata(ctx, options.serviceName, options.modelId),
|
|
130
|
+
resolveOverflowBehavior(ctx)
|
|
131
|
+
]);
|
|
132
|
+
const reservedOutputTokens = resolveReservedOutputTokens(options, metadata);
|
|
133
|
+
const inputTokenBudget = metadata.contextWindow - reservedOutputTokens;
|
|
134
|
+
if (inputTokenBudget <= 0) {
|
|
135
|
+
throw new DirectLlmContextError(
|
|
136
|
+
"context_length_exceeded",
|
|
137
|
+
`The model context window (${metadata.contextWindow}) leaves no input capacity after reserving ${reservedOutputTokens} output tokens.`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const fixedOverheadTokens = estimateValueTokens(options.tools) + (options.tools === void 0 ? 0 : 4);
|
|
141
|
+
const originalEstimate = estimateMessagesTokens(options.messages) + fixedOverheadTokens;
|
|
142
|
+
if (originalEstimate <= inputTokenBudget) {
|
|
143
|
+
return {
|
|
144
|
+
messages: options.messages,
|
|
145
|
+
estimatedInputTokens: originalEstimate,
|
|
146
|
+
inputTokenBudget,
|
|
147
|
+
reservedOutputTokens,
|
|
148
|
+
truncated: false
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (behavior === "reject") {
|
|
152
|
+
throw new DirectLlmContextError(
|
|
153
|
+
"context_length_exceeded",
|
|
154
|
+
`Estimated input tokens (${originalEstimate}) exceed the allowed input budget (${inputTokenBudget}).`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const { turns } = splitTurns(options.messages);
|
|
158
|
+
let remainingTurns = turns;
|
|
159
|
+
let messages = messagesWithTurns(options.messages, remainingTurns);
|
|
160
|
+
let estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
|
|
161
|
+
while (remainingTurns.length > 1 && estimatedInputTokens > inputTokenBudget) {
|
|
162
|
+
remainingTurns = remainingTurns.slice(1);
|
|
163
|
+
messages = messagesWithTurns(options.messages, remainingTurns);
|
|
164
|
+
estimatedInputTokens = estimateMessagesTokens(messages) + fixedOverheadTokens;
|
|
165
|
+
}
|
|
166
|
+
if (estimatedInputTokens > inputTokenBudget) {
|
|
167
|
+
throw new DirectLlmContextError(
|
|
168
|
+
"context_length_exceeded",
|
|
169
|
+
`The fixed instructions, tools, and newest conversation turn require ${estimatedInputTokens} input tokens, exceeding the allowed budget (${inputTokenBudget}).`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
messages,
|
|
174
|
+
estimatedInputTokens,
|
|
175
|
+
inputTokenBudget,
|
|
176
|
+
reservedOutputTokens,
|
|
177
|
+
truncated: messages.length !== options.messages.length
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
181
|
+
0 && (module.exports = {
|
|
182
|
+
DirectLlmContextError,
|
|
183
|
+
prepareDirectLlmContext
|
|
184
|
+
});
|
|
@@ -43,7 +43,8 @@ __export(openai_format_exports, {
|
|
|
43
43
|
toOpenAIEmbeddingsResponse: () => toOpenAIEmbeddingsResponse,
|
|
44
44
|
toOpenAIError: () => toOpenAIError,
|
|
45
45
|
toOpenAIResponse: () => toOpenAIResponse,
|
|
46
|
-
toOpenAIStreamChunk: () => toOpenAIStreamChunk
|
|
46
|
+
toOpenAIStreamChunk: () => toOpenAIStreamChunk,
|
|
47
|
+
toOpenAIUsageChunk: () => toOpenAIUsageChunk
|
|
47
48
|
});
|
|
48
49
|
module.exports = __toCommonJS(openai_format_exports);
|
|
49
50
|
var import_crypto = __toESM(require("crypto"));
|
|
@@ -116,7 +117,19 @@ function toOpenAIStreamChunk(options) {
|
|
|
116
117
|
logprobs: null,
|
|
117
118
|
finish_reason: finishReason
|
|
118
119
|
}
|
|
119
|
-
]
|
|
120
|
+
],
|
|
121
|
+
usage: null
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function toOpenAIUsageChunk(options) {
|
|
125
|
+
const { id, model, usage, object = "chat.completion.chunk" } = options;
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
object,
|
|
129
|
+
created: Math.floor(Date.now() / 1e3),
|
|
130
|
+
model,
|
|
131
|
+
choices: [],
|
|
132
|
+
usage
|
|
120
133
|
};
|
|
121
134
|
}
|
|
122
135
|
function toOpenAIEmbeddingsResponse(options) {
|
|
@@ -154,5 +167,6 @@ function formatSSEDone() {
|
|
|
154
167
|
toOpenAIEmbeddingsResponse,
|
|
155
168
|
toOpenAIError,
|
|
156
169
|
toOpenAIResponse,
|
|
157
|
-
toOpenAIStreamChunk
|
|
170
|
+
toOpenAIStreamChunk,
|
|
171
|
+
toOpenAIUsageChunk
|
|
158
172
|
});
|