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
@@ -0,0 +1,548 @@
1
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ Alert,
4
+ Button,
5
+ Card,
6
+ Form,
7
+ Input,
8
+ InputNumber,
9
+ Modal,
10
+ Popconfirm,
11
+ Select,
12
+ Space,
13
+ Switch,
14
+ Table,
15
+ Tag,
16
+ message,
17
+ Row,
18
+ Col,
19
+ } from 'antd';
20
+ import type { ColumnsType } from 'antd/es/table';
21
+ import { useFlowContext } from '@nocobase/flow-engine';
22
+ import { useT } from '../locale';
23
+ import { errorMessage, unwrapData } from './api';
24
+
25
+ interface UserSummary {
26
+ id: string | number;
27
+ nickname?: string;
28
+ username?: string;
29
+ email?: string;
30
+ }
31
+
32
+ interface EnabledLlmService {
33
+ llmService: string;
34
+ llmServiceTitle?: string;
35
+ enabledModels?: { label: string; value: string }[];
36
+ }
37
+
38
+ interface UsageGroup {
39
+ id: string | number;
40
+ name: string;
41
+ isDefault: boolean;
42
+ quotaMode: 'share' | 'per_user';
43
+ rateLimitPerMinute: number;
44
+ enabled: boolean;
45
+ periodType: 'daily' | 'monthly';
46
+ timezone: string;
47
+ requestLimit?: string;
48
+ totalTokenLimit?: string;
49
+ costLimit?: string;
50
+ currency: string;
51
+ rejectUnpricedModel: boolean;
52
+ missingUsageBehavior: 'allow' | 'use_reserved';
53
+ contextOverflowBehavior: 'reject' | 'truncate';
54
+ allowedLlmServices: string[];
55
+ allowAllModels: boolean;
56
+ allowedModels: string[];
57
+ }
58
+
59
+ interface GroupMember {
60
+ id: string | number;
61
+ groupId: string | number;
62
+ userId: string | number;
63
+ user?: UserSummary;
64
+ }
65
+
66
+ export default function UsageGroupsPage() {
67
+ const ctx = useFlowContext();
68
+ const t = useT();
69
+ const [form] = Form.useForm<UsageGroup>();
70
+ const [memberForm] = Form.useForm<{ userId: string | number }>();
71
+ const selectedServices = Form.useWatch('allowedLlmServices', form);
72
+ const allowAllModels = Form.useWatch('allowAllModels', form);
73
+ const [rows, setRows] = useState<UsageGroup[]>([]);
74
+ const [services, setServices] = useState<EnabledLlmService[]>([]);
75
+ const [loading, setLoading] = useState(false);
76
+ const [saving, setSaving] = useState(false);
77
+ const [editing, setEditing] = useState<UsageGroup>();
78
+ const [open, setOpen] = useState(false);
79
+ const [members, setMembers] = useState<GroupMember[]>([]);
80
+ const [membersLoading, setMembersLoading] = useState(false);
81
+ const [unassignedUsers, setUnassignedUsers] = useState<UserSummary[]>([]);
82
+ const [searchUserKeyword, setSearchUserKeyword] = useState('');
83
+ const [searchedGroup, setSearchedGroup] = useState<UsageGroup | null>(null);
84
+
85
+ const load = useCallback(async () => {
86
+ setLoading(true);
87
+ try {
88
+ const response = await ctx.api.request({
89
+ url: 'aiApiUsageGroups:list',
90
+ method: 'get',
91
+ params: { pageSize: 200, sort: '-updatedAt' },
92
+ });
93
+ setRows(unwrapData<UsageGroup[]>(response, []));
94
+ } catch (error) {
95
+ message.error(errorMessage(error));
96
+ } finally {
97
+ setLoading(false);
98
+ }
99
+ try {
100
+ const servicesResponse = await ctx.api.request({ url: 'ai:listAllEnabledModels', method: 'get' });
101
+ setServices(unwrapData<EnabledLlmService[]>(servicesResponse, []));
102
+ } catch (error) {
103
+ setServices([]);
104
+ message.error(`${t('Failed to load models')}: ${errorMessage(error)}`);
105
+ }
106
+ }, [ctx.api, t]);
107
+
108
+ const loadMembers = useCallback(
109
+ async (groupId: string | number) => {
110
+ setMembersLoading(true);
111
+ try {
112
+ const response = await ctx.api.request({
113
+ url: 'aiApiGroupMembers:list',
114
+ method: 'get',
115
+ params: { filter: { groupId }, pageSize: 1000, appends: ['user'] },
116
+ });
117
+ setMembers(unwrapData<GroupMember[]>(response, []));
118
+ } catch (error) {
119
+ message.error(errorMessage(error));
120
+ } finally {
121
+ setMembersLoading(false);
122
+ }
123
+ },
124
+ [ctx.api],
125
+ );
126
+
127
+ const loadUnassignedUsers = useCallback(
128
+ async (keyword?: string) => {
129
+ try {
130
+ const response = await ctx.api.request({
131
+ url: 'aiApiUsageGroups:listUnassignedUsers',
132
+ method: 'get',
133
+ params: { keyword, pageSize: 100 },
134
+ });
135
+ const rows = unwrapData<UserSummary[]>(response, []);
136
+ setUnassignedUsers(rows);
137
+ } catch (error) {
138
+ message.error(errorMessage(error));
139
+ }
140
+ },
141
+ [ctx.api],
142
+ );
143
+
144
+ useEffect(() => {
145
+ load();
146
+ }, [load]);
147
+
148
+ const showCreate = () => {
149
+ setEditing(undefined);
150
+ setMembers([]);
151
+ form.setFieldsValue({
152
+ name: '',
153
+ quotaMode: 'per_user',
154
+ rateLimitPerMinute: 60,
155
+ enabled: true,
156
+ periodType: 'monthly',
157
+ timezone: 'UTC',
158
+ currency: 'USD',
159
+ rejectUnpricedModel: true,
160
+ missingUsageBehavior: 'use_reserved',
161
+ contextOverflowBehavior: 'reject',
162
+ allowedLlmServices: [],
163
+ allowAllModels: true,
164
+ allowedModels: [],
165
+ } as UsageGroup);
166
+ setOpen(true);
167
+ };
168
+
169
+ const showEdit = async (record: UsageGroup) => {
170
+ setEditing(record);
171
+ form.setFieldsValue(record);
172
+ setOpen(true);
173
+ await loadMembers(record.id);
174
+ if (!record.isDefault) {
175
+ await loadUnassignedUsers();
176
+ }
177
+ };
178
+
179
+ const save = async () => {
180
+ const values = await form.validateFields();
181
+ setSaving(true);
182
+ try {
183
+ await ctx.api.request({
184
+ url: editing ? `aiApiUsageGroups:update/${editing.id}` : 'aiApiUsageGroups:create',
185
+ method: 'post',
186
+ data: values,
187
+ });
188
+ message.success(t('Saved successfully'));
189
+ setOpen(false);
190
+ await load();
191
+ } catch (error) {
192
+ message.error(errorMessage(error));
193
+ } finally {
194
+ setSaving(false);
195
+ }
196
+ };
197
+
198
+ const remove = async (record: UsageGroup) => {
199
+ try {
200
+ await ctx.api.request({
201
+ url: `aiApiUsageGroups:destroy/${record.id}`,
202
+ method: 'post',
203
+ });
204
+ message.success(t('Deleted successfully'));
205
+ await load();
206
+ } catch (error) {
207
+ message.error(errorMessage(error));
208
+ }
209
+ };
210
+
211
+ const addMember = async () => {
212
+ if (!editing) return;
213
+ const values = await memberForm.validateFields();
214
+ try {
215
+ await ctx.api.request({
216
+ url: 'aiApiUsageGroups:addMember',
217
+ method: 'post',
218
+ data: { groupId: editing.id, userId: values.userId },
219
+ });
220
+ message.success(t('Member added'));
221
+ memberForm.resetFields();
222
+ await loadMembers(editing.id);
223
+ await loadUnassignedUsers();
224
+ } catch (error) {
225
+ message.error(errorMessage(error));
226
+ }
227
+ };
228
+
229
+ const removeMember = async (member: GroupMember) => {
230
+ try {
231
+ await ctx.api.request({
232
+ url: 'aiApiUsageGroups:removeMember',
233
+ method: 'post',
234
+ data: { groupId: member.groupId, userId: member.userId },
235
+ });
236
+ message.success(t('Member removed'));
237
+ if (editing) {
238
+ await loadMembers(editing.id);
239
+ await loadUnassignedUsers();
240
+ }
241
+ } catch (error) {
242
+ message.error(errorMessage(error));
243
+ }
244
+ };
245
+
246
+ const searchGroupByUser = async () => {
247
+ if (!searchUserKeyword.trim()) return;
248
+ try {
249
+ const response = await ctx.api.request({
250
+ url: 'aiApiUsageGroups:searchUsers',
251
+ method: 'get',
252
+ params: { keyword: searchUserKeyword, pageSize: 1 },
253
+ });
254
+ const users = unwrapData<UserSummary[]>(response, []);
255
+ if (users.length === 0) {
256
+ message.warning(t('User not found'));
257
+ setSearchedGroup(null);
258
+ return;
259
+ }
260
+ const groupResponse = await ctx.api.request({
261
+ url: 'aiApiUsageGroups:getByUser',
262
+ method: 'get',
263
+ params: { userId: users[0].id },
264
+ });
265
+ setSearchedGroup(unwrapData<UsageGroup>(groupResponse, null));
266
+ } catch (error) {
267
+ message.error(errorMessage(error));
268
+ }
269
+ };
270
+
271
+ const userLabel = (user?: UserSummary) => user?.nickname || user?.username || user?.email || String(user?.id ?? '');
272
+
273
+ const serviceOptions = useMemo(
274
+ () =>
275
+ services.map((service) => ({ label: service.llmServiceTitle || service.llmService, value: service.llmService })),
276
+ [services],
277
+ );
278
+
279
+ // Model IDs are "serviceName/modelId", matching what the gateway enforces and what
280
+ // GET /v1/models returns, so admins never have to type them by hand.
281
+ const modelOptions = useMemo(() => {
282
+ const picked = new Set(selectedServices || []);
283
+ return services
284
+ .filter((service) => picked.has(service.llmService))
285
+ .flatMap((service) =>
286
+ (service.enabledModels || []).map((model) => ({
287
+ label: `${service.llmServiceTitle || service.llmService} / ${model.label || model.value}`,
288
+ value: `${service.llmService}/${model.value}`,
289
+ })),
290
+ );
291
+ }, [services, selectedServices]);
292
+
293
+ const serviceLabel = (name: string) => serviceOptions.find((option) => option.value === name)?.label || name;
294
+
295
+ const columns: ColumnsType<UsageGroup> = [
296
+ { title: t('Name'), dataIndex: 'name', key: 'name', width: 180 },
297
+ {
298
+ title: t('Default'),
299
+ dataIndex: 'isDefault',
300
+ key: 'isDefault',
301
+ width: 100,
302
+ render: (isDefault: boolean) => (isDefault ? <Tag color="blue">{t('Default')}</Tag> : null),
303
+ },
304
+ { title: t('Mode'), dataIndex: 'quotaMode', key: 'quotaMode', width: 120 },
305
+ { title: t('Rate limit/min'), dataIndex: 'rateLimitPerMinute', key: 'rateLimitPerMinute', width: 140 },
306
+ {
307
+ title: t('Model access'),
308
+ key: 'modelAccess',
309
+ width: 240,
310
+ render: (_, record) => {
311
+ const groupServices = record.allowedLlmServices || [];
312
+ const groupModels = record.allowedModels || [];
313
+ const openServices = groupServices.length === 0;
314
+ const openModels = record.allowAllModels !== false;
315
+ if (openServices && openModels) {
316
+ return <Tag color="blue">{t('All models')}</Tag>;
317
+ }
318
+ return (
319
+ <Space size={[0, 4]} wrap>
320
+ <Tag>{openServices ? t('All services') : groupServices.map((name) => serviceLabel(name)).join(', ')}</Tag>
321
+ <Tag color={openModels ? 'blue' : undefined}>
322
+ {openModels ? t('All models') : groupModels.length ? groupModels.join(', ') : t('No models')}
323
+ </Tag>
324
+ </Space>
325
+ );
326
+ },
327
+ },
328
+ {
329
+ title: t('Status'),
330
+ dataIndex: 'enabled',
331
+ key: 'enabled',
332
+ width: 100,
333
+ render: (enabled: boolean) => (
334
+ <Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
335
+ ),
336
+ },
337
+ {
338
+ title: t('Actions'),
339
+ key: 'actions',
340
+ width: 150,
341
+ fixed: 'right',
342
+ render: (_, record) => (
343
+ <Space size={0}>
344
+ <Button type="link" onClick={() => showEdit(record)}>
345
+ {t('Edit')}
346
+ </Button>
347
+ {!record.isDefault && (
348
+ <Popconfirm title={t('Delete this group?')} onConfirm={() => remove(record)}>
349
+ <Button type="link" danger>
350
+ {t('Delete')}
351
+ </Button>
352
+ </Popconfirm>
353
+ )}
354
+ </Space>
355
+ ),
356
+ },
357
+ ];
358
+
359
+ const memberColumns: ColumnsType<GroupMember> = [
360
+ {
361
+ title: t('User'),
362
+ key: 'user',
363
+ render: (_, record) => userLabel(record.user) || String(record.userId),
364
+ },
365
+ {
366
+ title: t('Actions'),
367
+ key: 'actions',
368
+ width: 120,
369
+ render: (_, record) => (
370
+ <Popconfirm title={t('Remove member?')} onConfirm={() => removeMember(record)}>
371
+ <Button type="link" danger>
372
+ {t('Remove')}
373
+ </Button>
374
+ </Popconfirm>
375
+ ),
376
+ },
377
+ ];
378
+
379
+ return (
380
+ <Card
381
+ title={t('Usage groups')}
382
+ extra={
383
+ <Button type="primary" onClick={showCreate}>
384
+ {t('Add group')}
385
+ </Button>
386
+ }
387
+ >
388
+ <Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
389
+ <Col span={12}>
390
+ <Input.Search
391
+ placeholder={t('Search group by user')}
392
+ value={searchUserKeyword}
393
+ onChange={(e) => setSearchUserKeyword(e.target.value)}
394
+ onSearch={searchGroupByUser}
395
+ enterButton
396
+ />
397
+ </Col>
398
+ <Col span={12}>
399
+ {searchedGroup && (
400
+ <Tag color="blue">
401
+ {t('User belongs to')}: {searchedGroup.name}
402
+ </Tag>
403
+ )}
404
+ </Col>
405
+ </Row>
406
+ <Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 700 }} />
407
+ <Modal
408
+ title={editing ? t('Edit group') : t('Add group')}
409
+ open={open}
410
+ onCancel={() => setOpen(false)}
411
+ onOk={save}
412
+ confirmLoading={saving}
413
+ destroyOnClose
414
+ width={720}
415
+ >
416
+ <Form form={form} layout="vertical" preserve={false}>
417
+ <Form.Item name="name" label={t('Name')} rules={[{ required: true }]}>
418
+ <Input disabled={editing?.isDefault} />
419
+ </Form.Item>
420
+ <Form.Item name="quotaMode" label={t('Mode')} rules={[{ required: true }]}>
421
+ <Select
422
+ disabled={Boolean(editing)}
423
+ options={[
424
+ { label: t('Share'), value: 'share' },
425
+ { label: t('Per user'), value: 'per_user' },
426
+ ]}
427
+ />
428
+ </Form.Item>
429
+ <Form.Item name="rateLimitPerMinute" label={t('Rate limit per minute')} rules={[{ required: true }]}>
430
+ <InputNumber min={1} style={{ width: '100%' }} />
431
+ </Form.Item>
432
+ <Form.Item name="periodType" label={t('Period')} rules={[{ required: true }]}>
433
+ <Select
434
+ options={[
435
+ { label: t('Daily'), value: 'daily' },
436
+ { label: t('Monthly'), value: 'monthly' },
437
+ ]}
438
+ />
439
+ </Form.Item>
440
+ <Form.Item name="timezone" label={t('Timezone')} rules={[{ required: true }]}>
441
+ <Input placeholder="UTC" />
442
+ </Form.Item>
443
+ <Form.Item name="requestLimit" label={t('Request limit')}>
444
+ <InputNumber min={0} stringMode style={{ width: '100%' }} />
445
+ </Form.Item>
446
+ <Form.Item name="totalTokenLimit" label={t('Token limit')}>
447
+ <InputNumber min={0} stringMode style={{ width: '100%' }} />
448
+ </Form.Item>
449
+ <Form.Item name="costLimit" label={t('Cost limit')}>
450
+ <InputNumber min={0} stringMode style={{ width: '100%' }} />
451
+ </Form.Item>
452
+ <Form.Item name="currency" label={t('Currency')} rules={[{ required: true }]}>
453
+ <Input />
454
+ </Form.Item>
455
+ <Form.Item name="rejectUnpricedModel" label={t('Reject unpriced models')} valuePropName="checked">
456
+ <Switch />
457
+ </Form.Item>
458
+ <Form.Item name="missingUsageBehavior" label={t('Missing usage behavior')} rules={[{ required: true }]}>
459
+ <Select
460
+ options={[
461
+ { label: t('Use reserved estimate'), value: 'use_reserved' },
462
+ { label: t('Allow without token charge'), value: 'allow' },
463
+ ]}
464
+ />
465
+ </Form.Item>
466
+ <Form.Item name="contextOverflowBehavior" label={t('Context overflow behavior')} rules={[{ required: true }]}>
467
+ <Select
468
+ options={[
469
+ { label: t('Reject request'), value: 'reject' },
470
+ { label: t('Truncate oldest conversation turns'), value: 'truncate' },
471
+ ]}
472
+ />
473
+ </Form.Item>
474
+ <Form.Item
475
+ name="allowedLlmServices"
476
+ label={t('Allowed LLM services')}
477
+ extra={t('Leave empty to allow every service enabled in the general configuration.')}
478
+ >
479
+ <Select mode="multiple" allowClear showSearch optionFilterProp="label" options={serviceOptions} />
480
+ </Form.Item>
481
+ <Form.Item name="allowAllModels" label={t('Allow all models')} valuePropName="checked">
482
+ <Switch />
483
+ </Form.Item>
484
+ {allowAllModels === false && (
485
+ <Form.Item name="allowedModels" label={t('Allowed models')}>
486
+ <Select mode="multiple" allowClear showSearch optionFilterProp="label" options={modelOptions} />
487
+ </Form.Item>
488
+ )}
489
+ <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
490
+ <Switch />
491
+ </Form.Item>
492
+ </Form>
493
+ {editing && (
494
+ <Card title={t('Members')} size="small" style={{ marginTop: 24 }}>
495
+ {editing.isDefault ? (
496
+ <>
497
+ <Alert
498
+ type="info"
499
+ showIcon
500
+ message={t(
501
+ 'Users who do not belong to any other group automatically use this default group — no need to add members.',
502
+ )}
503
+ style={{ marginBottom: members.length > 0 ? 12 : 0 }}
504
+ />
505
+ {members.length > 0 && (
506
+ <Table
507
+ rowKey="id"
508
+ columns={memberColumns}
509
+ dataSource={members}
510
+ loading={membersLoading}
511
+ pagination={false}
512
+ size="small"
513
+ />
514
+ )}
515
+ </>
516
+ ) : (
517
+ <>
518
+ <Form form={memberForm} layout="inline">
519
+ <Form.Item name="userId" label={t('User')} rules={[{ required: true }]} style={{ minWidth: 240 }}>
520
+ <Select
521
+ showSearch
522
+ optionFilterProp="label"
523
+ options={unassignedUsers.map((user) => ({ label: userLabel(user), value: user.id }))}
524
+ onFocus={() => loadUnassignedUsers()}
525
+ />
526
+ </Form.Item>
527
+ <Form.Item>
528
+ <Button type="primary" onClick={addMember}>
529
+ {t('Add member')}
530
+ </Button>
531
+ </Form.Item>
532
+ </Form>
533
+ <Table
534
+ rowKey="id"
535
+ columns={memberColumns}
536
+ dataSource={members}
537
+ loading={membersLoading}
538
+ pagination={false}
539
+ size="small"
540
+ />
541
+ </>
542
+ )}
543
+ </Card>
544
+ )}
545
+ </Modal>
546
+ </Card>
547
+ );
548
+ }
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { Plugin, Application } from '@nocobase/client-v2';
3
- import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
3
+ import { AI_API_ACL_SNIPPET } from '../constants';
4
4
 
