plugin-ai-api 1.0.25 → 1.0.28

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 (115) hide show
  1. package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
  2. package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
  3. package/dist/client/562.44b16aad4718b4c7.js +10 -0
  4. package/dist/client/685.ae483e17b6b49c98.js +10 -0
  5. package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
  6. package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.3971233415999b2c.js +10 -0
  9. package/dist/client-v2/562.45d5c504433be38b.js +10 -0
  10. package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
  11. package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
  12. package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
  13. package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +2 -5
  16. package/dist/locale/en-US.json +26 -8
  17. package/dist/locale/vi-VN.json +26 -8
  18. package/dist/locale/zh-CN.json +26 -8
  19. package/dist/server/billing.js +25 -32
  20. package/dist/server/collections/ai-api-config.js +1 -7
  21. package/dist/server/collections/ai-api-group-members.js +62 -0
  22. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  23. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  24. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  25. package/dist/server/collections/ai-api-usage-records.js +1 -0
  26. package/dist/server/middleware/rate-limit.js +7 -6
  27. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  28. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  29. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  30. package/dist/server/plugin.js +90 -22
  31. package/dist/server/quota-groups.js +108 -0
  32. package/dist/server/resource/ai-api-config.js +0 -3
  33. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  34. package/dist/server/routes/agent-completions.js +2 -1
  35. package/dist/server/routes/chat-completions.js +32 -32
  36. package/dist/server/routes/completions.js +16 -19
  37. package/dist/server/routes/embeddings.js +2 -1
  38. package/dist/server/routes/models.js +2 -1
  39. package/dist/server/routes/router.js +3 -2
  40. package/dist/server/services/file-processor.js +186 -22
  41. package/dist/server/usage.js +5 -1
  42. package/dist/server/utils/direct-llm-context.js +13 -11
  43. package/dist/server/utils/rate-limiter.js +1 -1
  44. package/dist/server/utils/request-cache.js +61 -0
  45. package/dist/server/utils/resolve-service.js +2 -1
  46. package/dist/server/utils/user-permissions.js +25 -39
  47. package/dist/server/validation.js +7 -0
  48. package/dist/swagger.js +6 -7
  49. package/package.json +1 -1
  50. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  51. package/src/client/plugin.tsx +5 -16
  52. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  53. package/src/client-v2/locale.ts +3 -1
  54. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  55. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  56. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  57. package/src/client-v2/plugin.tsx +4 -13
  58. package/src/constants.ts +0 -7
  59. package/src/locale/en-US.json +26 -8
  60. package/src/locale/vi-VN.json +26 -8
  61. package/src/locale/zh-CN.json +26 -8
  62. package/src/server/__tests__/billing-quota.test.ts +28 -9
  63. package/src/server/__tests__/direct-llm-context.test.ts +122 -4
  64. package/src/server/__tests__/file-processor.test.ts +225 -0
  65. package/src/server/__tests__/models.test.ts +1 -1
  66. package/src/server/__tests__/permission-sync.test.ts +34 -35
  67. package/src/server/__tests__/usage-groups.test.ts +160 -0
  68. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  69. package/src/server/__tests__/usage-route.test.ts +262 -2
  70. package/src/server/__tests__/usage.test.ts +38 -0
  71. package/src/server/__tests__/user-permissions.test.ts +214 -133
  72. package/src/server/__tests__/validation.test.ts +11 -0
  73. package/src/server/billing.ts +30 -38
  74. package/src/server/collections/ai-api-config.ts +1 -7
  75. package/src/server/collections/ai-api-group-members.ts +41 -0
  76. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  77. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  78. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  79. package/src/server/collections/ai-api-usage-records.ts +1 -0
  80. package/src/server/middleware/rate-limit.ts +10 -12
  81. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  82. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  83. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  84. package/src/server/plugin.ts +101 -30
  85. package/src/server/quota-groups.ts +117 -0
  86. package/src/server/resource/ai-api-config.ts +0 -3
  87. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  88. package/src/server/routes/agent-completions.ts +2 -1
  89. package/src/server/routes/chat-completions.ts +39 -36
  90. package/src/server/routes/completions.ts +18 -21
  91. package/src/server/routes/embeddings.ts +2 -1
  92. package/src/server/routes/models.ts +4 -3
  93. package/src/server/routes/router.ts +4 -3
  94. package/src/server/services/file-processor.ts +214 -24
  95. package/src/server/usage.ts +5 -1
  96. package/src/server/utils/direct-llm-context.ts +20 -11
  97. package/src/server/utils/rate-limiter.ts +1 -1
  98. package/src/server/utils/request-cache.ts +59 -0
  99. package/src/server/utils/resolve-service.ts +2 -1
  100. package/src/server/utils/user-permissions.ts +49 -69
  101. package/src/server/validation.ts +7 -0
  102. package/src/swagger.ts +7 -8
  103. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  104. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  105. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  106. package/dist/client/902.e74518750f1e4201.js +0 -10
  107. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  108. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  109. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  110. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  111. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  112. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  113. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  114. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  115. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -0,0 +1,171 @@
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 { Context } from '@nocobase/actions';
11
+
12
+ const USER_SUMMARY_FIELDS = ['id', 'username', 'nickname', 'email'];
13
+
14
+ async function getByUser(ctx: Context, next: () => Promise<void>) {
15
+ const { userId } = ctx.action.params;
16
+ if (!userId) {
17
+ ctx.throw(400, 'userId is required');
18
+ }
19
+ const member = await ctx.db.getRepository('aiApiGroupMembers').findOne({
20
+ filter: { userId },
21
+ appends: ['group'],
22
+ });
23
+
24
+ if (member && member.get('group')) {
25
+ ctx.body = member.get('group');
26
+ return next();
27
+ }
28
+
29
+ const defaultGroup = await ctx.db.getRepository('aiApiUsageGroups').findOne({
30
+ filter: { isDefault: true },
31
+ });
32
+
33
+ if (!defaultGroup) {
34
+ ctx.throw(404, 'Default group not found');
35
+ }
36
+
37
+ ctx.body = defaultGroup;
38
+ return next();
39
+ }
40
+
41
+ async function listUnassignedUsers(ctx: Context, next: () => Promise<void>) {
42
+ const { page = 1, pageSize = 20, keyword } = ctx.action.params;
43
+
44
+ const memberRows = (await ctx.db.getRepository('aiApiGroupMembers').find({
45
+ fields: ['userId'],
46
+ pageSize: 10000,
47
+ })) as unknown[];
48
+ const assignedUserIds = memberRows.map((row) => {
49
+ const value = (row as Record<string, unknown>).get?.('userId') ?? (row as Record<string, unknown>).userId;
50
+ return String(value);
51
+ });
52
+
53
+ const where: Record<string, unknown> = {};
54
+ if (keyword) {
55
+ where.$or = [
56
+ { username: { $includes: keyword } },
57
+ { email: { $includes: keyword } },
58
+ { nickname: { $includes: keyword } },
59
+ ];
60
+ }
61
+ if (assignedUserIds.length > 0) {
62
+ where.id = { $notIn: assignedUserIds };
63
+ }
64
+
65
+ const repository = ctx.db.getRepository('users');
66
+ const [rows, count] = await repository.findAndCount({
67
+ filter: where,
68
+ fields: USER_SUMMARY_FIELDS,
69
+ page: Number(page),
70
+ pageSize: Number(pageSize),
71
+ });
72
+
73
+ ctx.body = { rows, count };
74
+ return next();
75
+ }
76
+
77
+ async function searchUsers(ctx: Context, next: () => Promise<void>) {
78
+ const { keyword, pageSize = 20 } = ctx.action.params;
79
+
80
+ const where: Record<string, unknown> = {};
81
+ if (keyword) {
82
+ where.$or = [
83
+ { username: { $includes: keyword } },
84
+ { email: { $includes: keyword } },
85
+ { nickname: { $includes: keyword } },
86
+ ];
87
+ }
88
+
89
+ const [rows, count] = await ctx.db.getRepository('users').findAndCount({
90
+ filter: where,
91
+ fields: USER_SUMMARY_FIELDS,
92
+ page: 1,
93
+ pageSize: Number(pageSize),
94
+ });
95
+
96
+ ctx.body = { rows, count };
97
+ return next();
98
+ }
99
+
100
+ async function addMember(ctx: Context, next: () => Promise<void>) {
101
+ const { groupId, userId } = ctx.action.params.values || ctx.action.params || {};
102
+ if (!groupId || !userId) {
103
+ ctx.throw(400, 'groupId and userId are required');
104
+ }
105
+
106
+ const group = await ctx.db.getRepository('aiApiUsageGroups').findOne({
107
+ filterByTk: groupId,
108
+ });
109
+ if (!group) {
110
+ ctx.throw(404, 'Usage group not found');
111
+ }
112
+ // The default group is a fallback, not a membership target: an explicit row would
113
+ // pin the user there and hide them from listUnassignedUsers forever.
114
+ if (group.get('isDefault')) {
115
+ ctx.throw(400, 'Cannot add members to the default group: users without another group use it automatically');
116
+ }
117
+
118
+ const existing = await ctx.db.getRepository('aiApiGroupMembers').findOne({
119
+ filter: { userId },
120
+ });
121
+ if (existing) {
122
+ ctx.throw(409, 'User already belongs to a group');
123
+ }
124
+
125
+ const member = await ctx.db.getRepository('aiApiGroupMembers').create({
126
+ values: { groupId, userId },
127
+ });
128
+ ctx.body = member;
129
+ return next();
130
+ }
131
+
132
+ async function removeMember(ctx: Context, next: () => Promise<void>) {
133
+ const { groupId, userId } = ctx.action.params.values || ctx.action.params || {};
134
+ if (!groupId || !userId) {
135
+ ctx.throw(400, 'groupId and userId are required');
136
+ }
137
+
138
+ const member = await ctx.db.getRepository('aiApiGroupMembers').findOne({
139
+ filter: { groupId, userId },
140
+ });
141
+ if (!member) {
142
+ ctx.throw(404, 'Member not found');
143
+ }
144
+
145
+ await ctx.db.getRepository('aiApiGroupMembers').destroy({
146
+ filterByTk: member.get('id'),
147
+ });
148
+ ctx.body = member;
149
+ return next();
150
+ }
151
+
152
+ export default {
153
+ name: 'aiApiUsageGroups',
154
+ actions: {
155
+ getByUser: {
156
+ handler: getByUser,
157
+ },
158
+ listUnassignedUsers: {
159
+ handler: listUnassignedUsers,
160
+ },
161
+ searchUsers: {
162
+ handler: searchUsers,
163
+ },
164
+ addMember: {
165
+ handler: addMember,
166
+ },
167
+ removeMember: {
168
+ handler: removeMember,
169
+ },
170
+ },
171
+ };
@@ -19,6 +19,7 @@ import {
19
19
  import { resolveModelString } from '../utils/resolve-service';
20
20
  import { checkEmployeeAccess } from '../middleware/role-permission';
21
21
  import { enforceModelAccess } from '../utils/user-permissions';
22
+ import { getAiApiConfig } from '../utils/request-cache';
22
23
  import { isStreamingRequested } from '../utils/streaming';
23
24
  import {
24
25
  AgentRuntimeContext,
@@ -89,7 +90,7 @@ export async function handleAgentCompletions(ctx: Context, plugin: PluginAiApiSe
89
90
  }
90
91
 
91
92
  // ─── Load config and validate AI Employee ─────────────────────────────────
92
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
93
+ const config = await getAiApiConfig(ctx);
93
94
  const defaultAiEmployee = config ? config.get('defaultAiEmployee') || config.defaultAiEmployee : null;
94
95
  if (!defaultAiEmployee) {
95
96
  ctx.status = 400;
@@ -26,8 +26,8 @@ import {
26
26
  isStreamingRequested,
27
27
  writeResponse,
28
28
  } from '../utils/streaming';
29
- import { checkEmployeeAccess } from '../middleware/role-permission';
30
29
  import { enforceModelAccess } from '../utils/user-permissions';
30
+ import { getAiApiConfig } from '../utils/request-cache';
31
31
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
32
32
  import type PluginAiApiServer from '../plugin';
33
33
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
@@ -131,7 +131,7 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
131
131
  }
132
132
 
133
133
  // ─── Check whitelist (global config ∩ per-user grant) ───
134
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
134
+ const config = await getAiApiConfig(ctx);
135
135
  if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
136
136
  return;
137
137
  }
@@ -166,35 +166,10 @@ export async function handleChatCompletions(ctx: Context, plugin: PluginAiApiSer
166
166
  if (body.presence_penalty !== undefined) modelOptions.presencePenalty = body.presence_penalty;
167
167
  if (body.stop !== undefined) modelOptions.stop = body.stop;
168
168
 
169
- // ─── Build system prompt from AI Employee ───
170
- let systemPrompt = '';
171
- if (config?.defaultAiEmployee) {
172
- // Check role is allowed to use this employee
173
- if (!checkEmployeeAccess(ctx, config.defaultAiEmployee)) {
174
- ctx.status = 403;
175
- ctx.body = toOpenAIError(
176
- 403,
177
- `Role is not permitted to use AI Employee '${config.defaultAiEmployee}'. ` +
178
- `An admin must grant access in Settings → Users & Permissions → [Role] → AI API.`,
179
- 'permission_denied',
180
- 'employee_not_permitted',
181
- );
182
- return;
183
- }
184
- const employee = await ctx.db.getRepository('aiEmployees').findOne({
185
- filter: { username: config.defaultAiEmployee },
186
- });
187
- if (employee) {
188
- systemPrompt = employee.about || employee.defaultPrompt || '';
189
- }
190
- }
191
-
192
- // ─── Build messages (inject system prompt if not provided by client) ───
169
+ // Direct LLM mode ignores the default AI Employee: its prompt belongs to agent
170
+ // mode only. The model metadata system prompt is still applied later by
171
+ // prepareDirectLlmContext.
193
172
  let messages: OpenAIMessage[] = [...body.messages];
194
- const hasSystemMessage = messages.some((m: any) => m.role === 'system');
195
- if (systemPrompt && !hasSystemMessage) {
196
- messages.unshift({ role: 'system', content: systemPrompt });
197
- }
198
173
 
199
174
  // ─── Process file / file_url blocks through the file processor service ───
200
175
  messages = await Promise.all(
@@ -327,12 +302,14 @@ async function handleNonStreamingCompletion(
327
302
 
328
303
  ctx.status = 200;
329
304
  const toolCalls = normalizeToolCalls(result.tool_calls);
305
+ const providerFinishReason = extractFinishReason(result);
330
306
  ctx.body = toOpenAIResponse({
331
307
  id: completionId,
332
308
  model: modelName,
333
309
  content,
334
310
  usage: usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
335
311
  toolCalls,
312
+ ...(providerFinishReason ? { finishReason: providerFinishReason } : {}),
336
313
  });
337
314
  }
338
315
 
@@ -369,8 +346,10 @@ async function handleStreamingCompletion(
369
346
 
370
347
  const requestAbort = createRequestAbortController(ctx);
371
348
  let usage: Usage | undefined;
349
+ let usageResponseMetadata: unknown;
372
350
  let providerRequestId: string | undefined;
373
- let finishReason = 'stop';
351
+ let providerFinishReason: string | undefined;
352
+ let sawToolCalls = false;
374
353
  try {
375
354
  const stream = await chatModel.stream(messages, { ...providerRequestParameters, signal: requestAbort.signal });
376
355
 
@@ -401,7 +380,7 @@ async function handleStreamingCompletion(
401
380
  const toolCallChunks = normalizeToolCallChunks(chunk.tool_call_chunks);
402
381
  if (toolCallChunks.length) {
403
382
  markAiApiFirstProviderOutput(ctx);
404
- finishReason = 'tool_calls';
383
+ sawToolCalls = true;
405
384
  await writeResponse(
406
385
  ctx,
407
386
  formatSSE(
@@ -414,8 +393,14 @@ async function handleStreamingCompletion(
414
393
  );
415
394
  }
416
395
  if (chunk.usage_metadata) {
417
- usage = normalizeUsage(chunk.usage_metadata) ?? usage;
396
+ const normalized = normalizeUsage(chunk.usage_metadata);
397
+ if (normalized) {
398
+ usage = normalized;
399
+ usageResponseMetadata = chunk.response_metadata;
400
+ }
418
401
  }
402
+ const chunkFinishReason = extractFinishReason(chunk);
403
+ if (chunkFinishReason) providerFinishReason = chunkFinishReason;
419
404
  providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
420
405
  }
421
406
 
@@ -427,7 +412,7 @@ async function handleStreamingCompletion(
427
412
  id: completionId,
428
413
  model: modelName,
429
414
  delta: {},
430
- finishReason,
415
+ finishReason: providerFinishReason ?? (sawToolCalls ? 'tool_calls' : 'stop'),
431
416
  }),
432
417
  ),
433
418
  );
@@ -447,7 +432,7 @@ async function handleStreamingCompletion(
447
432
 
448
433
  // Send [DONE]
449
434
  await writeResponse(ctx, formatSSEDone());
450
- setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
435
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
451
436
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
452
437
  } catch (err) {
453
438
  const cancelled = isClientDisconnected(ctx, err);
@@ -464,7 +449,7 @@ async function handleStreamingCompletion(
464
449
  }),
465
450
  );
466
451
  }
467
- setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
452
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
468
453
  ctx.state.aiApiStreamResult = {
469
454
  succeeded: false,
470
455
  id: completionId,
@@ -480,6 +465,24 @@ function getErrorMessage(error: unknown, fallback: string) {
480
465
  return error instanceof Error && error.message ? error.message : fallback;
481
466
  }
482
467
 
468
+ /**
469
+ * Reads the provider's finish_reason from a LangChain result or stream chunk.
470
+ * LangChain surfaces it in response_metadata.finish_reason; some adapters put it
471
+ * in additional_kwargs.finish_reason instead. Returns undefined when the provider
472
+ * reported nothing, so callers can fall back to their own default.
473
+ */
474
+ export function extractFinishReason(value: unknown): string | undefined {
475
+ if (!isRecord(value)) return undefined;
476
+ const candidates = [
477
+ isRecord(value.response_metadata) ? value.response_metadata.finish_reason : undefined,
478
+ isRecord(value.additional_kwargs) ? value.additional_kwargs.finish_reason : undefined,
479
+ ];
480
+ for (const candidate of candidates) {
481
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate;
482
+ }
483
+ return undefined;
484
+ }
485
+
483
486
  /**
484
487
  * Content block types every provider adapter in `@nocobase/plugin-ai` maps to a
485
488
  * native equivalent.
@@ -23,7 +23,8 @@ import {
23
23
  isStreamingRequested,
24
24
  writeResponse,
25
25
  } from '../utils/streaming';
26
- import { getProviderRequestParameters, applyProviderRequestParameters } from './chat-completions';
26
+ import { getProviderRequestParameters, applyProviderRequestParameters, extractFinishReason } from './chat-completions';
27
+ import { getAiApiConfig } from '../utils/request-cache';
27
28
  import { extractProviderRequestId, normalizeUsage, setAiApiUsageResult, type Usage } from '../usage';
28
29
  import type PluginAiApiServer from '../plugin';
29
30
  import { AiApiQuotaError, markLlmProviderAttempted, prepareLlmBilling } from '../billing';
@@ -103,7 +104,7 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
103
104
  }
104
105
 
105
106
  // ─── Check whitelist (global config ∩ per-user grant) ───
106
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
107
+ const config = await getAiApiConfig(ctx);
107
108
  if (!(await enforceModelAccess(ctx, config?.enabledLlmServices, service, modelId))) {
108
109
  return;
109
110
  }
@@ -134,20 +135,8 @@ export async function handleCompletions(ctx: Context, plugin: PluginAiApiServer)
134
135
  ? body.prompt.join('\n')
135
136
  : String(body.prompt);
136
137
 
137
- // Inject system prompt from AI Employee if configured
138
- const messages: OpenAIMessage[] = [];
139
- if (config?.defaultAiEmployee) {
140
- const employee = await ctx.db.getRepository('aiEmployees').findOne({
141
- filter: { username: config.defaultAiEmployee },
142
- });
143
- if (employee) {
144
- const systemPrompt = employee.about || employee.defaultPrompt || '';
145
- if (systemPrompt) {
146
- messages.push({ role: 'system', content: systemPrompt });
147
- }
148
- }
149
- }
150
- messages.push({ role: 'user', content: prompt });
138
+ // Direct LLM mode ignores the default AI Employee: its prompt belongs to agent mode only.
139
+ const messages: OpenAIMessage[] = [{ role: 'user', content: prompt }];
151
140
 
152
141
  const preparedContext = await prepareDirectLlmContext(ctx, {
153
142
  serviceName: service.name,
@@ -253,7 +242,7 @@ async function handleNonStreamingTextCompletion(
253
242
  text,
254
243
  index: 0,
255
244
  logprobs: null,
256
- finish_reason: 'stop',
245
+ finish_reason: extractFinishReason(result) ?? 'stop',
257
246
  },
258
247
  ],
259
248
  usage: usage
@@ -288,7 +277,9 @@ async function handleStreamingTextCompletion(
288
277
 
289
278
  const requestAbort = createRequestAbortController(ctx);
290
279
  let usage: Usage | undefined;
280
+ let usageResponseMetadata: unknown;
291
281
  let providerRequestId: string | undefined;
282
+ let providerFinishReason: string | undefined;
292
283
  try {
293
284
  const stream = await chatModel.stream(messages, {
294
285
  ...providerRequestParameters,
@@ -329,8 +320,14 @@ async function handleStreamingTextCompletion(
329
320
  );
330
321
  }
331
322
  if (chunk.usage_metadata) {
332
- usage = normalizeUsage(chunk.usage_metadata) ?? usage;
323
+ const normalized = normalizeUsage(chunk.usage_metadata);
324
+ if (normalized) {
325
+ usage = normalized;
326
+ usageResponseMetadata = chunk.response_metadata;
327
+ }
333
328
  }
329
+ const chunkFinishReason = extractFinishReason(chunk);
330
+ if (chunkFinishReason) providerFinishReason = chunkFinishReason;
334
331
  providerRequestId = providerRequestId ?? extractProviderRequestId(chunk);
335
332
  }
336
333
 
@@ -348,7 +345,7 @@ async function handleStreamingTextCompletion(
348
345
  text: '',
349
346
  index: 0,
350
347
  logprobs: null,
351
- finish_reason: 'stop',
348
+ finish_reason: providerFinishReason ?? 'stop',
352
349
  },
353
350
  ],
354
351
  usage: null,
@@ -370,7 +367,7 @@ async function handleStreamingTextCompletion(
370
367
  }
371
368
 
372
369
  await writeResponse(ctx, formatSSEDone());
373
- setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
370
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
374
371
  ctx.state.aiApiStreamResult = { succeeded: true, id: completionId };
375
372
  } catch (err) {
376
373
  const cancelled = isClientDisconnected(ctx, err);
@@ -386,7 +383,7 @@ async function handleStreamingTextCompletion(
386
383
  }),
387
384
  );
388
385
  }
389
- setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId });
386
+ setAiApiUsageResult(ctx, usage, { gatewayResponseId: completionId, providerRequestId }, usageResponseMetadata);
390
387
  ctx.state.aiApiStreamResult = {
391
388
  succeeded: false,
392
389
  id: completionId,
@@ -11,6 +11,7 @@ import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError, toOpenAIEmbeddingsResponse } from '../utils/openai-format';
12
12
  import { resolveModelString } from '../utils/resolve-service';
13
13
  import { enforceModelAccess } from '../utils/user-permissions';
14
+ import { getAiApiConfig } from '../utils/request-cache';
14
15
  import { setAiApiUsageUnavailable } from '../usage';
15
16
  import type PluginAiApiServer from '../plugin';
16
17
 
@@ -118,7 +119,7 @@ export async function handleEmbeddings(ctx: Context, plugin: PluginAiApiServer)
118
119
  // still enforced: an explicit deny must never be bypassed by an unreadable config.
119
120
  let globalEnabledServices: unknown = [];
120
121
  try {
121
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
122
+ const config = await getAiApiConfig(ctx);
122
123
  globalEnabledServices = config?.enabledLlmServices ?? [];
123
124
  } catch {
124
125
  // Config read failure: fail open on the global whitelist only.
@@ -10,6 +10,7 @@
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { toOpenAIError } from '../utils/openai-format';
12
12
  import { isModelAllowed, isServiceAllowed, resolveUserAccessScope } from '../utils/user-permissions';
13
+ import { getAiApiConfig } from '../utils/request-cache';
13
14
  import type PluginAiApiServer from '../plugin';
14
15
 
15
16
  /**
@@ -20,8 +21,8 @@ import type PluginAiApiServer from '../plugin';
20
21
  * so clients can copy-paste the ID directly into POST /v1/chat/completions
21
22
  * without needing to configure a defaultLlmService.
22
23
  *
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.
24
+ * The catalog is scoped to the caller: it starts from the global whitelist and is
25
+ * narrowed by the caller's usage group settings (allowedLlmServices / allowedModels).
25
26
  *
26
27
  * Backward compatibility: resolveModelString() in resolve-service.ts still
27
28
  * accepts bare model IDs via its 3-tier fallback (defaultLlmService / single service).
@@ -251,7 +252,7 @@ function toPositiveInt(value: unknown): number | null {
251
252
  }
252
253
 
253
254
  async function getPluginConfig(ctx: Context) {
254
- return ctx.db.getRepository('aiApiConfig').findOne();
255
+ return getAiApiConfig(ctx);
255
256
  }
256
257
 
257
258
  /**
@@ -20,6 +20,7 @@ import { createRateLimitMiddleware } from '../middleware/rate-limit';
20
20
  import { checkRolePermission } from '../middleware/role-permission';
21
21
  import { startUsageRecord, finishUsageRecord } from '../usage';
22
22
  import { isStreamingRequested } from '../utils/streaming';
23
+ import { getAiApiConfig } from '../utils/request-cache';
23
24
  import type PluginAiApiServer from '../plugin';
24
25
  import { finalizeLlmBilling } from '../billing';
25
26
  import { finishAiApiObservation, startAiApiObservation } from '../utils/app-observability';
@@ -43,7 +44,7 @@ type DataWrappingContext = Context & { withoutDataWrapping?: boolean };
43
44
  * - OPTIONS preflight handling (204)
44
45
  * - X-Request-Id on every response
45
46
  * - Bearer token authentication
46
- * - Sliding window rate limiting (enforces rateLimitPerMinute from config)
47
+ * - Sliding window rate limiting (enforces rateLimitPerMinute from the user's usage group)
47
48
  * - Structured request logging via app.logger
48
49
  *
49
50
  * Supported endpoints:
@@ -353,7 +354,7 @@ export function normalizeMaxRequestBodyMb(value: unknown): number {
353
354
  async function resolveMaxBodyBytes(ctx: Context): Promise<number> {
354
355
  let configuredMb: unknown;
355
356
  try {
356
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
357
+ const config = await getAiApiConfig(ctx);
357
358
  configuredMb = config?.get('maxRequestBodyMb');
358
359
  } catch (err) {
359
360
  ctx.log?.warn?.('AI API: could not read maxRequestBodyMb, using default:', err);
@@ -431,7 +432,7 @@ async function resolveMode(ctx: Context): Promise<'llm' | 'agent'> {
431
432
  }
432
433
 
433
434
  try {
434
- const config = await ctx.db.getRepository('aiApiConfig').findOne();
435
+ const config = await getAiApiConfig(ctx);
435
436
  if (config) {
436
437
  const dbMode = config.get('mode') || config.mode;
437
438
  if (dbMode === 'agent' || dbMode === 'llm') {