plugin-ai-api 1.0.21 → 1.0.24

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 (59) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/902.e74518750f1e4201.js +10 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  5. package/dist/client-v2/902.c7c00a565085438a.js +10 -0
  6. package/dist/client-v2/index.js +1 -1
  7. package/dist/constants.js +5 -2
  8. package/dist/locale/en-US.json +15 -1
  9. package/dist/locale/vi-VN.json +15 -1
  10. package/dist/locale/zh-CN.json +15 -1
  11. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  12. package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
  13. package/dist/server/plugin.js +32 -0
  14. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  15. package/dist/server/routes/agent-completions.js +5 -0
  16. package/dist/server/routes/chat-completions.js +52 -27
  17. package/dist/server/routes/completions.js +59 -33
  18. package/dist/server/routes/embeddings.js +6 -14
  19. package/dist/server/routes/models.js +24 -0
  20. package/dist/server/utils/direct-llm-context.js +184 -0
  21. package/dist/server/utils/openai-format.js +17 -3
  22. package/dist/server/utils/user-permissions.js +160 -0
  23. package/dist/server/validation.js +3 -0
  24. package/dist/swagger.js +4 -3
  25. package/package.json +2 -2
  26. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  27. package/src/client/plugin.tsx +14 -3
  28. package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
  29. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  30. package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
  31. package/src/client-v2/plugin.tsx +12 -3
  32. package/src/constants.ts +7 -0
  33. package/src/locale/en-US.json +15 -1
  34. package/src/locale/vi-VN.json +15 -1
  35. package/src/locale/zh-CN.json +15 -1
  36. package/src/server/__tests__/direct-llm-context.test.ts +125 -0
  37. package/src/server/__tests__/models.test.ts +44 -2
  38. package/src/server/__tests__/openai-format.test.ts +52 -1
  39. package/src/server/__tests__/permission-sync.test.ts +109 -0
  40. package/src/server/__tests__/usage-route.test.ts +265 -5
  41. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  42. package/src/server/__tests__/user-permissions.test.ts +284 -0
  43. package/src/server/__tests__/validation.test.ts +36 -0
  44. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  45. package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
  46. package/src/server/plugin.ts +42 -1
  47. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  48. package/src/server/routes/agent-completions.ts +7 -0
  49. package/src/server/routes/chat-completions.ts +58 -30
  50. package/src/server/routes/completions.ts +68 -34
  51. package/src/server/routes/embeddings.ts +10 -15
  52. package/src/server/routes/models.ts +28 -0
  53. package/src/server/utils/direct-llm-context.ts +216 -0
  54. package/src/server/utils/openai-format.ts +26 -0
  55. package/src/server/utils/user-permissions.ts +218 -0
  56. package/src/server/validation.ts +3 -0
  57. package/src/swagger.ts +9 -3
  58. package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
  59. package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
@@ -0,0 +1,322 @@
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
+ }
@@ -39,6 +39,7 @@ interface QuotaPolicy {
39
39
  currency: string;
40
40
  rejectUnpricedModel: boolean;
41
41
  missingUsageBehavior: 'allow' | 'use_reserved';
42
+ contextOverflowBehavior: 'reject' | 'truncate';
42
43
  }
43
44
 
44
45
  export default function UserQuotasPage() {
@@ -85,6 +86,7 @@ export default function UserQuotasPage() {
85
86
  currency: 'USD',
86
87
  rejectUnpricedModel: true,
87
88
  missingUsageBehavior: 'use_reserved',
89
+ contextOverflowBehavior: 'reject',
88
90
  } as QuotaPolicy);
89
91
  setOpen(true);
90
92
  };
@@ -157,6 +159,14 @@ export default function UserQuotasPage() {
157
159
  width: 120,
158
160
  render: (value, record) => (value == null ? t('Unlimited') : `${value} ${record.currency}`),
159
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
+ },
160
170
  { title: t('Timezone'), dataIndex: 'timezone', key: 'timezone', width: 140 },
