plugin-ai-api 1.0.21 → 1.0.23

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 (46) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/index.js +1 -1
  3. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  4. package/dist/client-v2/index.js +1 -1
  5. package/dist/constants.js +5 -2
  6. package/dist/locale/en-US.json +12 -1
  7. package/dist/locale/vi-VN.json +12 -1
  8. package/dist/locale/zh-CN.json +12 -1
  9. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  10. package/dist/server/plugin.js +32 -0
  11. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  12. package/dist/server/routes/agent-completions.js +5 -0
  13. package/dist/server/routes/chat-completions.js +29 -16
  14. package/dist/server/routes/completions.js +33 -20
  15. package/dist/server/routes/embeddings.js +6 -14
  16. package/dist/server/routes/models.js +24 -0
  17. package/dist/server/utils/openai-format.js +17 -3
  18. package/dist/server/utils/user-permissions.js +160 -0
  19. package/dist/swagger.js +4 -3
  20. package/package.json +2 -2
  21. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  22. package/src/client/plugin.tsx +14 -3
  23. package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
  24. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  25. package/src/client-v2/plugin.tsx +12 -3
  26. package/src/constants.ts +7 -0
  27. package/src/locale/en-US.json +12 -1
  28. package/src/locale/vi-VN.json +12 -1
  29. package/src/locale/zh-CN.json +12 -1
  30. package/src/server/__tests__/models.test.ts +44 -2
  31. package/src/server/__tests__/openai-format.test.ts +52 -1
  32. package/src/server/__tests__/permission-sync.test.ts +109 -0
  33. package/src/server/__tests__/usage-route.test.ts +213 -0
  34. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  35. package/src/server/__tests__/user-permissions.test.ts +284 -0
  36. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  37. package/src/server/plugin.ts +42 -1
  38. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  39. package/src/server/routes/agent-completions.ts +7 -0
  40. package/src/server/routes/chat-completions.ts +32 -16
  41. package/src/server/routes/completions.ts +40 -18
  42. package/src/server/routes/embeddings.ts +10 -15
  43. package/src/server/routes/models.ts +28 -0
  44. package/src/server/utils/openai-format.ts +26 -0
  45. package/src/server/utils/user-permissions.ts +218 -0
  46. package/src/swagger.ts +9 -3
@@ -8,13 +8,16 @@
8
8
  */
9
9
 
10
10
  import { Plugin } from '@nocobase/server';
11
+ import type { Transactionable } from '@nocobase/database';
11
12
  import { createAiLlmRouter, AI_LLM_PREFIX } from './routes/router';
12
13
  import aiApiConfigResource from './resource/ai-api-config';
13
14
  import aiApiUsageMonitorResource from './resource/ai-api-usage-monitor';
15
+ import aiApiUserPermissionsResource from './resource/ai-api-user-permissions';
14
16
  import { RateLimiter } from './utils/rate-limiter';
15
17
  import { invalidateRolePermissionCache } from './middleware/role-permission';
18
+ import { invalidateUserPermissionCache } from './utils/user-permissions';
16
19
  import { validateModelPrice, validateModelMetadata, validateQuotaPolicy } from './validation';
17
- import { AI_API_ACL_SNIPPET } from '../constants';
20
+ import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
18
21
 
19
22
  // Ensure dayjs timezone + utc plugins are loaded.
20
23
  // Some Docker builds ship an older @nocobase/utils whose dayjs.js does not
