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.
- package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
- package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
- package/dist/client/562.44b16aad4718b4c7.js +10 -0
- package/dist/client/685.ae483e17b6b49c98.js +10 -0
- package/dist/client/{757.56952e321dc399b7.js → 757.6568d3504ad29352.js} +1 -1
- package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client-v2/302.3971233415999b2c.js +10 -0
- package/dist/client-v2/562.45d5c504433be38b.js +10 -0
- package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
- package/dist/client-v2/{757.db678ca1aa6c422c.js → 757.f2bc9cfba07004b0.js} +1 -1
- package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
- package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
- package/dist/client-v2/index.js +1 -1
- package/dist/constants.js +2 -5
- package/dist/locale/en-US.json +26 -8
- package/dist/locale/vi-VN.json +26 -8
- package/dist/locale/zh-CN.json +26 -8
- package/dist/server/billing.js +25 -32
- package/dist/server/collections/ai-api-config.js +1 -7
- package/dist/server/collections/ai-api-group-members.js +62 -0
- package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
- package/dist/server/collections/ai-api-model-metadata.js +6 -0
- package/dist/server/collections/ai-api-usage-groups.js +74 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/middleware/rate-limit.js +7 -6
- package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
- package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
- package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
- package/dist/server/plugin.js +90 -22
- package/dist/server/quota-groups.js +108 -0
- package/dist/server/resource/ai-api-config.js +0 -3
- package/dist/server/resource/ai-api-usage-groups.js +168 -0
- package/dist/server/routes/agent-completions.js +2 -1
- package/dist/server/routes/chat-completions.js +32 -32
- package/dist/server/routes/completions.js +16 -19
- package/dist/server/routes/embeddings.js +2 -1
- package/dist/server/routes/models.js +2 -1
- package/dist/server/routes/router.js +3 -2
- package/dist/server/services/file-processor.js +186 -22
- package/dist/server/usage.js +5 -1
- package/dist/server/utils/direct-llm-context.js +13 -11
- package/dist/server/utils/rate-limiter.js +1 -1
- package/dist/server/utils/request-cache.js +61 -0
- package/dist/server/utils/resolve-service.js +2 -1
- package/dist/server/utils/user-permissions.js +25 -39
- package/dist/server/validation.js +7 -0
- package/dist/swagger.js +6 -7
- package/package.json +1 -1
- package/src/client/__tests__/settings-registration.test.tsx +6 -29
- package/src/client/plugin.tsx +5 -16
- package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
- package/src/client-v2/locale.ts +3 -1
- package/src/client-v2/pages/GeneralPage.tsx +0 -5
- package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
- package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
- package/src/client-v2/plugin.tsx +4 -13
- package/src/constants.ts +0 -7
- package/src/locale/en-US.json +26 -8
- package/src/locale/vi-VN.json +26 -8
- package/src/locale/zh-CN.json +26 -8
- package/src/server/__tests__/billing-quota.test.ts +28 -9
- package/src/server/__tests__/direct-llm-context.test.ts +122 -4
- package/src/server/__tests__/file-processor.test.ts +225 -0
- package/src/server/__tests__/models.test.ts +1 -1
- package/src/server/__tests__/permission-sync.test.ts +34 -35
- package/src/server/__tests__/usage-groups.test.ts +160 -0
- package/src/server/__tests__/usage-monitor.test.ts +2 -0
- package/src/server/__tests__/usage-route.test.ts +262 -2
- package/src/server/__tests__/usage.test.ts +38 -0
- package/src/server/__tests__/user-permissions.test.ts +214 -133
- package/src/server/__tests__/validation.test.ts +11 -0
- package/src/server/billing.ts +30 -38
- package/src/server/collections/ai-api-config.ts +1 -7
- package/src/server/collections/ai-api-group-members.ts +41 -0
- package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
- package/src/server/collections/ai-api-model-metadata.ts +7 -0
- package/src/server/collections/ai-api-usage-groups.ts +53 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/middleware/rate-limit.ts +10 -12
- package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
- package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
- package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
- package/src/server/plugin.ts +101 -30
- package/src/server/quota-groups.ts +117 -0
- package/src/server/resource/ai-api-config.ts +0 -3
- package/src/server/resource/ai-api-usage-groups.ts +171 -0
- package/src/server/routes/agent-completions.ts +2 -1
- package/src/server/routes/chat-completions.ts +39 -36
- package/src/server/routes/completions.ts +18 -21
- package/src/server/routes/embeddings.ts +2 -1
- package/src/server/routes/models.ts +4 -3
- package/src/server/routes/router.ts +4 -3
- package/src/server/services/file-processor.ts +214 -24
- package/src/server/usage.ts +5 -1
- package/src/server/utils/direct-llm-context.ts +20 -11
- package/src/server/utils/rate-limiter.ts +1 -1
- package/src/server/utils/request-cache.ts +59 -0
- package/src/server/utils/resolve-service.ts +2 -1
- package/src/server/utils/user-permissions.ts +49 -69
- package/src/server/validation.ts +7 -0
- package/src/swagger.ts +7 -8
- package/dist/client/123.e6fe04c856ce6417.js +0 -10
- package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
- package/dist/client/562.17a0a299d2e5152c.js +0 -10
- package/dist/client/902.e74518750f1e4201.js +0 -10
- package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
- package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
- package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
- package/dist/client-v2/902.c7c00a565085438a.js +0 -10
- package/dist/server/resource/ai-api-user-permissions.js +0 -75
- package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
- package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
- package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
- 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
|
-
|
|
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
|
|
41
|
-
|
|
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 = { ...
|
|
49
|
-
function
|
|
50
|
-
if (
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
scope = buildAccessScope(row);
|
|
76
|
+
group = await (0, import_request_cache.resolveRequestUserGroup)(ctx, userId);
|
|
94
77
|
} catch (err) {
|
|
95
|
-
(
|
|
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.
|
|
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.
|
|
109
|
-
return matchesService(scope.allowedServices
|
|
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.
|
|
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
|
-
|
|
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 `
|
|
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: {
|
|
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;
|
|
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
|
@@ -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
|
|
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.
|
|
33
|
-
'ai-api.user-quotas',
|
|
32
|
+
'ai-api.usage-groups',
|
|
34
33
|
'ai-api.usage',
|
|
35
34
|
]);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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('
|
|
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
|
});
|
package/src/client/plugin.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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('
|
|
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.
|
|
83
|
-
'ai-api.user-quotas',
|
|
57
|
+
'ai-api.usage-groups',
|
|
84
58
|
'ai-api.usage',
|
|
85
59
|
]);
|
|
86
60
|
});
|
package/src/client-v2/locale.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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>
|