5
5
  interface PermissionTabOptions {
6
6
  key: string;
@@ -53,20 +53,11 @@ export class PluginAiApiClient extends Plugin<Record<string, never>, Application
53
53
 
54
54
  this.pluginSettingsManager.addPageTabItem({
55
55
  menuKey: 'ai-api',
56
- key: 'user-permissions',
57
- title: this.t('User LLM permissions'),
58
- aclSnippet: AI_API_USER_PERMISSIONS_SNIPPET,
59
- sort: 4,
60
- componentLoader: () => import('./pages/UserPermissionsPage'),
61
- });
62
-
63
- this.pluginSettingsManager.addPageTabItem({
64
- menuKey: 'ai-api',
65
- key: 'user-quotas',
66
- title: this.t('User quotas'),
56
+ key: 'usage-groups',
57
+ title: this.t('Usage groups'),
67
58
  aclSnippet: AI_API_ACL_SNIPPET,
68
59
  sort: 5,
69
- componentLoader: () => import('./pages/UserQuotasPage'),
60
+ componentLoader: () => import('./pages/UsageGroupsPage'),
70
61
  });
71
62
 
72
63
  this.pluginSettingsManager.addPageTabItem({
package/src/constants.ts CHANGED
@@ -19,10 +19,3 @@
19
19
  * Shared by both client runtimes so a rename cannot silently desynchronise them.
20
20
  */
21
21
  export const AI_API_ACL_SNIPPET = 'pm.plugin-ai-api.configuration';
22
-
23
- /**
24
- * Child snippet for the per-user LLM permission surface, deliberately separate from
25
- * AI_API_ACL_SNIPPET so granting someone the gateway settings does not also let them
26
- * hand out model access. Same `plugin-ai-api` prefix constraint as above applies.
27
- */
28
- export const AI_API_USER_PERMISSIONS_SNIPPET = 'pm.plugin-ai-api.user-permissions';
@@ -99,6 +99,8 @@
99
99
  "Leave empty to not override": "Leave empty to not override",
100
100
  "Total input + output token capacity reported to clients.": "Total input + output token capacity reported to clients.",
101
101
  "Maximum output tokens reported to clients.": "Maximum output tokens reported to clients.",
102
+ "Initial system prompt": "Initial system prompt",
103
+ "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.": "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.",
102
104
  "AI API": "AI API",
103
105
  "Allow this role to use the AI API": "Allow this role to use the AI API",
104
106
  "Allow all AI Employees": "Allow all AI Employees",
@@ -106,15 +108,31 @@
106
108
  "Select allowed AI Employees": "Select allowed AI Employees",
107
109
  "Max request body size (MB)": "Max request body size (MB)",
108
110
  "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.",
109
- "User LLM permissions": "User LLM permissions",
110
- "Add permission": "Add permission",
111
- "Edit permission": "Edit permission",
112
- "Delete this permission?": "Delete this permission?",
111
+ "Usage groups": "Usage groups",
112
+ "Add group": "Add group",
113
+ "Edit group": "Edit group",
114
+ "Delete this group?": "Delete this group?",
115
+ "Mode": "Mode",
116
+ "Share": "Share",
117
+ "Per user": "Per user",
118
+ "Rate limit per minute": "Rate limit per minute",
119
+ "Members": "Members",
120
+ "Add member": "Add member",
121
+ "Member added": "Member added",
122
+ "Member removed": "Member removed",
123
+ "Remove member?": "Remove member?",
124
+ "Remove": "Remove",
125
+ "Search group by user": "Search group by user",
126
+ "User belongs to": "User belongs to",
127
+ "User not found": "User not found",
128
+ "Default": "Default",
113
129
  "Allowed LLM services": "Allowed LLM services",
114
130
  "Allow all models": "Allow all models",
115
131
  "Allowed models": "Allowed models",
116
- "No service allowed": "No service allowed",
117
- "All models of allowed services": "All models of allowed services",
118
- "Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.": "Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.",
119
- "Only services also enabled in the general configuration take effect.": "Only services also enabled in the general configuration take effect."
132
+ "Model access": "Model access",
133
+ "All models": "All models",
134
+ "All services": "All services",
135
+ "No models": "No models",
136
+ "Leave empty to allow every service enabled in the general configuration.": "Leave empty to allow every service enabled in the general configuration.",
137
+ "Users who do not belong to any other group automatically use this default group — no need to add members.": "Users who do not belong to any other group automatically use this default group — no need to add members."
120
138
  }