@@ -77,6 +80,7 @@ export class PluginAiApiServer extends Plugin {
77
80
  // 2. Register admin config resource
78
81
  this.app.resourceManager.define(aiApiConfigResource);
79
82
  this.app.resourceManager.define(aiApiUsageMonitorResource);
83
+ this.app.resourceManager.define(aiApiUserPermissionsResource);
80
84
 
81
85
  this.app.db.on('aiApiRolePermissions.afterSave', (model) => {
82
86
  invalidateRolePermissionCache(model.get('roleName'));
@@ -85,6 +89,13 @@ export class PluginAiApiServer extends Plugin {
85
89
  invalidateRolePermissionCache(model.get('roleName'));
86
90
  });
87
91
 
92
+ this.app.db.on('aiApiUserPermissions.afterSave', (model, options) => {
93
+ this.revokeUserPermissions(model.get('userId'), options?.transaction);
94
+ });
95
+ this.app.db.on('aiApiUserPermissions.afterDestroy', (model, options) => {
96
+ this.revokeUserPermissions(model.get('userId'), options?.transaction);
97
+ });
98
+
88
99
  // 3. Set ACL permissions for admin config + role permissions management
89
100
  this.app.acl.registerSnippet({
90
101
  name: AI_API_ACL_SNIPPET,
@@ -102,12 +113,42 @@ export class PluginAiApiServer extends Plugin {
102
113
  ],
103
114
  });
104
115
 
116
+ // Per-user LLM grants are a separate child permission: handing out model access is a
117
+ // stronger capability than editing gateway settings, so it ticks independently.
118
+ // The wildcard also covers `listUsers`, which backs the page's user picker — without it
119
+ // the page would depend on `pm.plugin-users` and break for a role holding only this snippet.
120
+ this.app.acl.registerSnippet({
121
+ name: AI_API_USER_PERMISSIONS_SNIPPET,
122
+ actions: ['aiApiUserPermissions:*'],
123
+ });
124
+
105
125
  // 4. GC the rate limiter every 5 minutes to evict stale user entries.
106
126
  // .unref() prevents this timer from keeping the process alive on shutdown.
107
127
  this.gcInterval = setInterval(() => this.rateLimiter.gc(), 5 * 60 * 1000);
108
128
  this.gcInterval.unref();
109
129
  }
110
130
 
131
+ /**
132
+ * Drop a user's cached LLM scope on every node.
133
+ *
134
+ * The local call is not redundant: syncMessageManager hardcodes skipSelf, so the publishing
135
+ * node never receives its own message. Passing the transaction defers the broadcast until
136
+ * the write commits, so other nodes cannot re-read the old row and re-cache it.
137
+ */
138
+ private revokeUserPermissions(userId: unknown, transaction?: Transactionable['transaction']) {
139
+ invalidateUserPermissionCache(userId as string | number | bigint);
140
+ this.sendSyncMessage({ type: 'invalidateUserPermissions', userId }, { transaction });
141
+ }
142
+
143
+ /**
144
+ * Received only on the *other* nodes (skipSelf), so this must not re-broadcast.
145
+ */
146
+ async handleSyncMessage(message: { type?: string; userId?: unknown }) {
147
+ if (message?.type === 'invalidateUserPermissions') {
148
+ invalidateUserPermissionCache(message.userId as string | number | bigint);
149
+ }
150
+ }
151
+
111
152
  async install() {
112
153
  // Create default config record on first install
113
154
  const existing = await this.db.getRepository('aiApiConfig').findOne();
@@ -0,0 +1,76 @@
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
+ import type { ResourceOptions } from '@nocobase/resourcer';
11
+
12
+ const MAX_PAGE_SIZE = 100;
13
+
14
+ /**
15
+ * Minimal user directory for the permission page's user picker.
16
+ *
17
+ * The page is gated by AI_API_USER_PERMISSIONS_SNIPPET, but `users:list` belongs to
18
+ * `pm.plugin-users`. Calling it would force every permission admin to also be a user admin,
19
+ * so this action exposes only the identity fields the picker renders — never password hashes,
20
+ * roles or any other user column.
21
+ */
22
+ const aiApiUserPermissionsResource: ResourceOptions = {
23
+ name: 'aiApiUserPermissions',
24
+ actions: {
25
+ async listUsers(ctx, next) {
26
+ const params = ctx.action.params || {};
27
+ const keyword = typeof params.keyword === 'string' ? params.keyword.trim() : '';
28
+ const page = Math.max(1, Number(params.page) || 1);
29
+ const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(params.pageSize) || 50));
30
+
31
+ const filter: Record<string, unknown> = keyword
32
+ ? {
33
+ $or: [
34
+ { nickname: { $includes: keyword } },
35
+ { username: { $includes: keyword } },
36
+ { email: { $includes: keyword } },
37
+ ],
38
+ }
39
+ : {};
40
+
41
+ // A user already holding a grant is excluded so the picker cannot produce a duplicate
42
+ // that the unique index on userId would reject at save time.
43
+ if (params.excludeGranted) {
44
+ const granted = await ctx.db.getRepository('aiApiUserPermissions').find({ fields: ['userId'] });
45
+ const ids = granted.map((row) => row.get('userId')).filter((id) => id !== null && id !== undefined);
46
+ if (ids.length) filter.id = { $notIn: ids };
47
+ }
48
+
49
+ const [rows, count] = await ctx.db.getRepository('users').findAndCount({
50
+ filter,
51
+ fields: ['id', 'nickname', 'username', 'email'],
52
+ sort: ['nickname', 'id'],
53
+ offset: (page - 1) * pageSize,
54
+ limit: pageSize,
55
+ });
56
+
57
+ // Use the canonical { rows, ...meta } action shape. NocoBase's dataWrapping
58
+ // middleware turns this into { data, meta } on the wire; returning that wire
59
+ // shape here would make it wrap a second time.
60
+ ctx.body = {
61
+ rows: rows.map((row) => ({
62
+ id: row.get('id'),
63
+ nickname: row.get('nickname'),
64
+ username: row.get('username'),
65
+ email: row.get('email'),
66
+ })),
67
+ count,
68
+ page,
69
+ pageSize,
70
+ };
71
+ await next();
72
+ },
73
+ },
74
+ };
75
+
76
+ export default aiApiUserPermissionsResource;
@@ -18,6 +18,7 @@ import {
18
18
  } from '../utils/openai-format';
