plugin-ai-api 1.0.24 → 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 (130) 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.6568d3504ad29352.js +10 -0
  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.f2bc9cfba07004b0.js +10 -0
  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/externalVersion.js +8 -8
  17. package/dist/locale/en-US.json +27 -8
  18. package/dist/locale/vi-VN.json +27 -8
  19. package/dist/locale/zh-CN.json +27 -8
  20. package/dist/server/billing.js +31 -33
  21. package/dist/server/collections/ai-api-config.js +7 -7
  22. package/dist/server/collections/ai-api-group-members.js +62 -0
  23. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  24. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  25. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  26. package/dist/server/collections/ai-api-usage-records.js +2 -0
  27. package/dist/server/middleware/rate-limit.js +7 -6
  28. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  29. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  30. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  31. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  32. package/dist/server/plugin.js +100 -22
  33. package/dist/server/quota-groups.js +108 -0
  34. package/dist/server/resource/ai-api-config.js +5 -3
  35. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  36. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  37. package/dist/server/routes/agent-completions.js +2 -1
  38. package/dist/server/routes/chat-completions.js +121 -42
  39. package/dist/server/routes/completions.js +48 -29
  40. package/dist/server/routes/embeddings.js +2 -1
  41. package/dist/server/routes/models.js +2 -1
  42. package/dist/server/routes/router.js +3 -2
  43. package/dist/server/services/file-processor.js +426 -0
  44. package/dist/server/usage.js +37 -3
  45. package/dist/server/utils/direct-llm-context.js +163 -26
  46. package/dist/server/utils/openai-format.js +21 -2
  47. package/dist/server/utils/rate-limiter.js +1 -1
  48. package/dist/server/utils/request-cache.js +61 -0
  49. package/dist/server/utils/resolve-service.js +2 -1
  50. package/dist/server/utils/user-permissions.js +25 -39
  51. package/dist/server/validation.js +7 -0
  52. package/dist/swagger.js +48 -10
  53. package/package.json +1 -1
  54. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  55. package/src/client/plugin.tsx +5 -16
  56. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  57. package/src/client-v2/locale.ts +3 -1
  58. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  59. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  60. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  61. package/src/client-v2/pages/UsagePage.tsx +9 -0
  62. package/src/client-v2/plugin.tsx +4 -13
  63. package/src/constants.ts +0 -7
  64. package/src/locale/en-US.json +27 -8
  65. package/src/locale/vi-VN.json +27 -8
  66. package/src/locale/zh-CN.json +27 -8
  67. package/src/server/__tests__/billing-quota.test.ts +28 -9
  68. package/src/server/__tests__/direct-llm-context.test.ts +209 -10
  69. package/src/server/__tests__/file-processor.test.ts +225 -0
  70. package/src/server/__tests__/models.test.ts +1 -1
  71. package/src/server/__tests__/openai-format.test.ts +12 -2
  72. package/src/server/__tests__/permission-sync.test.ts +34 -35
  73. package/src/server/__tests__/request-body.test.ts +45 -2
  74. package/src/server/__tests__/usage-groups.test.ts +160 -0
  75. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  76. package/src/server/__tests__/usage-route.test.ts +382 -5
  77. package/src/server/__tests__/usage.test.ts +57 -0
  78. package/src/server/__tests__/user-permissions.test.ts +214 -133
  79. package/src/server/__tests__/validation.test.ts +11 -0
  80. package/src/server/billing.ts +36 -39
  81. package/src/server/collections/ai-api-config.ts +9 -7
  82. package/src/server/collections/ai-api-group-members.ts +41 -0
  83. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  84. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  85. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  86. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  87. package/src/server/collections/ai-api-usage-records.ts +2 -0
  88. package/src/server/index.ts +10 -10
  89. package/src/server/middleware/rate-limit.ts +68 -70
  90. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  91. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  92. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  93. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  94. package/src/server/plugin.ts +121 -30
  95. package/src/server/quota-groups.ts +117 -0
  96. package/src/server/resource/ai-api-config.ts +5 -3
  97. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  98. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  99. package/src/server/routes/agent-completions.ts +2 -1
  100. package/src/server/routes/chat-completions.ts +173 -47
  101. package/src/server/routes/completions.ts +50 -27
  102. package/src/server/routes/embeddings.ts +2 -1
  103. package/src/server/routes/models.ts +4 -3
  104. package/src/server/routes/router.ts +4 -3
  105. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  106. package/src/server/services/file-processor.ts +513 -0
  107. package/src/server/usage.ts +51 -1
  108. package/src/server/utils/direct-llm-context.ts +218 -31
  109. package/src/server/utils/openai-format.ts +25 -2
  110. package/src/server/utils/rate-limiter.ts +83 -83
  111. package/src/server/utils/request-cache.ts +59 -0
  112. package/src/server/utils/resolve-service.ts +83 -82
  113. package/src/server/utils/user-permissions.ts +49 -69
  114. package/src/server/validation.ts +7 -0
  115. package/src/swagger.ts +52 -11
  116. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  117. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  118. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  119. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  120. package/dist/client/902.e74518750f1e4201.js +0 -10
  121. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  122. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  123. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  124. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  125. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  126. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  127. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  128. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  129. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  130. 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