161
171
  {
162
172
  title: t('Status'),
@@ -248,6 +258,14 @@ export default function UserQuotasPage() {
248
258
  ]}
249
259
  />
250
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>
251
269
  <Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
252
270
  <Switch />
253
271
  </Form.Item>
@@ -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 } from '../constants';
3
+ import { AI_API_ACL_SNIPPET, AI_API_USER_PERMISSIONS_SNIPPET } from '../constants';
4
4
 
5
5
  interface PermissionTabOptions {
6
6
  key: string;
@@ -51,12 +51,21 @@ export class PluginAiApiClient extends Plugin<Record<string, never>, Application
51
51
  componentLoader: () => import('./pages/ModelMetadataPage'),
52
52
  });
53
53
 
54
+ this.pluginSettingsManager.addPageTabItem({
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
+
54
63
  this.pluginSettingsManager.addPageTabItem({
55
64
  menuKey: 'ai-api',
56
65
  key: 'user-quotas',
57
66
  title: this.t('User quotas'),
58
67
  aclSnippet: AI_API_ACL_SNIPPET,
59
- sort: 4,
68
+ sort: 5,
60
69
  componentLoader: () => import('./pages/UserQuotasPage'),
61
70
  });
62
71
 
@@ -65,7 +74,7 @@ export class PluginAiApiClient extends Plugin<Record<string, never>, Application
65
74
  key: 'usage',
66
75
  title: this.t('Usage'),
67
76
  aclSnippet: AI_API_ACL_SNIPPET,
68
- sort: 5,
77
+ sort: 6,
69
78
  componentLoader: () => import('./pages/UsagePage'),
70
79
  });
71
80
 
package/src/constants.ts CHANGED
@@ -19,3 +19,10 @@
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';
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "Missing usage behavior",
54
54
  "Use reserved estimate": "Use reserved estimate",
55
55
  "Allow without token charge": "Allow without token charge",
56
+ "Context overflow behavior": "Context overflow behavior",
57
+ "Reject request": "Reject request",
58
+ "Truncate oldest conversation turns": "Truncate oldest conversation turns",
56
59
  "Started at": "Started at",
57
60
  "Requested model": "Requested model",
58
61
  "Resolved service": "Resolved service",
@@ -101,5 +104,16 @@
101
104
  "Select which AI Employees this role may use:": "Select which AI Employees this role may use:",
102
105
  "Select allowed AI Employees": "Select allowed AI Employees",
103
106
  "Max request body size (MB)": "Max request body size (MB)",
104
- "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."
107
+ "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?",
112
+ "Allowed LLM services": "Allowed LLM services",
113
+ "Allow all models": "Allow all models",
114
+ "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."
105
119
  }
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "Xử lý khi thiếu token usage",
54
54
  "Use reserved estimate": "Dùng số liệu giữ chỗ để ước tính",
55
55
  "Allow without token charge": "Cho phép và không tính token",
56
+ "Context overflow behavior": "Xử lý khi vượt context",
57
+ "Reject request": "Từ chối request",
58
+ "Truncate oldest conversation turns": "Cắt các lượt hội thoại cũ nhất",
56
59
  "Started at": "Bắt đầu lúc",
57
60
  "Requested model": "Model được yêu cầu",
58
61
  "Resolved service": "Service đã resolve",
@@ -101,5 +104,16 @@
101
104
  "Select which AI Employees this role may use:": "Chọn AI Employee mà vai trò này được dùng:",
102
105
  "Select allowed AI Employees": "Chọn AI Employee được phép",
103
106
  "Max request body size (MB)": "Giới hạn kích thước request body (MB)",
