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.
Files changed (59) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/902.e74518750f1e4201.js +10 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  5. package/dist/client-v2/902.c7c00a565085438a.js +10 -0
  6. package/dist/client-v2/index.js +1 -1
  7. package/dist/constants.js +5 -2
  8. package/dist/locale/en-US.json +15 -1
  9. package/dist/locale/vi-VN.json +15 -1
  10. package/dist/locale/zh-CN.json +15 -1
  11. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  12. package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
  13. package/dist/server/plugin.js +32 -0
  14. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  15. package/dist/server/routes/agent-completions.js +5 -0
  16. package/dist/server/routes/chat-completions.js +52 -27
  17. package/dist/server/routes/completions.js +59 -33
  18. package/dist/server/routes/embeddings.js +6 -14
  19. package/dist/server/routes/models.js +24 -0
  20. package/dist/server/utils/direct-llm-context.js +184 -0
  21. package/dist/server/utils/openai-format.js +17 -3
  22. package/dist/server/utils/user-permissions.js +160 -0
  23. package/dist/server/validation.js +3 -0
  24. package/dist/swagger.js +4 -3
  25. package/package.json +2 -2
  26. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  27. package/src/client/plugin.tsx +14 -3
  28. package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
  29. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  30. package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
  31. package/src/client-v2/plugin.tsx +12 -3
  32. package/src/constants.ts +7 -0
  33. package/src/locale/en-US.json +15 -1
  34. package/src/locale/vi-VN.json +15 -1
  35. package/src/locale/zh-CN.json +15 -1
  36. package/src/server/__tests__/direct-llm-context.test.ts +125 -0
  37. package/src/server/__tests__/models.test.ts +44 -2
  38. package/src/server/__tests__/openai-format.test.ts +52 -1
  39. package/src/server/__tests__/permission-sync.test.ts +109 -0
  40. package/src/server/__tests__/usage-route.test.ts +265 -5
  41. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  42. package/src/server/__tests__/user-permissions.test.ts +284 -0
  43. package/src/server/__tests__/validation.test.ts +36 -0
  44. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  45. package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
  46. package/src/server/plugin.ts +42 -1
  47. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  48. package/src/server/routes/agent-completions.ts +7 -0
  49. package/src/server/routes/chat-completions.ts +58 -30
  50. package/src/server/routes/completions.ts +68 -34
  51. package/src/server/routes/embeddings.ts +10 -15
  52. package/src/server/routes/models.ts +28 -0
  53. package/src/server/utils/direct-llm-context.ts +216 -0
  54. package/src/server/utils/openai-format.ts +26 -0
  55. package/src/server/utils/user-permissions.ts +218 -0
  56. package/src/server/validation.ts +3 -0
  57. package/src/swagger.ts +9 -3
  58. package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
  59. 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?.length) {
132
- const serviceName = service.name;
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
- const messages = [...body.messages];
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.headersSent) {
271
+ if (!ctx.res?.headersSent) {
264
272
  const isQuotaError = err instanceof AiApiQuotaError;
265
- ctx.status = isQuotaError ? 429 : 500;
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(toOpenAIStreamChunk({ id: completionId, model: modelName, delta: { tool_calls: toolCallChunks } })),
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 { generateCompletionId, toOpenAIError, formatSSE, formatSSEDone } from '../utils/openai-format';
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?.length) {
99
- const serviceName = service.name;
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 langchainMessages: [string, string][] = [];
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
- langchainMessages.push(['system', systemPrompt]);
145
+ messages.push({ role: 'system', content: systemPrompt });
159
146
  }
160
147
  }
161
148
  }
162
- langchainMessages.push(['human', prompt]);
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(ctx, chatModel, langchainMessages, completionId, body.model);
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.headersSent) {
188
+ if (!ctx.res?.headersSent) {
176
189
  const isQuotaError = err instanceof AiApiQuotaError;
177
- ctx.status = isQuotaError ? 429 : 500;
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, { signal: requestAbort.signal });
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
- if (config?.enabledLlmServices?.length) {
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;