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
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
generateCompletionId,
|
|
13
13
|
toOpenAIResponse,
|
|
14
14
|
toOpenAIStreamChunk,
|
|
15
|
+
toOpenAIUsageChunk,
|
|
15
16
|
toOpenAIError,
|
|
16
17
|
formatSSE,
|
|
17
18
|
formatSSEDone,
|
|
@@ -26,9 +27,11 @@ import {
|
|
|
26
27
|
writeResponse,
|
|
27
28
|
} from '../utils/streaming';
|
|
28
29
|
import { checkEmployeeAccess } from '../middleware/role-permission';
|
|
30
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
29
31
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
30
32
|
import type PluginAiApiServer from '../plugin';
|
|
31
33
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
34
|
+
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
32
35
|
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
33
36
|
|
|
34
37
|
/**
|
|
@@ -126,22 +129,10 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
126
129
|
return;
|
|
127
130
|
}
|
|
128
131
|
|
|
129
|
-
// ─── Check whitelist ───
|
|
132
|
+
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
130
133
|
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
131
|
-
if (config?.enabledLlmServices
|
|
132
|
-
|
|
133
|
-
const serviceTitle = service.title;
|
|
134
|
-
const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
|
|
135
|
-
if (!isAllowed) {
|
|
136
|
-
ctx.status = 403;
|
|
137
|
-
ctx.body = toOpenAIError(
|
|
138
|
-
403,
|
|
139
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
140
|
-
'invalid_request_error',
|
|
141
|
-
'model_not_available',
|
|
142
|
-
);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
134
|
+
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
135
|
+
return;
|
|
145
136
|
}
|
|
146
137
|
|
|
147
138
|
// ─── Create LLM provider instance ───
|
|
@@ -152,9 +143,14 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
152
143
|
return;
|
|
153
144
|
}
|
|
154
145
|
|
|
155
|
-
await prepareLlmBilling(ctx, resolved);
|
|
156
|
-
|
|
157
146
|
const providerRequestParameters = getProviderRequestParameters(body);
|
|
147
|
+
if (stream) {
|
|
148
|
+
const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
|
|
149
|
+
providerRequestParameters.stream_options = {
|
|
150
|
+
...streamOptions,
|
|
151
|
+
include_usage: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
158
154
|
const modelOptions: Record<string, unknown> = {
|
|
159
155
|
model: modelId,
|
|
160
156
|
llmService: service.name,
|
|
@@ -169,13 +165,6 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
169
165
|
if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
|
|
170
166
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
171
167
|
|
|
172
|
-
const Provider = providerMeta.provider;
|
|
173
|
-
const provider = new Provider({
|
|
174
|
-
app: ctx.app,
|
|
175
|
-
serviceOptions: service.options,
|
|
176
|
-
modelOptions,
|
|
177
|
-
});
|
|
178
|
-
|
|
179
168
|
// ─── Build system prompt from AI Employee ───
|
|
180
169
|
let systemPrompt = '';
|
|
181
170
|
if (config?.defaultAiEmployee) {
|
|
@@ -200,12 +189,31 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
200
189
|
}
|
|
201
190
|
|
|
202
191
|
// ─── Build messages (inject system prompt if not provided by client) ───
|
|
203
|
-
|
|
192
|
+
let messages: OpenAIMessage[] = [...body.messages];
|
|
204
193
|
const hasSystemMessage = messages.some((m: any) => m.role === 'system');
|
|
205
194
|
if (systemPrompt && !hasSystemMessage) {
|
|
206
195
|
messages.unshift({ role: 'system', content: systemPrompt });
|
|
207
196
|
}
|
|
208
197
|
|
|
198
|
+
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
199
|
+
serviceName: service.name,
|
|
200
|
+
modelId,
|
|
201
|
+
messages,
|
|
202
|
+
tools: body.tools,
|
|
203
|
+
maxCompletionTokens: body.max_completion_tokens,
|
|
204
|
+
maxTokens: body.max_tokens,
|
|
205
|
+
});
|
|
206
|
+
messages = preparedContext.messages;
|
|
207
|
+
|
|
208
|
+
await prepareLlmBilling(ctx, resolved);
|
|
209
|
+
|
|
210
|
+
const Provider = providerMeta.provider;
|
|
211
|
+
const provider = new Provider({
|
|
212
|
+
app: ctx.app,
|
|
213
|
+
serviceOptions: service.options,
|
|
214
|
+
modelOptions,
|
|
215
|
+
});
|
|
216
|
+
|
|
209
217
|
// ─── Build message tuples for LangChain model ───
|
|
210
218
|
// LangChain chat models accept [role, content] tuples or BaseMessage objects.
|
|
211
219
|
// We use tuples to avoid importing @langchain/core directly.
|
|
@@ -260,15 +268,16 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
|
|
|
260
268
|
}
|
|
261
269
|
} catch (err) {
|
|
262
270
|
ctx.log.error('AI API chat completions error:', err);
|
|
263
|
-
if (!ctx.res
|
|
271
|
+
if (!ctx.res?.headersSent) {
|
|
264
272
|
const isQuotaError = err instanceof AiApiQuotaError;
|
|
265
|
-
|
|
273
|
+
const isContextError = err instanceof DirectLlmContextError;
|
|
274
|
+
ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
|
|
266
275
|
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
267
276
|
ctx.body = toOpenAIError(
|
|
268
277
|
ctx.status,
|
|
269
278
|
getErrorMessage(err, 'Internal server error'),
|
|
270
|
-
isQuotaError ? 'quota_error' : 'server_error',
|
|
271
|
-
isQuotaError ? err.code : undefined,
|
|
279
|
+
isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
|
|
280
|
+
isQuotaError || isContextError ? err.code : undefined,
|
|
272
281
|
);
|
|
273
282
|
}
|
|
274
283
|
}
|
|
@@ -380,7 +389,13 @@ async function handleStreamingCompletion(
|
|
|
380
389
|
finishReason = 'tool_calls';
|
|
381
390
|
await writeResponse(
|
|
382
391
|
ctx,
|
|
383
|
-
formatSSE(
|
|
392
|
+
formatSSE(
|
|
393
|
+
toOpenAIStreamChunk({
|
|
394
|
+
id: completionId,
|
|
395
|
+
model: modelName,
|
|
396
|
+
delta: { tool_calls: toolCallChunks },
|
|
397
|
+
}),
|
|
398
|
+
),
|
|
384
399
|
);
|
|
385
400
|
}
|
|
386
401
|
if (chunk.usage_metadata) {
|
|
@@ -402,6 +417,19 @@ async function handleStreamingCompletion(
|
|
|
402
417
|
),
|
|
403
418
|
);
|
|
404
419
|
|
|
420
|
+
if (usage) {
|
|
421
|
+
await writeResponse(
|
|
422
|
+
ctx,
|
|
423
|
+
formatSSE(
|
|
424
|
+
toOpenAIUsageChunk({
|
|
425
|
+
id: completionId,
|
|
426
|
+
model: modelName,
|
|
427
|
+
usage,
|
|
428
|
+
}),
|
|
429
|
+
),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
405
433
|
// Send [DONE]
|
|
406
434
|
await writeResponse(ctx, formatSSEDone());
|
|
407
435
|
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
generateCompletionId,
|
|
13
|
+
toOpenAIError,
|
|
14
|
+
formatSSE,
|
|
15
|
+
formatSSEDone,
|
|
16
|
+
toOpenAIUsageChunk,
|
|
17
|
+
} from '../utils/openai-format';
|
|
12
18
|
import { resolveModelString } from '../utils/resolve-service';
|
|
19
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
13
20
|
import {
|
|
14
21
|
createRequestAbortController,
|
|
15
22
|
isClientDisconnected,
|
|
@@ -19,6 +26,7 @@ import {
|
|
|
19
26
|
import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
|
|
20
27
|
import type PluginAiApiServer from '../plugin';
|
|
21
28
|
import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
|
|
29
|
+
import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
|
|
22
30
|
import { markAiApiFirstProviderOutput } from '../utils/app-observability';
|
|
23
31
|
|
|
24
32
|
/**
|
|
@@ -93,22 +101,10 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
93
101
|
return;
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
// ─── Check whitelist ───
|
|
104
|
+
// ─── Check whitelist (global config ∩ per-user grant) ───
|
|
97
105
|
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
98
|
-
if (config?.enabledLlmServices
|
|
99
|
-
|
|
100
|
-
const serviceTitle = service.title;
|
|
101
|
-
const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
|
|
102
|
-
if (!isAllowed) {
|
|
103
|
-
ctx.status = 403;
|
|
104
|
-
ctx.body = toOpenAIError(
|
|
105
|
-
403,
|
|
106
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
107
|
-
'invalid_request_error',
|
|
108
|
-
'model_not_available',
|
|
109
|
-
);
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
106
|
+
if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
|
|
107
|
+
return;
|
|
112
108
|
}
|
|
113
109
|
|
|
114
110
|
// ─── Create LLM provider instance ───
|
|
@@ -119,8 +115,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
119
115
|
return;
|
|
120
116
|
}
|
|
121
117
|
|
|
122
|
-
await prepareLlmBilling(ctx, resolved);
|
|
123
|
-
|
|
124
118
|
const modelOptions: Record<string, any> = {
|
|
125
119
|
model: modelId,
|
|
126
120
|
llmService: service.name,
|
|
@@ -131,13 +125,6 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
131
125
|
if (body.max_tokens !== undefined) modelOptions.maxTokens = body.max_tokens;
|
|
132
126
|
if (body.stop !== undefined) modelOptions.stop = body.stop;
|
|
133
127
|
|
|
134
|
-
const Provider = providerMeta.provider;
|
|
135
|
-
const provider = new Provider({
|
|
136
|
-
app: ctx.app,
|
|
137
|
-
serviceOptions: service.options,
|
|
138
|
-
modelOptions,
|
|
139
|
-
});
|
|
140
|
-
|
|
141
128
|
// ─── Convert prompt to message tuple ───
|
|
142
129
|
const prompt =
|
|
143
130
|
typeof body.prompt === 'string'
|
|
@@ -147,7 +134,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
147
134
|
: String(body.prompt);
|
|
148
135
|
|
|
149
136
|
// Inject system prompt from AI Employee if configured
|
|
150
|
-
const
|
|
137
|
+
const messages: OpenAIMessage[] = [];
|
|
151
138
|
if (config?.defaultAiEmployee) {
|
|
152
139
|
const employee = await ctx.db.getRepository('aiEmployees').findOne({
|
|
153
140
|
filter: { username: config.defaultAiEmployee },
|
|
@@ -155,32 +142,59 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
|
|
|
155
142
|
if (employee) {
|
|
156
143
|
const systemPrompt = employee.about || employee.defaultPrompt || '';
|
|
157
144
|
if (systemPrompt) {
|
|
158
|
-
|
|
145
|
+
messages.push({ role: 'system', content: systemPrompt });
|
|
159
146
|
}
|
|
160
147
|
}
|
|
161
148
|
}
|
|
162
|
-
|
|
149
|
+
messages.push({ role: 'user', content: prompt });
|
|
150
|
+
|
|
151
|
+
const preparedContext = await prepareDirectLlmContext(ctx, {
|
|
152
|
+
serviceName: service.name,
|
|
153
|
+
modelId,
|
|
154
|
+
messages,
|
|
155
|
+
maxTokens: body.max_tokens,
|
|
156
|
+
});
|
|
157
|
+
await prepareLlmBilling(ctx, resolved);
|
|
158
|
+
|
|
159
|
+
const Provider = providerMeta.provider;
|
|
160
|
+
const provider = new Provider({
|
|
161
|
+
app: ctx.app,
|
|
162
|
+
serviceOptions: service.options,
|
|
163
|
+
modelOptions,
|
|
164
|
+
});
|
|
165
|
+
const langchainMessages = preparedContext.messages.map((message): [string, string] => [
|
|
166
|
+
message.role === 'user' ? 'human' : message.role,
|
|
167
|
+
String(message.content ?? ''),
|
|
168
|
+
]);
|
|
163
169
|
|
|
164
170
|
const completionId = generateCompletionId().replace('chatcmpl-', 'cmpl-');
|
|
165
171
|
const chatModel = provider.createModel();
|
|
166
172
|
markLlmProviderAttempted(ctx);
|
|
167
173
|
|
|
168
174
|
if (stream) {
|
|
169
|
-
await handleStreamingTextCompletion(
|
|
175
|
+
await handleStreamingTextCompletion(
|
|
176
|
+
ctx,
|
|
177
|
+
chatModel,
|
|
178
|
+
langchainMessages,
|
|
179
|
+
completionId,
|
|
180
|
+
body.model,
|
|
181
|
+
body.stream_options,
|
|
182
|
+
);
|
|
170
183
|
} else {
|
|
171
184
|
await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
|
|
172
185
|
}
|
|
173
186
|
} catch (err) {
|
|
174
187
|
ctx.log.error('AI API completions error:', err);
|
|
175
|
-
if (!ctx.res
|
|
188
|
+
if (!ctx.res?.headersSent) {
|
|
176
189
|
const isQuotaError = err instanceof AiApiQuotaError;
|
|
177
|
-
|
|
190
|
+
const isContextError = err instanceof DirectLlmContextError;
|
|
191
|
+
ctx.status = isQuotaError ? 429 : isContextError ? 400 : 500;
|
|
178
192
|
if (isQuotaError) ctx.set('X-RateLimit-Reason', err.code);
|
|
179
193
|
ctx.body = toOpenAIError(
|
|
180
194
|
ctx.status,
|
|
181
195
|
getErrorMessage(err, 'Internal server error'),
|
|
182
|
-
isQuotaError ? 'quota_error' : 'server_error',
|
|
183
|
-
isQuotaError ? err.code : undefined,
|
|
196
|
+
isQuotaError ? 'quota_error' : isContextError ? 'invalid_request_error' : 'server_error',
|
|
197
|
+
isQuotaError || isContextError ? err.code : undefined,
|
|
184
198
|
);
|
|
185
199
|
}
|
|
186
200
|
}
|
|
@@ -237,6 +251,7 @@ async function handleStreamingTextCompletion(
|
|
|
237
251
|
messages: [string, string][],
|
|
238
252
|
completionId: string,
|
|
239
253
|
modelName: string,
|
|
254
|
+
streamOptions: Record<string, unknown> | undefined,
|
|
240
255
|
) {
|
|
241
256
|
ctx.set({
|
|
242
257
|
'Content-Type': 'text/event-stream',
|
|
@@ -250,7 +265,10 @@ async function handleStreamingTextCompletion(
|
|
|
250
265
|
let usage: Usage | undefined;
|
|
251
266
|
let providerRequestId: string | undefined;
|
|
252
267
|
try {
|
|
253
|
-
const stream = await chatModel.stream(messages, {
|
|
268
|
+
const stream = await chatModel.stream(messages, {
|
|
269
|
+
stream_options: { ...streamOptions, include_usage: true },
|
|
270
|
+
signal: requestAbort.signal,
|
|
271
|
+
});
|
|
254
272
|
|
|
255
273
|
for await (const chunk of stream) {
|
|
256
274
|
if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
|
|
@@ -280,6 +298,7 @@ async function handleStreamingTextCompletion(
|
|
|
280
298
|
finish_reason: null,
|
|
281
299
|
},
|
|
282
300
|
],
|
|
301
|
+
usage: null,
|
|
283
302
|
}),
|
|
284
303
|
);
|
|
285
304
|
}
|
|
@@ -306,9 +325,24 @@ async function handleStreamingTextCompletion(
|
|
|
306
325
|
finish_reason: 'stop',
|
|
307
326
|
},
|
|
308
327
|
],
|
|
328
|
+
usage: null,
|
|
309
329
|
}),
|
|
310
330
|
);
|
|
311
331
|
|
|
332
|
+
if (usage) {
|
|
333
|
+
await writeResponse(
|
|
334
|
+
ctx,
|
|
335
|
+
formatSSE(
|
|
336
|
+
toOpenAIUsageChunk({
|
|
337
|
+
id: completionId,
|
|
338
|
+
model: modelName,
|
|
339
|
+
object: 'text_completion',
|
|
340
|
+
usage,
|
|
341
|
+
}),
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
312
346
|
await writeResponse(ctx, formatSSEDone());
|
|
313
347
|
setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
|
|
314
348
|
ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
11
|
import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
|
|
12
12
|
import { resolveModelString } from '../utils/resolve-service';
|
|
13
|
+
import { enforceModelAccess } from '../utils/user-permissions';
|
|
13
14
|
import { setAiApiUsageUnavailable } from '../usage';
|
|
14
15
|
import type PluginAiApiServer from '../plugin';
|
|
15
16
|
|
|
@@ -112,24 +113,18 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
|
|
|
112
113
|
return;
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
// ─── Check service whitelist
|
|
116
|
+
// ─── Check service whitelist (global config ∩ per-user grant) ─────────────
|
|
117
|
+
// A config read failure falls open on the global list, but the per-user grant is
|
|
118
|
+
// still enforced: an explicit deny must never be bypassed by an unreadable config.
|
|
119
|
+
let globalEnabledServices: unknown = [];
|
|
116
120
|
try {
|
|
117
121
|
const config = await ctx.db.getRepository('aiApiConfig').findOne();
|
|
118
|
-
|
|
119
|
-
const allowed = config.enabledLlmServices.some((s: string) => s === service.name || s === service.title);
|
|
120
|
-
if (!allowed) {
|
|
121
|
-
ctx.status = 403;
|
|
122
|
-
ctx.body = toOpenAIError(
|
|
123
|
-
403,
|
|
124
|
-
`LLM service '${service.title || service.name}' is not enabled for API access`,
|
|
125
|
-
'invalid_request_error',
|
|
126
|
-
'model_not_available',
|
|
127
|
-
);
|
|
128
|
-
return;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
122
|
+
globalEnabledServices = config?.enabledLlmServices ?? [];
|
|
131
123
|
} catch {
|
|
132
|
-
// Config read failure: fail open
|
|
124
|
+
// Config read failure: fail open on the global whitelist only.
|
|
125
|
+
}
|
|
126
|
+
if (!(await enforceModelAccess(ctx, globalEnabledServices, service, modelId))) {
|
|
127
|
+
return;
|
|
133
128
|
}
|
|
134
129
|
|
|
135
130
|
// ─── Get embedding provider ───────────────────────────────────────────────
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { Context } from '@nocobase/actions';
|
|
11
11
|
import { toOpenAIError } from '../utils/openai-format';
|
|
12
|
+
import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
|
|
12
13
|
import type PluginAiApiServer from '../plugin';
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -19,6 +20,9 @@ import type PluginAiApiServer from '../plugin';
|
|
|
19
20
|
* so clients can copy-paste the ID directly into POST /v1/chat/completions
|
|
20
21
|
* without needing to configure a defaultLlmService.
|
|
21
22
|
*
|
|
23
|
+
* The catalog is scoped to the caller: a user with an aiApiUserPermissions row only
|
|
24
|
+
* sees the intersection of the global whitelist and their own grant.
|
|
25
|
+
*
|
|
22
26
|
* Backward compatibility: resolveModelString() in resolve-service.ts still
|
|
23
27
|
* accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
|
|
24
28
|
*/
|
|
@@ -44,18 +48,25 @@ export async function handleListModels(ctx: Context, plugin: PluginAiApiServer)
|
|
|
44
48
|
sort: 'sort',
|
|
45
49
|
});
|
|
46
50
|
|
|
51
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
52
|
+
if (scope.lookupFailed) {
|
|
53
|
+
respondPermissionCheckFailed(ctx);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
47
56
|
const metadataMap = await loadModelMetadata(ctx);
|
|
48
57
|
const now = Math.floor(Date.now() / 1000);
|
|
49
58
|
const models: any[] = [];
|
|
50
59
|
|
|
51
60
|
for (const service of services) {
|
|
52
61
|
if (service.enabled === false) continue;
|
|
62
|
+
if (!isServiceAllowed(scope, config?.enabledLlmServices, service)) continue;
|
|
53
63
|
|
|
54
64
|
const enabledModels = resolveEnabledModels(service);
|
|
55
65
|
const serviceLabel = service.title || service.name;
|
|
56
66
|
|
|
57
67
|
for (const model of enabledModels) {
|
|
58
68
|
const fullId = `${service.name}/${model.value}`;
|
|
69
|
+
if (!isModelAllowed(scope, fullId)) continue;
|
|
59
70
|
const meta = metadataMap.get(fullId);
|
|
60
71
|
// An override row with enabled=false hides the model from the catalog.
|
|
61
72
|
if (meta && meta.enabled === false) continue;
|
|
@@ -95,12 +106,18 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
|
|
|
95
106
|
sort: 'sort',
|
|
96
107
|
});
|
|
97
108
|
|
|
109
|
+
const scope = await resolveUserAccessScope(ctx);
|
|
110
|
+
if (scope.lookupFailed) {
|
|
111
|
+
respondPermissionCheckFailed(ctx);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
98
114
|
const metadataMap = await loadModelMetadata(ctx);
|
|
99
115
|
const now = Math.floor(Date.now() / 1000);
|
|
100
116
|
let found: any = null;
|
|
101
117
|
|
|
102
118
|
for (const service of services) {
|
|
103
119
|
if (service.enabled === false) continue;
|
|
120
|
+
if (!isServiceAllowed(scope, config?.enabledLlmServices, service)) continue;
|
|
104
121
|
const enabledModels = resolveEnabledModels(service);
|
|
105
122
|
const serviceLabel = service.title || service.name;
|
|
106
123
|
|
|
@@ -108,6 +125,7 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
|
|
|
108
125
|
const fullId = `${service.name}/${model.value}`;
|
|
109
126
|
// Accept both new "serviceName/modelId" format AND bare model ID (backward compat)
|
|
110
127
|
if (fullId === modelId || model.value === modelId) {
|
|
128
|
+
if (!isModelAllowed(scope, fullId)) continue;
|
|
111
129
|
const meta = metadataMap.get(fullId);
|
|
112
130
|
// A disabled override hides the model — treat as not found.
|
|
113
131
|
if (meta && meta.enabled === false) continue;
|
|
@@ -135,6 +153,16 @@ export async function handleGetModel(ctx: Context, modelId: string, plugin: Plug
|
|
|
135
153
|
|
|
136
154
|
// ─── Helpers ───
|
|
137
155
|
|
|
156
|
+
function respondPermissionCheckFailed(ctx: Context): void {
|
|
157
|
+
ctx.status = 503;
|
|
158
|
+
ctx.body = toOpenAIError(
|
|
159
|
+
503,
|
|
160
|
+
'Unable to verify LLM permissions for this user. Please retry shortly.',
|
|
161
|
+
'service_unavailable',
|
|
162
|
+
'permission_check_failed',
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
138
166
|
export interface ModelMetadataOverride {
|
|
139
167
|
contextWindow?: number | null;
|
|
140
168
|
maxCompletionTokens?: number | null;
|