plugin-ai-api 1.0.20 → 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 (91) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  3. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  4. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  5. package/dist/client/{757.71e30f2a1306562d.js → 757.a01403fb7a1bea01.js} +1 -1
  6. package/dist/client/{902.4238b04ac667c30a.js → 902.92e1daaf1ab16ebf.js} +1 -1
  7. package/dist/client/{97.37cda285d7da3a26.js → 97.72979a11a067a7c9.js} +1 -1
  8. package/dist/client/index.js +1 -1
  9. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  10. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  11. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  12. package/dist/client-v2/{757.c377e2f2b054d89d.js → 757.a117ce1cf7119cea.js} +1 -1
  13. package/dist/client-v2/{902.d40d7bda106124c8.js → 902.9054d990ddc223ac.js} +1 -1
  14. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  15. package/dist/client-v2/{97.fc922c37ced86831.js → 97.29c663318eebbd57.js} +1 -1
  16. package/dist/client-v2/index.js +1 -1
  17. package/dist/constants.js +39 -0
  18. package/dist/externalVersion.js +9 -10
  19. package/dist/locale/en-US.json +39 -9
  20. package/dist/locale/vi-VN.json +31 -1
  21. package/dist/locale/zh-CN.json +31 -1
  22. package/dist/server/collections/ai-api-config.js +6 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  24. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  25. package/dist/server/plugin.js +45 -1
  26. package/dist/server/resource/ai-api-config.js +17 -0
  27. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  28. package/dist/server/routes/agent-completions.js +67 -51
  29. package/dist/server/routes/auth.js +11 -1
  30. package/dist/server/routes/chat-completions.js +174 -20
  31. package/dist/server/routes/completions.js +41 -21
  32. package/dist/server/routes/embeddings.js +6 -14
  33. package/dist/server/routes/models.js +102 -20
  34. package/dist/server/routes/router.js +94 -22
  35. package/dist/server/usage.js +2 -0
  36. package/dist/server/utils/app-observability.js +110 -0
  37. package/dist/server/utils/openai-format.js +17 -3
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/utils/user-permissions.js +160 -0
  40. package/dist/server/validation.js +18 -0
  41. package/dist/swagger.js +36 -4
  42. package/package.json +2 -2
  43. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  44. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  45. package/src/client/locale.ts +11 -21
  46. package/src/client/plugin.tsx +28 -8
  47. package/src/client-v2/__tests__/settings-registration.test.tsx +87 -0
  48. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  49. package/src/client-v2/locale.ts +21 -1
  50. package/src/client-v2/pages/GeneralPage.tsx +13 -0
  51. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  52. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  53. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  54. package/src/client-v2/plugin.tsx +50 -1
  55. package/src/constants.ts +28 -0
  56. package/src/locale/en-US.json +39 -9
  57. package/src/locale/vi-VN.json +31 -1
  58. package/src/locale/zh-CN.json +31 -1
  59. package/src/server/__tests__/app-observability.test.ts +98 -0
  60. package/src/server/__tests__/models.test.ts +116 -0
  61. package/src/server/__tests__/openai-format.test.ts +52 -1
  62. package/src/server/__tests__/permission-sync.test.ts +109 -0
  63. package/src/server/__tests__/request-body.test.ts +310 -0
  64. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  65. package/src/server/__tests__/usage-route.test.ts +213 -0
  66. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  67. package/src/server/__tests__/user-permissions.test.ts +284 -0
  68. package/src/server/collections/ai-api-config.ts +6 -0
  69. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  70. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  71. package/src/server/plugin.ts +65 -4
  72. package/src/server/resource/ai-api-config.ts +23 -0
  73. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  74. package/src/server/routes/agent-completions.ts +84 -62
  75. package/src/server/routes/auth.ts +14 -1
  76. package/src/server/routes/chat-completions.ts +294 -20
  77. package/src/server/routes/completions.ts +54 -20
  78. package/src/server/routes/embeddings.ts +10 -15
  79. package/src/server/routes/models.ts +318 -195
  80. package/src/server/routes/router.ts +136 -26
  81. package/src/server/usage.ts +2 -0
  82. package/src/server/utils/app-observability.ts +105 -0
  83. package/src/server/utils/openai-format.ts +26 -0
  84. package/src/server/utils/streaming.ts +13 -1
  85. package/src/server/utils/user-permissions.ts +218 -0
  86. package/src/server/validation.ts +27 -0
  87. package/src/swagger.ts +47 -4
  88. package/dist/client/302.25edd5d75460acbf.js +0 -10
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client-v2/302.9b27a263901d54d8.js +0 -10
  91. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -8,12 +8,25 @@
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';
13
- import { createRequestAbortController, isStreamingRequested, writeResponse } from '../utils/streaming';
19
+ import { enforceModelAccess } from '../utils/user-permissions';
20
+ import {
21
+ createRequestAbortController,
22
+ isClientDisconnected,
23
+ isStreamingRequested,
24
+ writeResponse,
25
+ } from '../utils/streaming';
14
26
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
15
27
  import type PluginAiApiServer from '../plugin';