104
- "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "Tăng giá trị này để nhận ảnh base64 gửi trực tiếp. Base64 làm tăng khoảng 33% so với kích thước tệp gốc."
107
+ "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "Tăng giá trị này để nhận ảnh base64 gửi trực tiếp. Base64 làm tăng khoảng 33% so với kích thước tệp gốc.",
108
+ "User LLM permissions": "Phân quyền LLM theo người dùng",
109
+ "Add permission": "Thêm phân quyền",
110
+ "Edit permission": "Sửa phân quyền",
111
+ "Delete this permission?": "Xoá phân quyền này?",
112
+ "Allowed LLM services": "Dịch vụ LLM được phép",
113
+ "Allow all models": "Cho phép tất cả model",
114
+ "Allowed models": "Model được phép",
115
+ "No service allowed": "Không được phép dịch vụ nào",
116
+ "All models of allowed services": "Tất cả model của các dịch vụ được phép",
117
+ "Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.": "Người dùng có trong danh sách này chỉ được dùng các dịch vụ được chọn bên dưới. Người dùng không có bản ghi sẽ dùng theo cấu hình chung.",
118
+ "Only services also enabled in the general configuration take effect.": "Chỉ những dịch vụ đồng thời được bật trong cấu hình chung mới có hiệu lực."
105
119
  }
@@ -53,6 +53,9 @@
53
53
  "Missing usage behavior": "缺少用量时的行为",
54
54
  "Use reserved estimate": "使用预留估算",
55
55
  "Allow without token charge": "允许且不计令牌",
56
+ "Context overflow behavior": "上下文超限处理",
57
+ "Reject request": "拒绝请求",
58
+ "Truncate oldest conversation turns": "截断最早的对话轮次",
56
59
  "Started at": "开始时间",
57
60
  "Requested model": "请求模型",
58
61
  "Resolved service": "解析后的服务",
@@ -101,5 +104,16 @@
101
104
  "Select which AI Employees this role may use:": "选择此角色可使用的 AI 员工:",
102
105
  "Select allowed AI Employees": "选择允许的 AI 员工",
103
106
  "Max request body size (MB)": "请求体大小上限(MB)",
104
- "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "调高此值以接收内联 base64 图片。base64 编码会使体积增加约 33%。"
107
+ "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "调高此值以接收内联 base64 图片。base64 编码会使体积增加约 33%。",
108
+ "User LLM permissions": "用户 LLM 权限",
109
+ "Add permission": "添加权限",
110
+ "Edit permission": "编辑权限",
111
+ "Delete this permission?": "确定删除此权限?",
112
+ "Allowed LLM services": "允许的 LLM 服务",
113
+ "Allow all models": "允许所有模型",
114
+ "Allowed models": "允许的模型",
115
+ "No service allowed": "未允许任何服务",
116
+ "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.": "此处列出的用户仅能使用下方所选的服务;没有记录的用户按通用配置处理。",
118
+ "Only services also enabled in the general configuration take effect.": "仅当服务同时在通用配置中启用时才会生效。"
105
119
  }
