plugin-ai-api 1.0.21 → 1.0.23

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 (46) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/index.js +1 -1
  3. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  4. package/dist/client-v2/index.js +1 -1
  5. package/dist/constants.js +5 -2
  6. package/dist/locale/en-US.json +12 -1
  7. package/dist/locale/vi-VN.json +12 -1
  8. package/dist/locale/zh-CN.json +12 -1
  9. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  10. package/dist/server/plugin.js +32 -0
  11. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  12. package/dist/server/routes/agent-completions.js +5 -0
  13. package/dist/server/routes/chat-completions.js +29 -16
  14. package/dist/server/routes/completions.js +33 -20
  15. package/dist/server/routes/embeddings.js +6 -14
  16. package/dist/server/routes/models.js +24 -0
  17. package/dist/server/utils/openai-format.js +17 -3
  18. package/dist/server/utils/user-permissions.js +160 -0
  19. package/dist/swagger.js +4 -3
  20. package/package.json +2 -2
  21. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  22. package/src/client/plugin.tsx +14 -3
  23. package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
  24. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  25. package/src/client-v2/plugin.tsx +12 -3
  26. package/src/constants.ts +7 -0
  27. package/src/locale/en-US.json +12 -1
  28. package/src/locale/vi-VN.json +12 -1
  29. package/src/locale/zh-CN.json +12 -1
  30. package/src/server/__tests__/models.test.ts +44 -2
  31. package/src/server/__tests__/openai-format.test.ts +52 -1
  32. package/src/server/__tests__/permission-sync.test.ts +109 -0
  33. package/src/server/__tests__/usage-route.test.ts +213 -0
  34. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  35. package/src/server/__tests__/user-permissions.test.ts +284 -0
  36. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  37. package/src/server/plugin.ts +42 -1
  38. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  39. package/src/server/routes/agent-completions.ts +7 -0
  40. package/src/server/routes/chat-completions.ts +32 -16
  41. package/src/server/routes/completions.ts +40 -18
  42. package/src/server/routes/embeddings.ts +10 -15
  43. package/src/server/routes/models.ts +28 -0
  44. package/src/server/utils/openai-format.ts +26 -0
  45. package/src/server/utils/user-permissions.ts +218 -0
  46. package/src/swagger.ts +9 -3
@@ -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
+ }
@@ -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';
@@ -101,5 +101,16 @@
101
101
  "Select which AI Employees this role may use:": "Select which AI Employees this role may use:",
102
102
  "Select allowed AI Employees": "Select allowed AI Employees",
103
103
  "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."
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.",
105
+ "User LLM permissions": "User LLM permissions",
106
+ "Add permission": "Add permission",
107
+ "Edit permission": "Edit permission",
108
+ "Delete this permission?": "Delete this permission?",
109
+ "Allowed LLM services": "Allowed LLM services",
110
+ "Allow all models": "Allow all models",
111
+ "Allowed models": "Allowed models",
112
+ "No service allowed": "No service allowed",
113
+ "All models of allowed services": "All models of allowed services",
114
+ "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.",
115
+ "Only services also enabled in the general configuration take effect.": "Only services also enabled in the general configuration take effect."
105
116
  }
@@ -101,5 +101,16 @@
101
101
  "Select which AI Employees this role may use:": "Chọn AI Employee mà vai trò này được dùng:",
102
102
  "Select allowed AI Employees": "Chọn AI Employee được phép",
103
103
  "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."
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.",
105
+ "User LLM permissions": "Phân quyền LLM theo người dùng",
106
+ "Add permission": "Thêm phân quyền",
107
+ "Edit permission": "Sửa phân quyền",
108
+ "Delete this permission?": "Xoá phân quyền này?",
109
+ "Allowed LLM services": "Dịch vụ LLM được phép",
110
+ "Allow all models": "Cho phép tất cả model",
111
+ "Allowed models": "Model được phép",
112
+ "No service allowed": "Không được phép dịch vụ nào",
113
+ "All models of allowed services": "Tất cả model của các dịch vụ được phép",
114
+ "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.",
115
+ "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
116
  }
@@ -101,5 +101,16 @@
101
101
  "Select which AI Employees this role may use:": "选择此角色可使用的 AI 员工:",
102
102
  "Select allowed AI Employees": "选择允许的 AI 员工",
103
103
  "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%。"
104
+ "Raise this to accept inline base64 images. Base64 adds about 33% to the original file size.": "调高此值以接收内联 base64 图片。base64 编码会使体积增加约 33%。",
105
+ "User LLM permissions": "用户 LLM 权限",
106
+ "Add permission": "添加权限",
107
+ "Edit permission": "编辑权限",
108
+ "Delete this permission?": "确定删除此权限?",
109
+ "Allowed LLM services": "允许的 LLM 服务",
110
+ "Allow all models": "允许所有模型",
111
+ "Allowed models": "允许的模型",
112
+ "No service allowed": "未允许任何服务",
113
+ "All models of allowed services": "允许服务下的所有模型",
114
+ "Users listed here are limited to the services selected below. Users without a record fall back to the general configuration.": "此处列出的用户仅能使用下方所选的服务;没有记录的用户按通用配置处理。",
115
+ "Only services also enabled in the general configuration take effect.": "仅当服务同时在通用配置中启用时才会生效。"
105
116
  }