19
19
  import { resolveModelString } from '../utils/resolve-service';
20
20
  import { checkEmployeeAccess } from '../middleware/role-permission';
21
+ import { enforceModelAccess } from '../utils/user-permissions';
21
22
  import { isStreamingRequested } from '../utils/streaming';
22
23
  import {
23
24
  AgentRuntimeContext,
@@ -121,6 +122,12 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
121
122
  return;
122
123
  }
123
124
 
125
+ // ─── Check whitelist (global config ∩ per-user grant) ──────────────────────
126
+ const globalEnabledServices = config ? config.get('enabledLlmServices') || config.enabledLlmServices : [];
127
+ if (!(await enforceModelAccess(ctx, globalEnabledServices, service, modelId))) {
128
+ return;
129
+ }
130
+
124
131
  const employeeUsername = defaultAiEmployee;
125
132
 
126
133
  // ─── Check role is allowed to use this employee ────────────────────────────
@@ -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,6 +27,7 @@ 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';
@@ -126,22 +128,10 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
126
128
  return;
127
129
  }
128
130
 
129
- // ─── Check whitelist ───
131
+ // ─── Check whitelist (global config ∩ per-user grant) ───
130
132
  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
- }
133
+ if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
134
+ return;
145
135
  }
146
136
 
147
137
  // ─── Create LLM provider instance ───
@@ -155,6 +145,13 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
155
145
  await prepareLlmBilling(ctx, resolved);
156
146
 
157
147
  const providerRequestParameters = getProviderRequestParameters(body);
148
+ if (stream) {
149
+ const streamOptions = isRecord(body.stream_options) ? body.stream_options : {};
150
+ providerRequestParameters.stream_options = {
151
+ ...streamOptions,
152
+ include_usage: true,
153
+ };
154
+ }
158
155
  const modelOptions: Record<string, unknown> = {
159
156
  model: modelId,
160
157
  llmService: service.name,
@@ -380,7 +377,13 @@ async function handleStreamingCompletion(
380
377
  finishReason = 'tool_calls';
381
378
  await writeResponse(
382
379
  ctx,
383
- formatSSE(toOpenAIStreamChunk({ id: completionId, model: modelName, delta: { tool_calls: toolCallChunks } })),
380
+ formatSSE(
381
+ toOpenAIStreamChunk({
382
+ id: completionId,
383
+ model: modelName,
384
+ delta: { tool_calls: toolCallChunks },
385
+ }),
386
+ ),
384
387
  );
385
388
  }
386
389
  if (chunk.usage_metadata) {
@@ -402,6 +405,19 @@ async function handleStreamingCompletion(
402
405
  ),
403
406
  );
404
407
 
408
+ if (usage) {
409
+ await writeResponse(
410
+ ctx,
411
+ formatSSE(
412
+ toOpenAIUsageChunk({
413
+ id: completionId,
414
+ model: modelName,
415
+ usage,
416
+ }),
417
+ ),
418
+ );
419
+ }
420
+
405
421
  // Send [DONE]
406
422
  await writeResponse(ctx, formatSSEDone());
407
423
  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,
@@ -93,22 +100,10 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
93
100
  return;
94
101
  }