@@ -0,0 +1,125 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { DirectLlmContextError, prepareDirectLlmContext, type OpenAIMessage } from '../utils/direct-llm-context';
4
+
5
+ function context({ behavior = 'reject', metadata = { contextWindow: 120, maxCompletionTokens: 40 } } = {}): Context {
6
+ return {
7
+ state: { currentUser: { id: 1 } },
8
+ db: {
9
+ getRepository: vi.fn((name: string) => {
10
+ if (name === 'aiApiModelMetadata') {
11
+ return {
12
+ findOne: vi.fn().mockResolvedValue({ get: (key: string) => metadata[key as keyof typeof metadata] }),
13
+ };
14
+ }
15
+ if (name === 'aiApiUserQuotaPolicies') {
16
+ return {
17
+ findOne: vi
18
+ .fn()
19
+ .mockResolvedValue({ get: (key: string) => (key === 'contextOverflowBehavior' ? behavior : undefined) }),
20
+ };
21
+ }
22
+ return { findOne: vi.fn() };
23
+ }),
24
+ },
25
+ } as unknown as Context;
26
+ }
27
+
28
+ function request(messages: OpenAIMessage[], tools?: unknown) {
29
+ return {
30
+ serviceName: 'test-service',
31
+ modelId: 'test-model',
32
+ messages,
33
+ tools,
34
+ };
35
+ }
36
+
37
+ describe('direct LLM context preparation', () => {
38
+ it('uses reject when the user has no enabled policy', async () => {
39
+ const ctx = context();
40
+ vi.mocked(ctx.db.getRepository).mockImplementation((name: string) => {
41
+ if (name === 'aiApiModelMetadata') {
42
+ return {
43
+ findOne: vi.fn().mockResolvedValue({ get: (key: string) => (key === 'contextWindow' ? 120 : 40) }),
44
+ } as never;
45
+ }
46
+ return { findOne: vi.fn().mockResolvedValue(null) } as never;
47
+ });
48
+
49
+ await expect(
50
+ prepareDirectLlmContext(ctx, request([{ role: 'user', content: 'x'.repeat(400) }])),
51
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
52
+ });
53
+
54
+ it('keeps a request that fits the model input budget', async () => {
55
+ const messages = [{ role: 'user', content: 'hello' }];
56
+ const prepared = await prepareDirectLlmContext(context(), request(messages));
57
+
58
+ expect(prepared.messages).toBe(messages);
59
+ expect(prepared.truncated).toBe(false);
60
+ expect(prepared.inputTokenBudget).toBe(80);
61
+ });
62
+
63
+ it('rejects a requested output limit above model metadata', async () => {
64
+ await expect(
65
+ prepareDirectLlmContext(context(), { ...request([{ role: 'user', content: 'hello' }]), maxCompletionTokens: 41 }),
66
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'max_completion_tokens_exceeds_model_limit' });
67
+ });
68
+
69
+ it('truncates oldest complete turns while preserving instructions and newest turn', async () => {
70
+ const messages: OpenAIMessage[] = [
71
+ { role: 'system', content: 'Always answer safely.' },
72
+ { role: 'user', content: 'first '.repeat(30) },
73
+ { role: 'assistant', content: 'first answer '.repeat(20) },
74
+ { role: 'user', content: 'latest question' },
75
+ ];
76
+
77
+ const prepared = await prepareDirectLlmContext(context({ behavior: 'truncate' }), request(messages));
78
+
79
+ expect(prepared.truncated).toBe(true);
80
+ expect(prepared.messages).toEqual([messages[0], messages[3]]);
81
+ expect(messages).toHaveLength(4);
82
+ });
83
+
84
+ it('keeps assistant tool calls and their tool responses in the same turn', async () => {
85
+ const messages: OpenAIMessage[] = [
86
+ { role: 'user', content: 'old question '.repeat(30) },
87
+ { role: 'assistant', content: '', tool_calls: [{ id: 'call-old', type: 'function' }] },
88
+ { role: 'tool', tool_call_id: 'call-old', content: 'old result '.repeat(20) },
89
+ { role: 'user', content: 'latest question' },
90
+ ];
91
+
92
+ const prepared = await prepareDirectLlmContext(context({ behavior: 'truncate' }), request(messages));
93
+
94
+ expect(prepared.messages).toEqual([messages[3]]);
95
+ });
96
+
97
+ it('rejects when the newest turn cannot fit without cutting content', async () => {
98
+ await expect(
99
+ prepareDirectLlmContext(context({ behavior: 'truncate' }), request([{ role: 'user', content: 'x'.repeat(400) }])),
100
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
101
+ });
102
+
103
+ it('rejects image content until a model-specific estimator is available', async () => {
104
+ await expect(
105
+ prepareDirectLlmContext(
106
+ context(),
107
+ request([
108
+ { role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.test/image.png' } }] },
109
+ ]),
110
+ ),
111
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_estimation_unsupported' });
112
+ });
113
+
114
+ it('counts tool definitions as fixed input overhead', async () => {
115
+ await expect(
116
+ prepareDirectLlmContext(
117
+ context(),
118
+ request(
119
+ [{ role: 'user', content: 'hello' }],
120
+ [{ type: 'function', function: { name: 'large', parameters: { text: 'x'.repeat(400) } } }],
121
+ ),
122
+ ),
123
+ ).rejects.toMatchObject<Partial<DirectLlmContextError>>({ code: 'context_length_exceeded' });
124
+ });
125
+ });