@@ -7,11 +7,33 @@
7
7
  * For more information, please refer to: https://www.nocobase.com/agreement.
8
8
  */
9
9
 
10
- import { describe, expect, it } from 'vitest';
11
- import { buildModelObject } from '../routes/models';
10
+ import { Context } from '@nocobase/actions';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { buildModelObject, handleGetModel, handleListModels } from '../routes/models';
12
13
 
13
14
  const CREATED = 1_700_000_000;
14
15
 
16
+ function permissionLookupFailureContext() {
17
+ const ctx = {
18
+ app: { name: 'main', pm: { get: () => ({}) } },
19
+ state: { currentUser: { id: 1 } },
20
+ db: {
21
+ getRepository: (name: string) => {
22
+ if (name === 'aiApiConfig') return { findOne: vi.fn(async () => null) };
23
+ if (name === 'llmServices') return { find: vi.fn(async () => []) };
24
+ if (name === 'aiApiUserPermissions') {
25
+ return { findOne: vi.fn(async () => Promise.reject(new Error('permission database unavailable'))) };
26
+ }
27
+ return { find: vi.fn(async () => []) };
28
+ },
29
+ },
30
+ log: { error: vi.fn(), warn: vi.fn() },
31
+ status: 0,
32
+ body: undefined,
33
+ } as unknown as Context;
34
+ return ctx;
35
+ }
36
+
15
37
  describe('buildModelObject', () => {
16
38
  it('returns the base OpenAI model shape with no override', () => {
17
39
  const model = buildModelObject('svc/gpt-4o', CREATED, 'My Service');
@@ -72,3 +94,23 @@ describe('buildModelObject', () => {
72
94
  expect(buildModelObject('svc/m', CREATED, 'Svc', { contextWindow: 10 }).active).toBe(true);
73
95
  });
74
96
  });
97
+
98
+ describe('model catalog permission lookup failures', () => {
99
+ it('returns a retryable 503 when listing models', async () => {
100
+ const ctx = permissionLookupFailureContext();
101
+
102
+ await handleListModels(ctx, undefined as never);
103
+
104
+ expect(ctx.status).toBe(503);
105
+ expect(ctx.body).toMatchObject({ error: { code: 'permission_check_failed' } });
106
+ });
107
+
108
+ it('returns a retryable 503 when retrieving a model', async () => {
109
+ const ctx = permissionLookupFailureContext();
110
+
111
+ await handleGetModel(ctx, 'openai/gpt-4o', undefined as never);
112
+
113
+ expect(ctx.status).toBe(503);
114
+ expect(ctx.body).toMatchObject({ error: { code: 'permission_check_failed' } });
115
+ });
116
+ });
@@ -1,4 +1,4 @@
1
- import { toOpenAIResponse, toOpenAIStreamChunk } from '../utils/openai-format';
1
+ import { toOpenAIResponse, toOpenAIStreamChunk, toOpenAIUsageChunk } from '../utils/openai-format';
2
2
  import { isStreamingRequested } from '../utils/streaming';
3
3
  import { applyProviderRequestParameters, getProviderRequestParameters } from '../routes/chat-completions';
4
4
 
@@ -52,6 +52,57 @@ describe('AI API OpenAI tool-call formatting', () => {
52
52
  });
53
53
  });
54
54
 