95
102
 
96
- // ─── Check whitelist ───
103
+ // ─── Check whitelist (global config ∩ per-user grant) ───
97
104
  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
- }
105
+ if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
106
+ return;
112
107
  }
113
108
 
114
109
  // ─── Create LLM provider instance ───
@@ -166,7 +161,14 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
166
161
  markLlmProviderAttempted(ctx);
167
162
 
168
163
  if (stream) {
169
- await handleStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
164
+ await handleStreamingTextCompletion(
165
+ ctx,
166
+ chatModel,
167
+ langchainMessages,
168
+ completionId,
169
+ body.model,
170
+ body.stream_options,
171
+ );
170
172
  } else {
171
173
  await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
172
174
  }
@@ -237,6 +239,7 @@ async function handleStreamingTextCompletion(
237
239
  messages: [string, string][],
238
240
  completionId: string,
239
241
  modelName: string,
242
+ streamOptions: Record<string, unknown> | undefined,
240
243
  ) {
241
244
  ctx.set({
242
245
  'Content-Type': 'text/event-stream',
@@ -250,7 +253,10 @@ async function handleStreamingTextCompletion(
250
253
  let usage: Usage | undefined;
251
254
  let providerRequestId: string | undefined;
252
255
  try {
253
- const stream = await chatModel.stream(messages, { signal: requestAbort.signal });
256
+ const stream = await chatModel.stream(messages, {
257
+ stream_options: { ...streamOptions, include_usage: true },
258
+ signal: requestAbort.signal,
259
+ });
254
260
 
255
261
  for await (const chunk of stream) {
256
262
  if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
@@ -280,6 +286,7 @@ async function handleStreamingTextCompletion(
280
286
  finish_reason: null,
281
287
  },
282
288
  ],
289
+ usage: null,
283
290
  }),
284
291
  );
285
292
  }
@@ -306,9 +313,24 @@ async function handleStreamingTextCompletion(
306
313
  finish_reason: 'stop',
307
314
  },
308
315
  ],
316
+ usage: null,
309
317
  }),
310
318
  );
311
319
 
320
+ if (usage) {
321
+ await writeResponse(
322
+ ctx,
323
+ formatSSE(
324
+ toOpenAIUsageChunk({
325
+ id: completionId,
326
+ model: modelName,
327
+ object: 'text_completion',
328
+ usage,
329
+ }),
330
+ ),
331
+ );
332
+ }
333
+
312
334
  await writeResponse(ctx, formatSSEDone());
313
335
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
314
336
  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;
@@ -97,6 +97,14 @@ export function toOpenAIResponse(options: {
97
97
 
98
98
  // ─── OpenAI Streaming chunk format ───
99
99
 
100
+ export type OpenAIUsage = {
101
+ prompt_tokens: number | null;
102
+ completion_tokens: number | null;
103
+ total_tokens: number | null;
104
+ };
105
+
106
+ export type OpenAIStreamObject = 'chat.completion.chunk' | 'text_completion';
107
+
100
108
  export function toOpenAIStreamChunk(options: {
101
109
  id: string;
102
110
  model: string;
@@ -118,6 +126,24 @@ export function toOpenAIStreamChunk(options: {
118
126
  finish_reason: finishReason,
119
127
  },
120
128
  ],
129
+ usage: null,
130
+ };
131
+ }
132
+
133
+ export function toOpenAIUsageChunk(options: {
134
+ id: string;
135
+ model: string;
136
+ usage: OpenAIUsage;
137
+ object?: OpenAIStreamObject;
138
+ }) {
139
+ const { id, model, usage, object = 'chat.completion.chunk' } = options;
140
+ return {
141
+ id,
142
+ object,
143
+ created: Math.floor(Date.now() / 1000),
144
+ model,
145
+ choices: [],
146
+ usage,
121
147
  };
122
148
  }
123
149