+ }
@@ -32,6 +32,7 @@ interface UsageRecord {
32
32
  inputTokens?: number;
33
33
  outputTokens?: number;
34
34
  totalTokens?: number;
35
+ promptCacheTokens?: number;
35
36
  estimatedCost?: string;
36
37
  currency?: string;
37
38
  costStatus?: string;
@@ -51,6 +52,7 @@ interface UsageSummary {
51
52
  inputTokens: number;
52
53
  outputTokens: number;
53
54
  totalTokens: number;
55
+ promptCacheTokens: number;
54
56
  costsByCurrency: { currency: string; totalCost: string }[];
55
57
  }
56
58
 
@@ -65,6 +67,7 @@ const emptySummary: UsageSummary = {
65
67
  inputTokens: 0,
66
68
  outputTokens: 0,
67
69
  totalTokens: 0,
70
+ promptCacheTokens: 0,
68
71
  costsByCurrency: [],
69
72
  };
70
73
 
@@ -153,6 +156,7 @@ export default function UsagePage() {
153
156
  { title: t('Input tokens'), dataIndex: 'inputTokens', key: 'inputTokens', width: 110 },
154
157
  { title: t('Output tokens'), dataIndex: 'outputTokens', key: 'outputTokens', width: 110 },
155
158
  { title: t('Total tokens'), dataIndex: 'totalTokens', key: 'totalTokens', width: 110 },
159
+ { title: t('Prompt cache tokens'), dataIndex: 'promptCacheTokens', key: 'promptCacheTokens', width: 140 },
156
160
  {
157
161
  title: t('Cost'),
158
162
  key: 'cost',
@@ -234,6 +238,11 @@ export default function UsagePage() {
234
238
  <Statistic title={t('Total tokens')} value={summary.totalTokens} />
235
239
  </Card>
236
240
  </Col>
241
+ <Col xs={24} sm={12} lg={6}>
242
+ <Card size="small">
243
+ <Statistic title={t('Prompt cache tokens')} value={summary.promptCacheTokens} />
244
+ </Card>
245
+ </Col>
237
246
  <Col xs={24}>
238
247
  <Card size="small">
239
248
  <Statistic title={t('Total cost')} value={totalCost} />
@@ -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';
@@ -63,6 +63,7 @@
63
63
  "Input tokens": "Input tokens",
64
64
  "Output tokens": "Output tokens",
65
65
  "Total tokens": "Total tokens",
66
+ "Prompt cache tokens": "Prompt cache tokens",
66
67
  "Cost": "Cost",
67
68
  "Cost status": "Cost status",
68
69
  "Request ID": "Request ID",
@@ -98,6 +99,8 @@
98
99
  "Leave empty to not override": "Leave empty to not override",
99
100
  "Total input + output token capacity reported to clients.": "Total input + output token capacity reported to clients.",
100
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.",
101
104
  "AI API": "AI API",
102
105
  "Allow this role to use the AI API": "Allow this role to use the AI API",
103
106
  "Allow all AI Employees": "Allow all AI Employees",
@@ -105,15 +108,31 @@
105
108
  "Select allowed AI Employees": "Select allowed AI Employees",
106
109
  "Max request body size (MB)": "Max request body size (MB)",
107
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.",
108
- "User LLM permissions": "User LLM permissions",
109
- "Add permission": "Add permission",
110
- "Edit permission": "Edit permission",
111
- "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",
112
129
  "Allowed LLM services": "Allowed LLM services",
113
130
  "Allow all models": "Allow all models",
114
131
  "Allowed models": "Allowed models",
115
- "No service allowed": "No service allowed",
116
- "All models of allowed services": "All models of allowed services",
117
- "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.",
118
- "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."
119
138
  }