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
@@ -1,322 +0,0 @@
1
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
- import { Alert, Button, Card, Form, Modal, Popconfirm, Select, Space, Switch, Table, Tag, message } from 'antd';
3
- import type { ColumnsType } from 'antd/es/table';
4
- import { useFlowContext } from '@nocobase/flow-engine';
5
- import { useT } from '../locale';
6
- import { errorMessage, unwrapData } from './api';
7
- import type { ApiEnvelope } from './api';
8
-
9
- interface UserSummary {
10
- id: string | number;
11
- nickname?: string;
12
- username?: string;
13
- email?: string;
14
- }
15
-
16
- interface EnabledLlmService {
17
- llmService: string;
18
- llmServiceTitle?: string;
19
- enabledModels?: { label: string; value: string }[];
20
- }
21
-
22
- interface UserPermission {
23
- id: string | number;
24
- userId: string | number;
25
- user?: UserSummary;
26
- enabled: boolean;
27
- allowedLlmServices: string[];
28
- allowAllModels: boolean;
29
- allowedModels: string[];
30
- }
31
-
32
- /** The modal edits everything except the server-assigned id. */
33
- type UserPermissionFormValues = Omit<UserPermission, 'id' | 'user'>;
34
-
35
- const PAGE_SIZE = 20;
36
-
37
- export default function UserPermissionsPage() {
38
- const ctx = useFlowContext();
39
- const t = useT();
40
- const [form] = Form.useForm<UserPermissionFormValues>();
41
- const selectedServices = Form.useWatch('allowedLlmServices', form);
42
- const allowAllModels = Form.useWatch('allowAllModels', form);
43
- const [rows, setRows] = useState<UserPermission[]>([]);
44
- const [total, setTotal] = useState(0);
45
- const [page, setPage] = useState(1);
46
- const [users, setUsers] = useState<UserSummary[]>([]);
47
- const [userSearch, setUserSearch] = useState('');
48
- const [services, setServices] = useState<EnabledLlmService[]>([]);
49
- const [loading, setLoading] = useState(false);
50
- const [saving, setSaving] = useState(false);
51
- const [editing, setEditing] = useState<UserPermission>();
52
- const [open, setOpen] = useState(false);
53
-
54
- const load = useCallback(async () => {
55
- setLoading(true);
56
- try {
57
- const [permissionsResponse, servicesResponse] = await Promise.all([
58
- ctx.api.request({
59
- url: 'aiApiUserPermissions:list',
60
- method: 'get',
61
- params: { page, pageSize: PAGE_SIZE, appends: ['user'], sort: '-updatedAt' },
62
- }),
63
- ctx.api.request({ url: 'ai:listAllEnabledModels', method: 'get' }),
64
- ]);
65
- setRows(unwrapData<UserPermission[]>(permissionsResponse, []));
66
- setTotal((permissionsResponse as ApiEnvelope<UserPermission[]>)?.data?.meta?.count ?? 0);
67
- setServices(unwrapData<EnabledLlmService[]>(servicesResponse, []));
68
- } catch (error) {
69
- message.error(errorMessage(error));
70
- } finally {
71
- setLoading(false);
72
- }
73
- }, [ctx.api, page]);
74
-
75
- useEffect(() => {
76
- load();
77
- }, [load]);
78
-
79
- // Served by the plugin's own action rather than `users:list`, which belongs to the
80
- // pm.plugin-users snippet and would make this page unusable for a role granted only
81
- // pm.plugin-ai-api.user-permissions.
82
- const loadUsers = useCallback(
83
- async (keyword: string) => {
84
- try {
85
- const response = await ctx.api.request({
86
- url: 'aiApiUserPermissions:listUsers',
87
- method: 'get',
88
- params: { keyword, pageSize: 50, excludeGranted: !editing },
89
- });
90
- setUsers(unwrapData<UserSummary[]>(response, []));
91
- } catch (error) {
92
- message.error(errorMessage(error));
93
- }
94
- },
95
- [ctx.api, editing],
96
- );
97
-
98
- useEffect(() => {
99
- if (!open) return;
100
- const timer = setTimeout(() => loadUsers(userSearch), 300);
101
- return () => clearTimeout(timer);
102
- }, [open, userSearch, loadUsers]);
103
-
104
- const serviceOptions = useMemo(
105
- () =>
106
- services.map((service) => ({ label: service.llmServiceTitle || service.llmService, value: service.llmService })),
107
- [services],
108
- );
109
-
110
- // Model IDs are "serviceName/modelId", matching what the gateway enforces and what
111
- // GET /v1/models returns, so admins never have to type them by hand.
112
- const modelOptions = useMemo(() => {
113
- const picked = new Set(selectedServices || []);
114
- return services
115
- .filter((service) => picked.has(service.llmService))
116
- .flatMap((service) =>
117
- (service.enabledModels || []).map((model) => ({
118
- label: `${service.llmServiceTitle || service.llmService} / ${model.label || model.value}`,
119
- value: `${service.llmService}/${model.value}`,
120
- })),
121
- );
122
- }, [services, selectedServices]);
123
-
124
- const showCreate = () => {
125
- setEditing(undefined);
126
- setUserSearch('');
127
- form.setFieldsValue({
128
- enabled: true,
129
- allowedLlmServices: [],
130
- allowAllModels: true,
131
- allowedModels: [],
132
- });
133
- setOpen(true);
134
- };
135
-
136
- const showEdit = (record: UserPermission) => {
137
- setEditing(record);
138
- setUserSearch('');
139
- // Seed the picker with the granted user so the label renders before any search runs.
140
- setUsers(record.user ? [record.user] : []);
141
- form.setFieldsValue({
142
- userId: record.userId,
143
- enabled: record.enabled,
144
- allowAllModels: record.allowAllModels,
145
- allowedLlmServices: record.allowedLlmServices || [],
146
- allowedModels: record.allowedModels || [],
147
- });
148
- setOpen(true);
149
- };
150
-
151
- const save = async () => {
152
- const values = await form.validateFields();
153
- setSaving(true);
154
- try {
155
- await ctx.api.request({
156
- url: editing ? `aiApiUserPermissions:update/${editing.id}` : 'aiApiUserPermissions:create',
157
- method: 'post',
158
- data: values,
159
- });
160
- message.success(t('Saved successfully'));
161
- setOpen(false);
162
- await load();
163
- } catch (error) {
164
- message.error(errorMessage(error));
165
- } finally {
166
- setSaving(false);
167
- }
168
- };
169
-
170
- const remove = async (record: UserPermission) => {
171
- try {
172
- await ctx.api.request({ url: `aiApiUserPermissions:destroy/${record.id}`, method: 'post' });
173
- message.success(t('Deleted successfully'));
174
- await load();
175
- } catch (error) {
176
- message.error(errorMessage(error));
177
- }
178
- };
179
-
180
- const userLabel = (user?: UserSummary) => user?.nickname || user?.username || user?.email || String(user?.id ?? '');
181
- const serviceLabel = (name: string) => serviceOptions.find((option) => option.value === name)?.label || name;
182
-
183
- const columns: ColumnsType<UserPermission> = [
184
- {
185
- title: t('User'),
186
- key: 'user',
187
- width: 180,
188
- render: (_, record) => userLabel(record.user) || String(record.userId),
189
- },
190
- {
191
- title: t('Allowed LLM services'),
192
- dataIndex: 'allowedLlmServices',
193
- key: 'allowedLlmServices',
194
- render: (values: string[]) =>
195
- values?.length ? (
196
- <Space size={[0, 4]} wrap>
197
- {values.map((value) => (
198
- <Tag key={value}>{serviceLabel(value)}</Tag>
199
- ))}
200
- </Space>
201
- ) : (
202
- <Tag color="red">{t('No service allowed')}</Tag>
203
- ),
204
- },
205
- {
206
- title: t('Allowed models'),
207
- key: 'allowedModels',
208
- width: 220,
209
- render: (_, record) =>
210
- record.allowAllModels ? (
211
- <Tag color="blue">{t('All models of allowed services')}</Tag>
212
- ) : (
213
- <Space size={[0, 4]} wrap>
214
- {(record.allowedModels || []).map((value) => (
215
- <Tag key={value}>{value}</Tag>
216
- ))}
217
- </Space>
218
- ),
219
- },
220
- {
221
- title: t('Status'),
222
- dataIndex: 'enabled',
223
- key: 'enabled',
224
- width: 100,
225
- render: (enabled: boolean) => (
226
- <Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
227
- ),
228
- },
229
- {
230
- title: t('Actions'),
231
- key: 'actions',
232
- width: 150,
233
- fixed: 'right',
234
- render: (_, record) => (
235
- <Space size={0}>
236
- <Button type="link" onClick={() => showEdit(record)}>
237
- {t('Edit')}
238
- </Button>
239
- <Popconfirm title={t('Delete this permission?')} onConfirm={() => remove(record)}>
240
- <Button type="link" danger>
241
- {t('Delete')}
242
- </Button>
243
- </Popconfirm>
244
- </Space>
245
- ),
246
- },
247
- ];
248
-
249
- return (
250
- <Card
251
- title={t('User LLM permissions')}
252
- extra={
253
- <Button type="primary" onClick={showCreate}>
254
- {t('Add permission')}
255
- </Button>
256
- }
257
- >
258
- <Alert
259
- type="info"
260
- showIcon
261
- style={{ marginBottom: 16 }}
262
- message={t(
263
- 'Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.',
264
- )}
265
- />
266
- <Table
267
- rowKey="id"
268
- columns={columns}
269
- dataSource={rows}
270
- loading={loading}
271
- scroll={{ x: 1000 }}
272
- pagination={{
273
- current: page,
274
- pageSize: PAGE_SIZE,
275
- total,
276
- showSizeChanger: false,
277
- onChange: setPage,
278
- }}
279
- />
280
- <Modal
281
- title={editing ? t('Edit permission') : t('Add permission')}
282
- open={open}
283
- onCancel={() => setOpen(false)}
284
- onOk={save}
285
- confirmLoading={saving}
286
- destroyOnClose
287
- >
288
- <Form form={form} layout="vertical" preserve={false}>
289
- <Form.Item name="userId" label={t('User')} rules={[{ required: true }]}>
290
- <Select
291
- disabled={Boolean(editing)}
292
- showSearch
293
- // Matching happens on the server, so keep every returned option visible.
294
- filterOption={false}
295
- onSearch={setUserSearch}
296
- notFoundContent={null}
297
- options={users.map((user) => ({ label: userLabel(user), value: user.id }))}
298
- />
299
- </Form.Item>
300
- <Form.Item
301
- name="allowedLlmServices"
302
- label={t('Allowed LLM services')}
303
- extra={t('Only services also enabled in the general configuration take effect.')}
304
- >
305
- <Select mode="multiple" allowClear showSearch optionFilterProp="label" options={serviceOptions} />
306
- </Form.Item>
307
- <Form.Item name="allowAllModels" label={t('Allow all models')} valuePropName="checked">
308
- <Switch />
309
- </Form.Item>
310
- {allowAllModels === false && (
311
- <Form.Item name="allowedModels" label={t('Allowed models')}>
312
- <Select mode="multiple" allowClear showSearch optionFilterProp="label" options={modelOptions} />
313
- </Form.Item>
314
- )}
315
- <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
316
- <Switch />
317
- </Form.Item>
318
- </Form>
319
- </Modal>
320
- </Card>
321
- );
322
- }
@@ -1,276 +0,0 @@
1
- import React, { useCallback, useEffect, useState } from 'react';
2
- import {
3
- Button,
4
- Card,
5
- Form,
6
- Input,
7
- InputNumber,
8
- Modal,
9
- Popconfirm,
10
- Select,
11
- Space,
12
- Switch,
13
- Table,
14
- Tag,
15
- message,
16
- } from 'antd';
17
- import type { ColumnsType } from 'antd/es/table';
18
- import { useFlowContext } from '@nocobase/flow-engine';
19
- import { useT } from '../locale';
20
- import { errorMessage, unwrapData } from './api';
21
-
22
- interface UserSummary {
23
- id: string | number;
24
- nickname?: string;
25
- username?: string;
26
- email?: string;
27
- }
28
-
29
- interface QuotaPolicy {
30
- id: string | number;
31
- userId: string | number;
32
- user?: UserSummary;
33
- enabled: boolean;
34
- periodType: 'daily' | 'monthly';
35
- timezone: string;
36
- requestLimit?: string;
37
- totalTokenLimit?: string;
38
- costLimit?: string;
39
- currency: string;
40
- rejectUnpricedModel: boolean;
41
- missingUsageBehavior: 'allow' | 'use_reserved';
42
- contextOverflowBehavior: 'reject' | 'truncate';
43
- }
44
-
45
- export default function UserQuotasPage() {
46
- const ctx = useFlowContext();
47
- const t = useT();
48
- const [form] = Form.useForm<QuotaPolicy>();
49
- const [rows, setRows] = useState<QuotaPolicy[]>([]);
50
- const [users, setUsers] = useState<UserSummary[]>([]);
51
- const [loading, setLoading] = useState(false);
52
- const [saving, setSaving] = useState(false);
53
- const [editing, setEditing] = useState<QuotaPolicy>();
54
- const [open, setOpen] = useState(false);
55
-
56
- const load = useCallback(async () => {
57
- setLoading(true);
58
- try {
59
- const [policiesResponse, usersResponse] = await Promise.all([
60
- ctx.api.request({
61
- url: 'aiApiUserQuotaPolicies:list',
62
- method: 'get',
63
- params: { pageSize: 200, appends: ['user'], sort: '-updatedAt' },
64
- }),
65
- ctx.api.request({ url: 'users:list', method: 'get', params: { pageSize: 200 } }),
66
- ]);
67
- setRows(unwrapData<QuotaPolicy[]>(policiesResponse, []));
68
- setUsers(unwrapData<UserSummary[]>(usersResponse, []));
69
- } catch (error) {
70
- message.error(errorMessage(error));
71
- } finally {
72
- setLoading(false);
73
- }
74
- }, [ctx.api]);
75
-
76
- useEffect(() => {
77
- load();
78
- }, [load]);
79
-
80
- const showCreate = () => {
81
- setEditing(undefined);
82
- form.setFieldsValue({
83
- enabled: true,
84
- periodType: 'monthly',
85
- timezone: 'UTC',
86
- currency: 'USD',
87
- rejectUnpricedModel: true,
88
- missingUsageBehavior: 'use_reserved',
89
- contextOverflowBehavior: 'reject',
90
- } as QuotaPolicy);
91
- setOpen(true);
92
- };
93
-
94
- const showEdit = (record: QuotaPolicy) => {
95
- setEditing(record);
96
- form.setFieldsValue(record);
97
- setOpen(true);
98
- };
99
-
100
- const save = async () => {
101
- const values = await form.validateFields();
102
- setSaving(true);
103
- try {
104
- await ctx.api.request({
105
- url: editing ? `aiApiUserQuotaPolicies:update/${editing.id}` : 'aiApiUserQuotaPolicies:create',
106
- method: 'post',
107
- data: values,
108
- });
109
- message.success(t('Saved successfully'));
110
- setOpen(false);
111
- await load();
112
- } catch (error) {
113
- message.error(errorMessage(error));
114
- } finally {
115
- setSaving(false);
116
- }
117
- };
118
-
119
- const remove = async (record: QuotaPolicy) => {
120
- try {
121
- await ctx.api.request({
122
- url: `aiApiUserQuotaPolicies:destroy/${record.id}`,
123
- method: 'post',
124
- });
125
- message.success(t('Deleted successfully'));
126
- await load();
127
- } catch (error) {
128
- message.error(errorMessage(error));
129
- }
130
- };
131
-
132
- const userLabel = (user?: UserSummary) => user?.nickname || user?.username || user?.email || String(user?.id ?? '');
133
- const columns: ColumnsType<QuotaPolicy> = [
134
- {
135
- title: t('User'),
136
- key: 'user',
137
- width: 180,
138
- render: (_, record) => userLabel(record.user) || String(record.userId),
139
- },
140
- { title: t('Period'), dataIndex: 'periodType', key: 'periodType', width: 100 },
141
- {
142
- title: t('Request limit'),
143
- dataIndex: 'requestLimit',
144
- key: 'requestLimit',
145
- width: 130,
146
- render: (value) => value ?? t('Unlimited'),
147
- },
148
- {
149
- title: t('Token limit'),
150
- dataIndex: 'totalTokenLimit',
151
- key: 'totalTokenLimit',
152
- width: 130,
153
- render: (value) => value ?? t('Unlimited'),
154
- },
155
- {
156
- title: t('Cost limit'),
157
- dataIndex: 'costLimit',
158
- key: 'costLimit',
159
- width: 120,
160
- render: (value, record) => (value == null ? t('Unlimited') : `${value} ${record.currency}`),
161
- },
162
- {
163
- title: t('Context overflow behavior'),
164
- dataIndex: 'contextOverflowBehavior',
165
- key: 'contextOverflowBehavior',
166
- width: 150,
167
- render: (value: QuotaPolicy['contextOverflowBehavior']) =>
168
- value === 'truncate' ? t('Truncate oldest conversation turns') : t('Reject request'),
169
- },
170
- { title: t('Timezone'), dataIndex: 'timezone', key: 'timezone', width: 140 },
171
- {
172
- title: t('Status'),
173
- dataIndex: 'enabled',
174
- key: 'enabled',
175
- width: 100,
176
- render: (enabled: boolean) => (
177
- <Tag color={enabled ? 'green' : 'default'}>{enabled ? t('Enabled') : t('Disabled')}</Tag>
178
- ),
179
- },
180
- {
181
- title: t('Actions'),
182
- key: 'actions',
183
- width: 150,
184
- fixed: 'right',
185
- render: (_, record) => (
186
- <Space size={0}>
187
- <Button type="link" onClick={() => showEdit(record)}>
188
- {t('Edit')}
189
- </Button>
190
- <Popconfirm title={t('Delete this quota?')} onConfirm={() => remove(record)}>
191
- <Button type="link" danger>
192
- {t('Delete')}
193
- </Button>
194
- </Popconfirm>
195
- </Space>
196
- ),
197
- },
198
- ];
199
-
200
- return (
201
- <Card
202
- title={t('User quotas')}
203
- extra={
204
- <Button type="primary" onClick={showCreate}>
205
- {t('Add quota')}
206
- </Button>
207
- }
208
- >
209
- <Table rowKey="id" columns={columns} dataSource={rows} loading={loading} scroll={{ x: 1100 }} />
210
- <Modal
211
- title={editing ? t('Edit quota') : t('Add quota')}
212
- open={open}
213
- onCancel={() => setOpen(false)}
214
- onOk={save}
215
- confirmLoading={saving}
216
- destroyOnClose
217
- >
218
- <Form form={form} layout="vertical" preserve={false}>
219
- <Form.Item name="userId" label={t('User')} rules={[{ required: true }]}>
220
- <Select
221
- disabled={Boolean(editing)}
222
- showSearch
223
- optionFilterProp="label"
224
- options={users.map((user) => ({ label: userLabel(user), value: user.id }))}
225
- />
226
- </Form.Item>
227
- <Form.Item name="periodType" label={t('Period')} rules={[{ required: true }]}>
228
- <Select
229
- options={[
230
- { label: t('Daily'), value: 'daily' },
231
- { label: t('Monthly'), value: 'monthly' },
232
- ]}
233
- />
234
- </Form.Item>
235
- <Form.Item name="timezone" label={t('Timezone')} rules={[{ required: true }]}>
236
- <Input placeholder="UTC" />
237
- </Form.Item>
238
- <Form.Item name="requestLimit" label={t('Request limit')}>
239
- <InputNumber min={0} stringMode style={{ width: '100%' }} />
240
- </Form.Item>
241
- <Form.Item name="totalTokenLimit" label={t('Token limit')}>
242
- <InputNumber min={0} stringMode style={{ width: '100%' }} />
243
- </Form.Item>
244
- <Form.Item name="costLimit" label={t('Cost limit')}>
245
- <InputNumber min={0} stringMode style={{ width: '100%' }} />
246
- </Form.Item>
247
- <Form.Item name="currency" label={t('Currency')} rules={[{ required: true }]}>
248
- <Input />
249
- </Form.Item>
250
- <Form.Item name="rejectUnpricedModel" label={t('Reject unpriced models')} valuePropName="checked">
251
- <Switch />
252
- </Form.Item>
253
- <Form.Item name="missingUsageBehavior" label={t('Missing usage behavior')} rules={[{ required: true }]}>
254
- <Select
255
- options={[
256
- { label: t('Use reserved estimate'), value: 'use_reserved' },
257
- { label: t('Allow without token charge'), value: 'allow' },
258
- ]}
259
- />
260
- </Form.Item>
261
- <Form.Item name="contextOverflowBehavior" label={t('Context overflow behavior')} rules={[{ required: true }]}>
262
- <Select
263
- options={[
264
- { label: t('Reject request'), value: 'reject' },
265
- { label: t('Truncate oldest conversation turns'), value: 'truncate' },
266
- ]}
267
- />
268
- </Form.Item>
269
- <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
270
- <Switch />
271
- </Form.Item>
272
- </Form>
273
- </Modal>
274
- </Card>
275
- );
276
- }
@@ -1,66 +0,0 @@
1
- /**
2
- * This file is part of the NocoBase (R) project.
3
- * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
- * Authors: NocoBase Team.
5
- *
6
- * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
- * For more information, please refer to: https://www.nocobase.com/agreement.
8
- */
9
-
10
- import type { Context, Next } from '@nocobase/actions';
11
- import { describe, expect, it, vi } from 'vitest';
12
- import aiApiUserPermissionsResource from '../resource/ai-api-user-permissions';
13
-
14
- type ActionHandler = (ctx: Context, next: Next) => Promise<void>;
15
-
16
- const listUsers = aiApiUserPermissionsResource.actions?.listUsers as ActionHandler;
17
-
18
- function model(values: Record<string, unknown>) {
19
- return { get: (key: string) => values[key] };
20
- }
21
-
22
- describe('aiApiUserPermissions:listUsers', () => {
23
- it('returns the canonical rows shape for NocoBase data wrapping', async () => {
24
- const user = model({
25
- id: 7,
26
- nickname: 'Ada',
27
- username: 'ada',
28
- email: 'ada@example.com',
29
- password: 'must-not-leak',
30
- });
31
- const findAndCount = vi.fn(async () => [[user], 1] as const);
32
- const ctx = {
33
- action: { params: {} },
34
- db: { getRepository: () => ({ findAndCount }) },
35
- } as unknown as Context;
36
-
37
- await listUsers(ctx, async () => undefined);
38
-
39
- expect(ctx.body).toEqual({
40
- rows: [{ id: 7, nickname: 'Ada', username: 'ada', email: 'ada@example.com' }],
41
- count: 1,
42
- page: 1,
43
- pageSize: 50,
44
- });
45
- expect(findAndCount).toHaveBeenCalledWith(
46
- expect.objectContaining({ fields: ['id', 'nickname', 'username', 'email'] }),
47
- );
48
- });
49
-
50
- it('excludes users that already hold a grant when requested', async () => {
51
- const findAndCount = vi.fn(async () => [[], 0] as const);
52
- const ctx = {
53
- action: { params: { excludeGranted: true } },
54
- db: {
55
- getRepository: (name: string) =>
56
- name === 'aiApiUserPermissions'
57
- ? { find: vi.fn(async () => [model({ userId: 3 }), model({ userId: 8 })]) }
58
- : { findAndCount },
59
- },
60
- } as unknown as Context;
61
-
62
- await listUsers(ctx, async () => undefined);
63
-
64
- expect(findAndCount).toHaveBeenCalledWith(expect.objectContaining({ filter: { id: { $notIn: [3, 8] } } }));
65
- });
66
- });