plugin-ai-api 1.0.25 → 1.1.0

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
@@ -28,73 +28,60 @@ var user_permissions_exports = {};
28
28
  __export(user_permissions_exports, {
29
29
  buildAccessScope: () => buildAccessScope,
30
30
  enforceModelAccess: () => enforceModelAccess,
31
- invalidateUserPermissionCache: () => invalidateUserPermissionCache,
31
+ invalidateGroupAccessCache: () => invalidateGroupAccessCache,
32
32
  isModelAllowed: () => isModelAllowed,
33
33
  isServiceAllowed: () => isServiceAllowed,
34
34
  resolveUserAccessScope: () => resolveUserAccessScope
35
35
  });
36
36
  module.exports = __toCommonJS(user_permissions_exports);
37
37
  var import_openai_format = require("./openai-format");
38
+ var import_request_cache = require("./request-cache");
38
39
  const SCOPE_TTL_MS = 15e3;
39
40
  const scopeCache = /* @__PURE__ */ new Map();
40
- const NO_RECORD_SCOPE = {
41
- hasUserRecord: false,
42
- denyAll: false,
43
- allowedServices: null,
41
+ const OPEN_SCOPE = {
42
+ allowedServices: [],
44
43
  allowAllModels: true,
45
44
  allowedModels: /* @__PURE__ */ new Set(),
46
45
  lookupFailed: false
47
46
  };
48
- const LOOKUP_FAILED_SCOPE = { ...NO_RECORD_SCOPE, denyAll: true, lookupFailed: true };
49
- function invalidateUserPermissionCache(userId) {
50
- if (userId === void 0 || userId === null) {
47
+ const LOOKUP_FAILED_SCOPE = { ...OPEN_SCOPE, lookupFailed: true };
48
+ function invalidateGroupAccessCache(groupId) {
49
+ if (groupId === void 0 || groupId === null) {
51
50
  scopeCache.clear();
52
51
  return;
53
52
  }
54
- const suffix = `:${userId}`;
53
+ const suffix = `:group:${groupId}`;
55
54
  for (const key of scopeCache.keys()) {
56
55
  if (key.endsWith(suffix)) scopeCache.delete(key);
57
56
  }
58
57
  }
59
- function valueOf(row, name) {
60
- if (!row) return void 0;
61
- const candidate = row;
62
- if (typeof candidate.get === "function") return candidate.get(name);
63
- return row[name];
64
- }
65
58
  function toStringArray(value) {
66
59
  if (!Array.isArray(value)) return [];
67
60
  return value.filter((item) => typeof item === "string" && item.length > 0);
68
61
  }
69
- function buildAccessScope(row) {
70
- if (!row) return NO_RECORD_SCOPE;
71
- if (valueOf(row, "enabled") === false) {
72
- return { ...NO_RECORD_SCOPE, hasUserRecord: true, denyAll: true, allowedServices: [] };
73
- }
62
+ function buildAccessScope(group) {
74
63
  return {
75
- hasUserRecord: true,
76
- denyAll: false,
77
- allowedServices: toStringArray(valueOf(row, "allowedLlmServices")),
78
- allowAllModels: valueOf(row, "allowAllModels") !== false,
79
- allowedModels: new Set(toStringArray(valueOf(row, "allowedModels"))),
64
+ groupId: group.id,
65
+ allowedServices: toStringArray(group.allowedLlmServices),
66
+ allowAllModels: group.allowAllModels !== false,
67
+ allowedModels: new Set(toStringArray(group.allowedModels)),
80
68
  lookupFailed: false
81
69
  };
82
70
  }
83
71
  async function resolveUserAccessScope(ctx) {
84
72
  var _a, _b, _c, _d;
85
73
  const userId = (_a = ctx.state.currentUser) == null ? void 0 : _a.id;
86
- if (userId === void 0 || userId === null) return NO_RECORD_SCOPE;
87
- const key = `${((_b = ctx.app) == null ? void 0 : _b.name) ?? "main"}:${userId}`;
88
- const cached = scopeCache.get(key);
89
- if (cached && cached.expiresAt > Date.now()) return cached.scope;
90
- let scope;
74
+ let group;
91
75
  try {
92
- const row = await ctx.db.getRepository("aiApiUserPermissions").findOne({ filter: { userId } });
93
- scope = buildAccessScope(row);
76
+ group = await (0, import_request_cache.resolveRequestUserGroup)(ctx, userId);
94
77
  } catch (err) {
95
- (_d = (_c = ctx.log) == null ? void 0 : _c.error) == null ? void 0 : _d.call(_c, "AI API user permissions lookup failed, denying access:", err);
78
+ (_c = (_b = ctx.log) == null ? void 0 : _b.error) == null ? void 0 : _c.call(_b, "AI API group access lookup failed, denying access:", err);
96
79
  return LOOKUP_FAILED_SCOPE;
97
80
  }
81
+ const key = `${((_d = ctx.app) == null ? void 0 : _d.name) ?? "main"}:group:${group.id}`;
82
+ const cached = scopeCache.get(key);
83
+ if (cached && cached.expiresAt > Date.now()) return cached.scope;
84
+ const scope = buildAccessScope(group);
98
85
  scopeCache.set(key, { scope, expiresAt: Date.now() + SCOPE_TTL_MS });
99
86
  return scope;
100
87
  }
@@ -102,15 +89,14 @@ function matchesService(list, serviceName, serviceTitle) {
102
89
  return list.some((entry) => entry === serviceName || entry === serviceTitle);
103
90
  }
104
91
  function isServiceAllowed(scope, globalEnabledServices, service) {
105
- if (scope.denyAll) return false;
92
+ if (scope.lookupFailed) return false;
106
93
  const globalList = toStringArray(globalEnabledServices);
107
94
  if (globalList.length && !matchesService(globalList, service.name, service.title)) return false;
108
- if (!scope.hasUserRecord) return true;
109
- return matchesService(scope.allowedServices ?? [], service.name, service.title);
95
+ if (!scope.allowedServices.length) return true;
96
+ return matchesService(scope.allowedServices, service.name, service.title);
110
97
  }
111
98
  function isModelAllowed(scope, fullModelId) {
112
- if (scope.denyAll) return false;
113
- if (!scope.hasUserRecord) return true;
99
+ if (scope.lookupFailed) return false;
114
100
  if (scope.allowAllModels) return true;
115
101
  return scope.allowedModels.has(fullModelId);
116
102
  }
@@ -153,7 +139,7 @@ async function enforceModelAccess(ctx, globalEnabledServices, service, modelId)
153
139
  0 && (module.exports = {
154
140
  buildAccessScope,
155
141
  enforceModelAccess,
156
- invalidateUserPermissionCache,
142
+ invalidateGroupAccessCache,
157
143
  isModelAllowed,
158
144
  isServiceAllowed,
159
145
  resolveUserAccessScope
@@ -88,6 +88,10 @@ function validateModelMetadata(model) {
88
88
  if (!String(model.get("model") ?? "").trim()) throw new Error("model is required.");
89
89
  requirePositiveIntegerOrNull(model.get("contextWindow"), "contextWindow");
90
90
  requirePositiveIntegerOrNull(model.get("maxCompletionTokens"), "maxCompletionTokens");
91
+ const systemPrompt = model.get("systemPrompt");
92
+ if (systemPrompt !== null && systemPrompt !== void 0 && typeof systemPrompt !== "string") {
93
+ throw new Error("systemPrompt must be a string.");
94
+ }
91
95
  const contextWindow = model.get("contextWindow");
92
96
  const maxCompletionTokens = model.get("maxCompletionTokens");
93
97
  if (contextWindow !== null && contextWindow !== void 0 && contextWindow !== "" && maxCompletionTokens !== null && maxCompletionTokens !== void 0 && maxCompletionTokens !== "" && Number(maxCompletionTokens) > Number(contextWindow)) {
@@ -98,6 +102,9 @@ function validateQuotaPolicy(model) {
98
102
  if (!["daily", "monthly"].includes(String(model.get("periodType")))) {
99
103
  throw new Error("periodType must be daily or monthly.");
100
104
  }
105
+ if (!["share", "per_user"].includes(String(model.get("quotaMode")))) {
106
+ throw new Error("quotaMode must be share or per_user.");
107
+ }
101
108
  if (!["allow", "use_reserved"].includes(String(model.get("missingUsageBehavior")))) {
102
109
  throw new Error("missingUsageBehavior must be allow or use_reserved.");
103
110
  }
package/dist/swagger.js CHANGED
@@ -78,7 +78,7 @@ var swagger_default = {
78
78
  get: {
79
79
  tags: ["ai-llm"],
80
80
  summary: "List available models",
81
- description: "Returns the LLM models available to the authenticated caller across registered services. Model IDs are formatted as `serviceName/modelId`.\n\nThe catalog is user-scoped: it starts from `enabledLlmServices` in the AI API configuration, then narrows to the caller's `aiApiUserPermissions` record when one exists. A user grant can only narrow the global whitelist, never widen it, so two users may receive different lists from the same request.",
81
+ description: "Returns the LLM models available to the authenticated caller across registered services. Model IDs are formatted as `serviceName/modelId`.\n\nThe catalog is user-scoped: it starts from `enabledLlmServices` in the AI API configuration, then narrows to the caller's usage group settings (`allowedLlmServices` / `allowedModels`). Group settings can only narrow the global whitelist, never widen it, so two users may receive different lists from the same request.",
82
82
  security: [{ BearerAuth: [] }],
83
83
  responses: {
84
84
  200: {
@@ -268,16 +268,15 @@ var swagger_default = {
268
268
  enum: ["llm", "agent"],
269
269
  description: "Default AI mode"
270
270
  },
271
- defaultAiEmployee: { type: "string", description: "Default AI employee name" },
271
+ defaultAiEmployee: {
272
+ type: "string",
273
+ description: "Default AI employee name (agent mode only; direct LLM mode ignores it)"
274
+ },
272
275
  defaultLlmService: { type: "string", description: "Default LLM service name" },
273
276
  enabledLlmServices: {
274
277
  type: "array",
275
278
  items: { type: "string" },
276
- description: "List of enabled LLM service names. This is the outer bound for every caller; per-user `aiApiUserPermissions` records can only narrow it further."
277
- },
278
- rateLimitPerMinute: {
279
- type: "integer",
280
- description: "Max requests per minute per user (0 = unlimited)"
279
+ description: "List of enabled LLM service names. This is the outer bound for every caller; usage group settings can only narrow it further."
281
280
  },
282
281
  maxRequestBodyMb: {
283
282
  type: "integer",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-ai-api",
3
- "version": "1.0.25",
3
+ "version": "1.1.0",
4
4
  "main": "dist/server/index.js",
5
5
  "dependencies": {},
6
6
  "peerDependencies": {
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { Application } from '@nocobase/client';
11
11
  import { describe, expect, it } from 'vitest';
12
- import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../../constants';
12
+ import { AI_API_ACL_SNIPPET } from '../../constants';
13
13
  import { PluginAiApiClient } from '../plugin';
14
14
 
15
15
  describe('AI API v1 settings registration', () => {
@@ -29,41 +29,18 @@ describe('AI API v1 settings registration', () => {
29
29
  'ai-api.config',
30
30
  'ai-api.model-pricing',
31
31
  'ai-api.model-metadata',
32
- 'ai-api.user-permissions',
33
- 'ai-api.user-quotas',
32
+ 'ai-api.usage-groups',
34
33
  'ai-api.usage',
35
34
  ]);
36
- expect(app.pluginSettingsManager.getAclSnippet('ai-api.user-permissions')).toBe(AI_API_USER_PERMISSIONS_SNIPPET);
37
- expect(app.pluginSettingsManager.getRoutePath('ai-api.user-permissions')).toBe(
38
- '/admin/settings/ai-api/user-permissions',
39
- );
40
- expect(app.pluginSettingsManager.getList().map((item) => item.name)).not.toContain('ai-api-user-permissions');
35
+ for (const child of menu?.children ?? []) {
36
+ expect(app.pluginSettingsManager.getAclSnippet(child.name), child.name).toBe(AI_API_ACL_SNIPPET);
37
+ }
41
38
  });
42
39
 
43
- it('requires access to the legacy parent before exposing the user permissions tab', async () => {
40
+ it('hides the whole settings surface when the snippet is denied', async () => {
44
41
  const app = await loadPlugin();
45
42
  app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`]);
46
43
 
47
44
  expect(app.pluginSettingsManager.getList()).toEqual([]);
48
45
  });
49
-
50
- it('hides only the user permissions tab when its own snippet is denied', async () => {
51
- const app = await loadPlugin();
52
- app.pluginSettingsManager.setAclSnippets([`!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
53
-
54
- expect(app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name)).toEqual([
55
- 'ai-api.config',
56
- 'ai-api.model-pricing',
57
- 'ai-api.model-metadata',
58
- 'ai-api.user-quotas',
59
- 'ai-api.usage',
60
- ]);
61
- });
62
-
63
- it('hides both settings surfaces when both snippets are denied', async () => {
64
- const app = await loadPlugin();
65
- app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`, `!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
66
-
67
- expect(app.pluginSettingsManager.getList()).toEqual([]);
68
- });
69
46
  });
@@ -9,14 +9,13 @@
9
9
 
10
10
  import { Plugin, lazy } from '@nocobase/client';
11
11
  import PluginACLClient from '@nocobase/plugin-acl/client';
12
- import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
12
+ import { AI_API_ACL_SNIPPET } from '../constants';
13
13
  import React from 'react';
14
14
 
15
15
  const AiApiConfigPage = React.lazy(() => import('../client-v2/pages/GeneralPage'));
16
16
  const AiApiModelPricingPage = React.lazy(() => import('../client-v2/pages/ModelPricingPage'));
17
17
  const AiApiModelMetadataPage = React.lazy(() => import('../client-v2/pages/ModelMetadataPage'));
18
- const AiApiUserPermissionsPage = React.lazy(() => import('../client-v2/pages/UserPermissionsPage'));
19
- const AiApiUserQuotasPage = React.lazy(() => import('../client-v2/pages/UserQuotasPage'));
18
+ const AiApiUsageGroupsPage = React.lazy(() => import('../client-v2/pages/UsageGroupsPage'));
20
19
  const AiApiUsagePage = React.lazy(() => import('../client-v2/pages/UsagePage'));
21
20
  const { AiApiRolePermissions } = lazy(() => import('./components/AiApiRolePermissions'), 'AiApiRolePermissions');
22
21
 
@@ -49,19 +48,9 @@ export class PluginAiApiClient extends Plugin {
49
48
  sort: 3,
50
49
  });
51
50
 
52
- // Keep the legacy settings layout aligned with client-v2. The v1 settings
53
- // manager evaluates the parent ACL first, so this tab additionally requires
54
- // access to the AI API settings parent even though it keeps its own snippet.
55
- this.app.pluginSettingsManager.add('ai-api.user-permissions', {
56
- title: this.t('User LLM permissions'),
57
- Component: AiApiUserPermissionsPage,
58
- aclSnippet: AI_API_USER_PERMISSIONS_SNIPPET,
59
- sort: 4,
60
- });
61
-
62
- this.app.pluginSettingsManager.add('ai-api.user-quotas', {
63
- title: this.t('User quotas'),
64
- Component: AiApiUserQuotasPage,
51
+ this.app.pluginSettingsManager.add('ai-api.usage-groups', {
52
+ title: this.t('Usage groups'),
53
+ Component: AiApiUsageGroupsPage,
65
54
  aclSnippet: AI_API_ACL_SNIPPET,
66
55
  sort: 5,
67
56
  });
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { createMockClient } from '@nocobase/client-v2';
11
- import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../../constants';
11
+ import { AI_API_ACL_SNIPPET } from '../../constants';
12
12
  import { PluginAiApiClient } from '../plugin';
13
13
 
14
14
  /**
@@ -18,10 +18,8 @@ import { PluginAiApiClient } from '../plugin';
18
18
  * omitting `aclSnippet` makes addPageTabItem default to `pm.ai-api.<key>`, a snippet the
19
19
  * server never registers, and omitting `sort` silently reorders the tabs alphabetically.
20
20
  *
21
- * The tabs deliberately span two snippets: everything sits under AI_API_ACL_SNIPPET except
22
- * user permissions, which is gated separately so granting it does not also grant gateway
23
- * configuration. Each snippet is evaluated independently, so denying one leaves the other's
24
- * tabs — and therefore the menu — reachable.
21
+ * Every tab sits under AI_API_ACL_SNIPPET model access is managed on the usage groups
22
+ * page, so there is no longer a separately-gated permissions tab.
25
23
  */
26
24
  describe('AI API v2 settings registration', () => {
27
25
  async function loadPlugin() {
@@ -36,41 +34,18 @@ describe('AI API v2 settings registration', () => {
36
34
 
37
35
  expect(menu?.aclSnippet).toBe(AI_API_ACL_SNIPPET);
38
36
  for (const child of menu?.children ?? []) {
39
- expect(app.pluginSettingsManager.getAclSnippet(child.name), child.name).toBe(
40
- child.name === 'ai-api.user-permissions' ? AI_API_USER_PERMISSIONS_SNIPPET : AI_API_ACL_SNIPPET,
41
- );
37
+ expect(app.pluginSettingsManager.getAclSnippet(child.name), child.name).toBe(AI_API_ACL_SNIPPET);
42
38
  }
43
39
  });
44
40
 
45
- it('leaves only the separately-gated tab when the main snippet is denied', async () => {
41
+ it('hides the menu and every tab when the snippet is denied', async () => {
46
42
  const app = await loadPlugin();
47
43
  app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`]);
48
44
 
49
- // User permissions carries its own snippet, so it survives on its own merit and keeps the
50
- // menu reachable. That separation is the point: a role can be allowed to hand out model
51
- // access without also being allowed to reconfigure the gateway.
52
- expect(app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name)).toEqual([
53
- 'ai-api.user-permissions',
54
- ]);
55
- });
56
-
57
- it('hides the menu and every tab when both snippets are denied', async () => {
58
- const app = await loadPlugin();
59
- app.pluginSettingsManager.setAclSnippets([`!${AI_API_ACL_SNIPPET}`, `!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
60
-
61
45
  expect(app.pluginSettingsManager.get('ai-api')).toBeNull();
62
46
  expect(app.pluginSettingsManager.getList().map((item) => item.name)).not.toContain('ai-api');
63
47
  });
64
48
 
65
- it('hides only the user permissions tab when its own snippet is denied', async () => {
66
- const app = await loadPlugin();
67
- app.pluginSettingsManager.setAclSnippets([`!${AI_API_USER_PERMISSIONS_SNIPPET}`]);
68
-
69
- const children = app.pluginSettingsManager.get('ai-api')?.children?.map((item) => item.name) ?? [];
70
- expect(children).not.toContain('ai-api.user-permissions');
71
- expect(children).toContain('ai-api.user-quotas');
72
- });
73
-
74
49
  it('keeps registration order instead of sorting tabs by name', async () => {
75
50
  const app = await loadPlugin();
76
51
  app.pluginSettingsManager.setAclSnippets([]);
@@ -79,8 +54,7 @@ describe('AI API v2 settings registration', () => {
79
54
  'ai-api.index',
80
55
  'ai-api.model-pricing',
81
56
  'ai-api.model-metadata',
82
- 'ai-api.user-permissions',
83
- 'ai-api.user-quotas',
57
+ 'ai-api.usage-groups',
84
58
  'ai-api.usage',
85
59
  ]);
86
60
  });
@@ -7,13 +7,15 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
+ import { useCallback } from 'react';
10
11
  import { tExpr as _tExpr, useFlowEngine } from '@nocobase/flow-engine';
11
12
  // @ts-ignore
12
13
  import pkg from './../../package.json';
13
14
 
14
15
  export function useT() {
15
16
  const engine = useFlowEngine();
16
- return (str: string) => engine.context.t(str, { ns: [pkg.name, 'client'] });
17
+ // Stable identity so callers can safely list `t` in hook dependency arrays.
18
+ return useCallback((str: string) => engine.context.t(str, { ns: [pkg.name, 'client'] }), [engine]);
17
19
  }
18
20
 
19
21
  export function tExpr(key: string) {
@@ -9,7 +9,6 @@ interface GeneralSettings {
9
9
  defaultAiEmployee?: string;
10
10
  defaultLlmService?: string;
11
11
  enabledLlmServices: string[];
12
- rateLimitPerMinute: number;
13
12
  maxRequestBodyMb: number;
14
13
  quotaEnabled: boolean;
15
14
  defaultReservationOutputTokens: number;
@@ -28,7 +27,6 @@ interface AiEmployee {
28
27
  const defaults: GeneralSettings = {
29
28
  mode: 'llm',
30
29
  enabledLlmServices: [],
31
- rateLimitPerMinute: 60,
32
30
  maxRequestBodyMb: 10,
33
31
  quotaEnabled: false,
34
32
  defaultReservationOutputTokens: 4096,
@@ -123,9 +121,6 @@ export default function GeneralPage() {
123
121
  />
124
122
  </Form.Item>
125
123
  ) : null}
126
- <Form.Item name="rateLimitPerMinute" label={t('Rate Limit')} rules={[{ required: true }]}>
127
- <InputNumber min={1} style={{ width: '100%' }} />
128
- </Form.Item>
129
124
  <Form.Item
130
125
  name="maxRequestBodyMb"
131
126
  label={t('Max request body size (MB)')}
@@ -28,6 +28,7 @@ interface ModelMetadata {
28
28
  ownedByOverride?: string | null;
29
29
  displayName?: string | null;
30
30
  description?: string | null;
31
+ systemPrompt?: string | null;
31
32
  enabled: boolean;
32
33
  }
33
34
 
@@ -136,6 +137,7 @@ export default function ModelMetadataPage() {
136
137
  ownedByOverride: values.ownedByOverride?.trim() || null,
137
138
  displayName: values.displayName?.trim() || null,
138
139
  description: values.description?.trim() || null,
140
+ systemPrompt: values.systemPrompt?.trim() || null,
139
141
  };
140
142
  setSaving(true);
141
143
  try {
@@ -171,6 +173,14 @@ export default function ModelMetadataPage() {
171
173
  { title: t('Max completion tokens'), dataIndex: 'maxCompletionTokens', key: 'maxCompletionTokens', width: 170 },
172
174
  { title: t('Owned by'), dataIndex: 'ownedByOverride', key: 'ownedByOverride', width: 140 },
173
175
  { title: t('Display name'), dataIndex: 'displayName', key: 'displayName', width: 160 },
176
+ {
177
+ title: t('Initial system prompt'),
178
+ dataIndex: 'systemPrompt',
179
+ key: 'systemPrompt',
180
+ width: 220,
181
+ ellipsis: true,
182
+ render: (value?: string | null) => value || '-',
183
+ },
174
184
  {
175
185
  title: t('Status'),
176
186
  dataIndex: 'enabled',
@@ -209,7 +219,7 @@ export default function ModelMetadataPage() {
209
219
  </Button>
210
220
  }
211
221
  >
212
- <Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1200 }} />
222
+ <Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1450 }} />
213
223
  <Modal
214
224
  title={editing ? t('Edit override') : t('Add override')}
215
225
  open={open}
@@ -270,6 +280,15 @@ export default function ModelMetadataPage() {
270
280
  <Form.Item name="description" label={t('Description')}>
271
281
  <Input.TextArea rows={3} />
272
282
  </Form.Item>
283
+ <Form.Item
284
+ name="systemPrompt"
285
+ label={t('Initial system prompt')}
286
+ tooltip={t(
287
+ 'Prepended as the first system message, before any system prompt sent by the client. If the client sends no system prompt, this becomes the system prompt sent to the provider.',
288
+ )}
289
+ >
290
+ <Input.TextArea rows={4} placeholder={t('Leave empty to not override')} />
291
+ </Form.Item>
273
292
  <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
274
293
  <Switch />
275
294
  </Form.Item>