55
+ describe('AI API OpenAI usage-only streaming chunks', () => {
56
+ it('includes a null usage field for normal chunks', () => {
57
+ const chunk = toOpenAIStreamChunk({
58
+ id: 'chatcmpl-1',
59
+ model: 'service/model',
60
+ delta: { content: 'Hello' },
61
+ });
62
+
63
+ expect(chunk.choices).toHaveLength(1);
64
+ expect(chunk).toHaveProperty('usage', null);
65
+ });
66
+
67
+ it('includes a null usage field for finish chunks', () => {
68
+ const chunk = toOpenAIStreamChunk({
69
+ id: 'chatcmpl-1',
70
+ model: 'service/model',
71
+ delta: {},
72
+ finishReason: 'stop',
73
+ });
74
+
75
+ expect(chunk.choices[0].finish_reason).toBe('stop');
76
+ expect(chunk).toHaveProperty('usage', null);
77
+ });
78
+
79
+ it('formats a usage-only chat completion chunk with an empty choices array', () => {
80
+ const chunk = toOpenAIUsageChunk({
81
+ id: 'chatcmpl-1',
82
+ model: 'service/model',
83
+ usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
84
+ object: 'chat.completion.chunk',
85
+ });
86
+
87
+ expect(chunk.object).toBe('chat.completion.chunk');
88
+ expect(chunk.choices).toEqual([]);
89
+ expect(chunk.usage).toEqual({ prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 });
90
+ });
91
+
92
+ it('formats a usage-only legacy text completion chunk', () => {
93
+ const chunk = toOpenAIUsageChunk({
94
+ id: 'cmpl-1',
95
+ model: 'service/model',
96
+ usage: { prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 },
97
+ object: 'text_completion',
98
+ });
99
+
100
+ expect(chunk.object).toBe('text_completion');
101
+ expect(chunk.choices).toEqual([]);
102
+ expect(chunk.usage).toEqual({ prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 });
103
+ });
104
+ });
105
+
55
106
  describe('AI API provider parameter forwarding', () => {
56
107
  it('forwards model and tool-call parameters not managed by the gateway', () => {
57
108
  const parameters = getProviderRequestParameters({
@@ -0,0 +1,109 @@
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 { Context } from '@nocobase/actions';
11
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import { PluginAiApiServer } from '../plugin';
13
+ import { invalidateUserPermissionCache, resolveUserAccessScope } from '../utils/user-permissions';
14
+
15
+ /**
16
+ * Covers the cross-node half of permission revocation.
17
+ *
18
+ * syncMessageManager hardcodes `skipSelf: true` (sync-message-manager.ts:59,73), so the node
19
+ * that writes the change never receives its own broadcast. That makes two things load-bearing
20
+ * and easy to regress: the writer must invalidate its own cache locally, and every other node
21
+ * must invalidate on receipt. Neither is observable from a single-node test of the cache alone.
22
+ */
23
+ function mockContext(userId: number, row: unknown) {
24
+ const findOne = vi.fn(async () => row);
25
+ const ctx = {
26
+ state: { currentUser: { id: userId } },
27
+ db: { getRepository: () => ({ findOne }) },
28
+ app: { name: 'main' },
29
+ log: { warn: vi.fn(), error: vi.fn() },
30
+ } as unknown as Context;
31
+ return { ctx, findOne };
32
+ }
33
+
34
+ function row(values: Record<string, unknown>) {
35
+ return { get: (key: string) => values[key] };
36
+ }
37
+
38
+ beforeEach(() => {
39
+ invalidateUserPermissionCache();
40
+ });
41
+
42
+ describe('cross-node permission invalidation', () => {
43
+ it("drops the receiving node's cached scope", async () => {
44
+ const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
45
+ const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
46
+
47
+ await resolveUserAccessScope(ctx);
48
+ await resolveUserAccessScope(ctx);
49
+ expect(findOne).toHaveBeenCalledTimes(1);
50
+
51
+ await plugin.handleSyncMessage({ type: 'invalidateUserPermissions', userId: 1 });
52
+
53
+ await resolveUserAccessScope(ctx);
54
+ expect(findOne).toHaveBeenCalledTimes(2);
55
+ });
56
+
57
+ it('ignores unrelated message types and other users', async () => {
58
+ const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
59
+ const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
60
+ await resolveUserAccessScope(ctx);
61
+
62
+ await plugin.handleSyncMessage({ type: 'somethingElse', userId: 1 });
63
+ await plugin.handleSyncMessage({ type: 'invalidateUserPermissions', userId: 2 });
64
+
65
+ await resolveUserAccessScope(ctx);
66
+ expect(findOne).toHaveBeenCalledTimes(1);
67
+ });
68
+
69
+ it('tolerates a malformed message instead of throwing into the subscriber', async () => {
70
+ const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
71
+ await expect(plugin.handleSyncMessage(undefined as never)).resolves.toBeUndefined();
72
+ await expect(plugin.handleSyncMessage({} as never)).resolves.toBeUndefined();
73
+ });
74
+
75
+ it('invalidates locally as well as broadcasting, since the publisher is skipped', async () => {
76
+ const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
77
+ const sendSyncMessage = vi.fn(async () => undefined);
78
+ Object.assign(plugin, { sendSyncMessage });
79
+
80
+ const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
81
+ await resolveUserAccessScope(ctx);
82
+ expect(findOne).toHaveBeenCalledTimes(1);
83
+
84
+ // revokeUserPermissions is private; reach it the way the db hook does.
85
+ (plugin as unknown as { revokeUserPermissions: (id: unknown, tx?: unknown) => void }).revokeUserPermissions(1);
86
+
87
+ // Local cache cleared without any message coming back to us.
88
+ await resolveUserAccessScope(ctx);
89
+ expect(findOne).toHaveBeenCalledTimes(2);
90
+ expect(sendSyncMessage).toHaveBeenCalledWith(
91
+ { type: 'invalidateUserPermissions', userId: 1 },
92
+ { transaction: undefined },
93
+ );
94
+ });
95
+
96
+ it('defers the broadcast to the transaction so other nodes cannot re-cache the old row', async () => {
97
+ const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
98
+ const sendSyncMessage = vi.fn(async () => undefined);
99
+ Object.assign(plugin, { sendSyncMessage });
100
+ const transaction = { id: 'tx-1' };
101
+
102
+ (plugin as unknown as { revokeUserPermissions: (id: unknown, tx?: unknown) => void }).revokeUserPermissions(
103
+ 7,
104
+ transaction,
105
+ );
106
+
107
+ expect(sendSyncMessage).toHaveBeenCalledWith({ type: 'invalidateUserPermissions', userId: 7 }, { transaction });
108
+ });
109
+ });