16
28
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
29
+ import { markAiApiFirstProviderOutput } from '../utils/app-observability';
17
30
 
18
31
  /**
19
32
  * POST /api/ai-llm/v1/completions
@@ -87,22 +100,10 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
87
100
  return;
88
101
  }
89
102
 
90
- // ─── Check whitelist ───
103
+ // ─── Check whitelist (global config ∩ per-user grant) ───
91
104
  const config = await ctx.db.getRepository('aiApiConfig').findOne();
92
- if (config?.enabledLlmServices?.length) {
93
- const serviceName = service.name;
94
- const serviceTitle = service.title;
95
- const isAllowed = config.enabledLlmServices.some((s: string) => s === serviceName || s === serviceTitle);
96
- if (!isAllowed) {
97
- ctx.status = 403;
98
- ctx.body = 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
- }
105
+ if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
106
+ return;
106
107
  }
107
108
 
108
109
  // ─── Create LLM provider instance ───
@@ -160,7 +161,14 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
160
161
  markLlmProviderAttempted(ctx);
161
162
 
162
163
  if (stream) {
163
- 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
+ );
164
172
  } else {
165
173
  await handleNonStreamingTextCompletion(ctx, chatModel, langchainMessages, completionId, body.model);
166
174
  }
@@ -231,6 +239,7 @@ async function handleStreamingTextCompletion(
231
239
  messages: [string, string][],
232
240
  completionId: string,
233
241
  modelName: string,
242
+ streamOptions: Record<string, unknown> | undefined,
234
243
  ) {
235
244
  ctx.set({
236
245
  'Content-Type': 'text/event-stream',
@@ -244,7 +253,10 @@ async function handleStreamingTextCompletion(
244
253
  let usage: Usage | undefined;
245
254
  let providerRequestId: string | undefined;
246
255
  try {
247
- 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
+ });
248
260
 
249
261
  for await (const chunk of stream) {
250
262
  if (requestAbort.signal.aborted) throw requestAbort.signal.reason;
@@ -257,6 +269,7 @@ async function handleStreamingTextCompletion(
257
269
  }
258
270
 
259
271
  if (text) {
272
+ markAiApiFirstProviderOutput(ctx);
260
273
  await writeResponse(
261
274
  ctx,
262
275
  formatSSE({
@@ -273,6 +286,7 @@ async function handleStreamingTextCompletion(
273
286
  finish_reason: null,
274
287
  },
275
288
  ],
289
+ usage: null,
276
290
  }),
277
291
  );
278
292
  }
@@ -299,13 +313,29 @@ async function handleStreamingTextCompletion(
299
313
  finish_reason: 'stop',
300
314
  },
301
315
  ],
316
+ usage: null,
302
317
  }),
303
318
  );
304
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
+
305
334
  await writeResponse(ctx, formatSSEDone());
306
335
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
307
336
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
308
337
  } catch (err) {
338
+ const cancelled = isClientDisconnected(ctx, err);
309
339
  ctx.log.error('AI API completions streaming error:', err);
310
340
  if (!ctx.res.destroyed && !ctx.res.writableEnded) {
311
341
  await writeResponse(
@@ -319,7 +349,11 @@ async function handleStreamingTextCompletion(
319
349
  );
320
350
  }
321
351
  setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
322
- ctx.state.aiApiStreamResult = { succeeded: false, id: completionId, errorCode: 'stream_error' };
352
+ ctx.state.aiApiStreamResult = {
353
+ succeeded: false,
354
+ id: completionId,
355
+ errorCode: cancelled ? 'client_disconnected' : 'stream_error',
356
+ };
323
357
  } finally {
324
358
  requestAbort.dispose();
325
359
  if (!ctx.res.writableEnded && !ctx.res.destroyed) ctx.res.end();
@@ -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 ───────────────────────────────────────────────