plugin-ai-api 1.1.1 → 1.1.2

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 (108) hide show
  1. package/README.md +51 -12
  2. package/dist/client/185.c47663fefaeb0e5b.js +10 -0
  3. package/dist/client/562.9012cfd1fa04303d.js +10 -0
  4. package/dist/client/685.b5b1e0a5b825d253.js +10 -0
  5. package/dist/client/index.js +1 -1
  6. package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
  7. package/dist/client-v2/562.db2984167250b1be.js +10 -0
  8. package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
  9. package/dist/client-v2/index.js +1 -1
  10. package/dist/externalVersion.js +8 -8
  11. package/dist/locale/en-US.json +175 -139
  12. package/dist/locale/vi-VN.json +40 -2
  13. package/dist/locale/zh-CN.json +40 -2
  14. package/dist/server/collections/ai-api-model-metadata.js +26 -0
  15. package/dist/server/collections/ai-api-response-records.js +101 -0
  16. package/dist/server/collections/ai-api-virtual-models.js +68 -0
  17. package/dist/server/middleware/response-record-resource.js +66 -0
  18. package/dist/server/middleware/role-permission.js +43 -18
  19. package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
  20. package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
  21. package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
  22. package/dist/server/plugin.js +66 -16
  23. package/dist/server/routes/chat-completions.js +38 -6
  24. package/dist/server/routes/completions.js +16 -4
  25. package/dist/server/routes/embeddings.js +25 -6
  26. package/dist/server/routes/models.js +29 -0
  27. package/dist/server/routes/responses.js +530 -0
  28. package/dist/server/routes/router.js +65 -10
  29. package/dist/server/usage.js +25 -4
  30. package/dist/server/utils/direct-llm-context.js +1 -1
  31. package/dist/server/utils/resolve-service.js +24 -0
  32. package/dist/server/utils/response-store.js +138 -0
  33. package/dist/server/utils/responses-format.js +686 -0
  34. package/dist/server/utils/responses-stream.js +330 -0
  35. package/dist/server/utils/virtual-models.js +238 -0
  36. package/dist/server/validation.js +44 -2
  37. package/dist/swagger.js +137 -0
  38. package/package.json +34 -32
  39. package/src/__tests__/locale.test.ts +43 -0
  40. package/src/client/__tests__/settings-registration.test.tsx +1 -0
  41. package/src/client/plugin.tsx +9 -1
  42. package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
  43. package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
  44. package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
  45. package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
  46. package/src/client-v2/plugin.tsx +8 -0
  47. package/src/locale/en-US.json +175 -139
  48. package/src/locale/vi-VN.json +40 -2
  49. package/src/locale/zh-CN.json +40 -2
  50. package/src/server/__tests__/embeddings.test.ts +184 -0
  51. package/src/server/__tests__/models.test.ts +21 -1
  52. package/src/server/__tests__/response-record-resource.test.ts +50 -0
  53. package/src/server/__tests__/response-store-integration.test.ts +341 -0
  54. package/src/server/__tests__/response-store.test.ts +195 -0
  55. package/src/server/__tests__/responses-contract.test.ts +469 -0
  56. package/src/server/__tests__/responses-format.test.ts +299 -0
  57. package/src/server/__tests__/responses-router.test.ts +182 -0
  58. package/src/server/__tests__/responses-streaming.test.ts +368 -0
  59. package/src/server/__tests__/responses.test.ts +462 -0
  60. package/src/server/__tests__/role-permission.test.ts +139 -0
  61. package/src/server/__tests__/seed-role-permission.test.ts +88 -0
  62. package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
  63. package/src/server/__tests__/usage-groups.test.ts +96 -0
  64. package/src/server/__tests__/usage-route.test.ts +1 -0
  65. package/src/server/__tests__/usage.test.ts +14 -0
  66. package/src/server/__tests__/validation.test.ts +66 -7
  67. package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
  68. package/src/server/collections/ai-api-model-metadata.ts +26 -0
  69. package/src/server/collections/ai-api-response-records.ts +77 -0
  70. package/src/server/collections/ai-api-virtual-models.ts +58 -0
  71. package/src/server/middleware/response-record-resource.ts +44 -0
  72. package/src/server/middleware/role-permission.ts +69 -35
  73. package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
  74. package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
  75. package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
  76. package/src/server/plugin.ts +84 -20
  77. package/src/server/resource/ai-api-config.ts +2 -1
  78. package/src/server/routes/agent-completions.ts +3 -0
  79. package/src/server/routes/chat-completions.ts +34 -10
  80. package/src/server/routes/completions.ts +16 -4
  81. package/src/server/routes/embeddings.ts +32 -10
  82. package/src/server/routes/models.ts +34 -0
  83. package/src/server/routes/responses.ts +640 -0
  84. package/src/server/routes/router.ts +81 -12
  85. package/src/server/services/__tests__/file-processor.test.ts +1 -0
  86. package/src/server/usage.ts +29 -2
  87. package/src/server/utils/app-observability.ts +1 -1
  88. package/src/server/utils/direct-llm-context.ts +2 -1
  89. package/src/server/utils/openai-format.ts +1 -0
  90. package/src/server/utils/resolve-service.ts +39 -1
  91. package/src/server/utils/response-store.ts +148 -0
  92. package/src/server/utils/responses-format.ts +974 -0
  93. package/src/server/utils/responses-stream.ts +384 -0
  94. package/src/server/utils/virtual-models.ts +320 -0
  95. package/src/server/validation.ts +49 -0
  96. package/src/swagger.ts +139 -0
  97. package/dist/client/562.44b16aad4718b4c7.js +0 -10
  98. package/dist/client/685.ae483e17b6b49c98.js +0 -10
  99. package/dist/client-v2/562.45d5c504433be38b.js +0 -10
  100. package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
  101. package/dist/server/collections/ai-api-user-permissions.js +0 -67
  102. package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
  103. package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
  104. package/dist/server/resource/ai-api-usage-groups.js +0 -168
  105. package/src/server/collections/ai-api-user-permissions.ts +0 -46
  106. package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
  107. package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
  108. package/src/server/resource/ai-api-usage-groups.ts +0 -171
@@ -0,0 +1,462 @@
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 } from '@nocobase/actions';
11
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import type PluginAiApiServer from '../plugin';
13
+ import {
14
+ handleDeleteResponse,
15
+ handleGetResponse,
16
+ handleResponses,
17
+ RESPONSES_CAPABLE_PROVIDERS,
18
+ } from '../routes/responses';
19
+ import { resolveModelString } from '../utils/resolve-service';
20
+ import {
21
+ deleteResponseRecord,
22
+ getResponseRecord,
23
+ loadConversationChain,
24
+ storeResponseRecord,
25
+ } from '../utils/response-store';
26
+ import { prepareDirectLlmContext } from '../utils/direct-llm-context';
27
+
28
+ vi.mock('../utils/resolve-service', () => ({ resolveModelString: vi.fn(), resolveModelReference: vi.fn() }));
29
+ vi.mock('../utils/virtual-models', () => ({ resolveVirtualModel: vi.fn().mockResolvedValue(null) }));
30
+ vi.mock('../utils/user-permissions', () => ({ enforceModelAccess: vi.fn().mockResolvedValue(true) }));
31
+ vi.mock('../utils/request-cache', () => ({ getAiApiConfig: vi.fn().mockResolvedValue({ enabledLlmServices: [] }) }));
32
+ vi.mock('../utils/response-store', () => ({
33
+ deleteResponseRecord: vi.fn().mockResolvedValue(true),
34
+ getResponseRecord: vi.fn(),
35
+ loadConversationChain: vi.fn().mockResolvedValue([]),
36
+ storeResponseRecord: vi.fn().mockResolvedValue(undefined),
37
+ }));
38
+ vi.mock('../billing', () => ({
39
+ AiApiQuotaError: class extends Error {
40
+ code = 'quota_exceeded';
41
+ },
42
+ markLlmProviderAttempted: vi.fn(),
43
+ prepareLlmBilling: vi.fn().mockResolvedValue(undefined),
44
+ }));
45
+ vi.mock('../utils/direct-llm-context', async (importOriginal) => {
46
+ const original = await importOriginal<typeof import('../utils/direct-llm-context')>();
47
+ return {
48
+ ...original,
49
+ prepareDirectLlmContext: vi.fn().mockImplementation(async (_ctx, options) => ({
50
+ messages: options.messages,
51
+ estimatedInputTokens: 10,
52
+ inputTokenBudget: 1000,
53
+ reservedOutputTokens: 100,
54
+ truncated: false,
55
+ })),
56
+ };
57
+ });
58
+ vi.mock('../usage', async (importOriginal) => {
59
+ const original = await importOriginal<typeof import('../usage')>();
60
+ return {
61
+ ...original,
62
+ extractProviderRequestId: vi.fn().mockReturnValue(undefined),
63
+ setAiApiUsageResult: vi.fn().mockReturnValue({
64
+ prompt_tokens: 10,
65
+ completion_tokens: 5,
66
+ total_tokens: 15,
67
+ prompt_cache_tokens: 2,
68
+ }),
69
+ };
70
+ });
71
+
72
+ function serviceModel(provider = 'test-provider') {
73
+ const values: Record<string, unknown> = {
74
+ name: 'test-service',
75
+ title: 'Test Service',
76
+ enabled: true,
77
+ provider,
78
+ options: {},
79
+ };
80
+ return { get: (key: string) => values[key] };
81
+ }
82
+
83
+ function createContext(requestBody: Record<string, unknown>, providerKey = 'test-provider') {
84
+ const model = {
85
+ invoke: vi.fn().mockResolvedValue({
86
+ content: 'Hello from Responses',
87
+ usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
88
+ }),
89
+ modelKwargs: {},
90
+ bindTools: vi.fn(function (this: unknown) {
91
+ return this;
92
+ }),
93
+ };
94
+ class TestProvider {
95
+ createModel() {
96
+ return model;
97
+ }
98
+ }
99
+
100
+ const ctx = {
101
+ app: {
102
+ pm: {
103
+ get: vi.fn().mockReturnValue({
104
+ aiManager: { llmProviders: new Map([[providerKey, { provider: TestProvider }]]) },
105
+ }),
106
+ },
107
+ },
108
+ request: { body: requestBody },
109
+ state: { currentUser: { id: 42 } } as Record<string, unknown>,
110
+ log: { error: vi.fn(), warn: vi.fn() },
111
+ set: vi.fn(),
112
+ status: 0,
113
+ body: undefined,
114
+ res: { headersSent: false },
115
+ } as unknown as Context;
116
+ return { ctx, model };
117
+ }
118
+
119
+ beforeEach(() => {
120
+ vi.clearAllMocks();
121
+ vi.mocked(resolveModelString).mockResolvedValue({
122
+ service: serviceModel() as never,
123
+ modelId: 'test-model',
124
+ });
125
+ vi.mocked(loadConversationChain).mockResolvedValue([]);
126
+ });
127
+
128
+ describe('handleResponses', () => {
129
+ it('returns an OpenAI Responses object and stores it by default', async () => {
130
+ const { ctx } = createContext({ model: 'test-service/test-model', input: 'Hello' });
131
+
132
+ await handleResponses(ctx, {} as PluginAiApiServer);
133
+
134
+ expect(ctx.status).toBe(200);
135
+ expect(ctx.body).toMatchObject({
136
+ object: 'response',
137
+ status: 'completed',
138
+ output_text: 'Hello from Responses',
139
+ usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
140
+ output: [
141
+ {
142
+ type: 'message',
143
+ role: 'assistant',
144
+ content: [{ type: 'output_text', text: 'Hello from Responses', annotations: [] }],
145
+ },
146
+ ],
147
+ });
148
+ expect(storeResponseRecord).toHaveBeenCalledWith(ctx, ctx.body, expect.any(Object), 42);
149
+ });
150
+
151
+ it('does not store when store=false', async () => {
152
+ const { ctx } = createContext({ model: 'test-service/test-model', input: 'Hello', store: false });
153
+
154
+ await handleResponses(ctx, {} as PluginAiApiServer);
155
+
156
+ expect(ctx.status).toBe(200);
157
+ expect(storeResponseRecord).not.toHaveBeenCalled();
158
+ });
159
+
160
+ it('forwards store=false to the upstream provider so native Responses backends do not persist', async () => {
161
+ vi.mocked(resolveModelString).mockResolvedValueOnce({
162
+ service: serviceModel('openai') as never,
163
+ modelId: 'test-model',
164
+ });
165
+ const { ctx, model } = createContext({ model: 'test-service/test-model', input: 'Hello', store: false }, 'openai');
166
+
167
+ await handleResponses(ctx, {} as PluginAiApiServer);
168
+
169
+ expect(model.modelKwargs).toMatchObject({ store: false });
170
+ expect(storeResponseRecord).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it('does not forward store when omitted or true so non-Responses backends keep their defaults', async () => {
174
+ const { ctx: omitCtx, model: omitModel } = createContext({ model: 'test-service/test-model', input: 'Hello' });
175
+ const { ctx: trueCtx, model: trueModel } = createContext({
176
+ model: 'test-service/test-model',
177
+ input: 'Hello',
178
+ store: true,
179
+ });
180
+
181
+ await handleResponses(omitCtx, {} as PluginAiApiServer);
182
+ await handleResponses(trueCtx, {} as PluginAiApiServer);
183
+
184
+ expect(omitModel.modelKwargs).not.toHaveProperty('store');
185
+ expect(trueModel.modelKwargs).not.toHaveProperty('store');
186
+ });
187
+
188
+ it('forwards store=false only to Responses-capable providers, never to Chat Completions providers', async () => {
189
+ // Responses-capable provider (registered as 'openai') must receive store=false upstream.
190
+ vi.mocked(resolveModelString).mockResolvedValueOnce({
191
+ service: serviceModel('openai') as never,
192
+ modelId: 'gpt-4o',
193
+ });
194
+ const { ctx: respCtx, model: respModel } = createContext(
195
+ { model: 'test-service/gpt-4o', input: 'Hello', store: false },
196
+ 'openai',
197
+ );
198
+ await handleResponses(respCtx, {} as PluginAiApiServer);
199
+ expect(respModel.modelKwargs).toMatchObject({ store: false });
200
+
201
+ // Chat Completions providers receive modelKwargs verbatim in the /chat/completions payload,
202
+ // where the unknown `store` field would be rejected — so it must not be forwarded.
203
+ // Expectations are derived from the exported provider classification so this test stays
204
+ // correct when the implementation adds or reclassifies a Responses-capable provider.
205
+ const ALL_PROVIDERS = [
206
+ 'openai',
207
+ 'openai-completions',
208
+ 'dashscope',
209
+ 'kimi',
210
+ 'deepseek',
211
+ 'google-genai',
212
+ 'ollama',
213
+ 'anthropic',
214
+ ];
215
+ for (const providerKey of ALL_PROVIDERS) {
216
+ const shouldForward = RESPONSES_CAPABLE_PROVIDERS.has(providerKey);
217
+ vi.mocked(resolveModelString).mockResolvedValueOnce({
218
+ service: serviceModel(providerKey) as never,
219
+ modelId: 'some-model',
220
+ });
221
+ const { ctx, model } = createContext(
222
+ { model: 'test-service/some-model', input: 'Hello', store: false },
223
+ providerKey,
224
+ );
225
+ await handleResponses(ctx, {} as PluginAiApiServer);
226
+ if (shouldForward) {
227
+ expect(model.modelKwargs).toMatchObject({ store: false });
228
+ } else {
229
+ expect(model.modelKwargs).not.toHaveProperty('store');
230
+ }
231
+ }
232
+ });
233
+
234
+ it('returns 500 for non-streaming requests when response persistence fails', async () => {
235
+ vi.mocked(storeResponseRecord).mockRejectedValueOnce(new Error('database unavailable'));
236
+ const { ctx } = createContext({ model: 'test-service/test-model', input: 'Hello', store: true });
237
+
238
+ await handleResponses(ctx, {} as PluginAiApiServer);
239
+
240
+ // Non-streaming clients asked for a stored response; a partial success (unstored 200) would
241
+ // silently break previous_response_id chaining, so the request must fail instead.
242
+ expect(ctx.status).toBe(500);
243
+ expect(ctx.body).toMatchObject({ error: { type: 'server_error' } });
244
+ });
245
+
246
+ it('prepends an owner-scoped previous response chain', async () => {
247
+ vi.mocked(loadConversationChain).mockResolvedValue([
248
+ { role: 'user', content: 'My name is Nam' },
249
+ { role: 'assistant', content: 'Nice to meet you, Nam' },
250
+ ]);
251
+ const { ctx, model } = createContext({
252
+ model: 'test-service/test-model',
253
+ input: 'What is my name?',
254
+ previous_response_id: 'resp_previous',
255
+ });
256
+
257
+ await handleResponses(ctx, {} as PluginAiApiServer);
258
+
259
+ expect(loadConversationChain).toHaveBeenCalledWith(ctx, 'resp_previous', 42);
260
+ expect(model.invoke.mock.calls[0][0]).toHaveLength(3);
261
+ });
262
+
263
+ it('returns 404 when previous_response_id is missing, expired, or belongs to another user', async () => {
264
+ vi.mocked(loadConversationChain).mockResolvedValue(null);
265
+ const { ctx, model } = createContext({
266
+ model: 'test-service/test-model',
267
+ input: 'Hello',
268
+ previous_response_id: 'resp_private',
269
+ });
270
+
271
+ await handleResponses(ctx, {} as PluginAiApiServer);
272
+
273
+ expect(ctx.status).toBe(404);
274
+ expect(ctx.body).toMatchObject({ error: { code: 'previous_response_not_found' } });
275
+ expect(model.invoke).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it('rejects OpenAI file IDs because this gateway has no OpenAI file store', async () => {
279
+ const { ctx, model } = createContext({
280
+ model: 'test-service/test-model',
281
+ input: [
282
+ {
283
+ type: 'message',
284
+ role: 'user',
285
+ content: [{ type: 'input_file', file_id: 'file_private' }],
286
+ },
287
+ ],
288
+ });
289
+
290
+ await handleResponses(ctx, {} as PluginAiApiServer);
291
+
292
+ expect(ctx.status).toBe(400);
293
+ expect(ctx.body).toMatchObject({ error: { code: 'unsupported_input' } });
294
+ expect(model.invoke).not.toHaveBeenCalled();
295
+ });
296
+ it('rejects unsupported built-in tools instead of silently forwarding them', async () => {
297
+ const { ctx, model } = createContext({
298
+ model: 'test-service/test-model',
299
+ input: 'Search the web',
300
+ tools: [{ type: 'web_search' }],
301
+ });
302
+
303
+ await handleResponses(ctx, {} as PluginAiApiServer);
304
+
305
+ expect(ctx.status).toBe(400);
306
+ expect(ctx.body).toMatchObject({ error: { code: 'unsupported_tool' } });
307
+ expect(model.invoke).not.toHaveBeenCalled();
308
+ });
309
+
310
+ it('validates service_tier values', async () => {
311
+ const { ctx } = createContext({
312
+ model: 'test-service/test-model',
313
+ input: 'Hello',
314
+ service_tier: 'flexible',
315
+ });
316
+
317
+ await handleResponses(ctx, {} as PluginAiApiServer);
318
+
319
+ expect(ctx.status).toBe(400);
320
+ expect(ctx.body).toMatchObject({ error: { code: 'invalid_service_tier' } });
321
+ });
322
+
323
+ it.each([
324
+ [{ input: [{ type: 'message', role: 'user', content: [{ type: 'input_audio' }] }] }, 'invalid_input'],
325
+ [{ tools: { type: 'function' } }, 'unsupported_tool'],
326
+ [{ tool_choice: { type: 'function' } }, 'invalid_tool_choice'],
327
+ [{ metadata: { key: 42 } }, 'invalid_metadata'],
328
+ [{ background: true }, 'unsupported_parameter'],
329
+ [{ prompt_cache_retention: '7d' }, 'invalid_parameter'],
330
+ [{ prompt_cache_key: null }, 'invalid_parameter'],
331
+ [{ safety_identifier: 'x'.repeat(65) }, 'invalid_parameter'],
332
+ [{ text: { format: { type: 'json_schema', name: '', schema: {} } } }, 'invalid_parameter'],
333
+ ])('rejects malformed Responses fields before provider invocation', async (override, code) => {
334
+ const { ctx, model } = createContext({
335
+ model: 'test-service/test-model',
336
+ input: 'Hello',
337
+ ...override,
338
+ });
339
+
340
+ await handleResponses(ctx, {} as PluginAiApiServer);
341
+
342
+ expect(ctx.status).toBe(400);
343
+ expect(ctx.body).toMatchObject({ error: { code } });
344
+ expect(model.invoke).not.toHaveBeenCalled();
345
+ });
346
+
347
+ it('maps Responses function tools to Chat Completions tool format', async () => {
348
+ const { ctx, model } = createContext({
349
+ model: 'test-service/test-model',
350
+ input: 'Weather?',
351
+ tools: [
352
+ {
353
+ type: 'function',
354
+ name: 'get_weather',
355
+ description: 'Get weather',
356
+ parameters: { type: 'object', properties: { city: { type: 'string' } } },
357
+ },
358
+ ],
359
+ });
360
+
361
+ await handleResponses(ctx, {} as PluginAiApiServer);
362
+
363
+ expect(ctx.status).toBe(200);
364
+ expect(model.bindTools).toHaveBeenCalledWith(
365
+ [expect.objectContaining({ type: 'function', function: expect.objectContaining({ name: 'get_weather' }) })],
366
+ expect.any(Object),
367
+ );
368
+ });
369
+
370
+ it('passes LangChain call options without leaking camelCase names into provider model kwargs', async () => {
371
+ const { ctx, model } = createContext({
372
+ model: 'test-service/test-model',
373
+ input: 'Hello',
374
+ prompt_cache_key: 'cache-key',
375
+ prompt_cache_retention: '24h',
376
+ safety_identifier: 'hashed-user',
377
+ reasoning: { effort: 'medium' },
378
+ text: { verbosity: 'low' },
379
+ });
380
+
381
+ await handleResponses(ctx, {} as PluginAiApiServer);
382
+
383
+ expect(model.invoke).toHaveBeenCalledWith(
384
+ expect.anything(),
385
+ expect.objectContaining({
386
+ promptCacheKey: 'cache-key',
387
+ promptCacheRetention: '24h',
388
+ reasoning: { effort: 'medium' },
389
+ verbosity: 'low',
390
+ }),
391
+ );
392
+ expect(model.modelKwargs).toMatchObject({ safety_identifier: 'hashed-user' });
393
+ expect(model.modelKwargs).not.toHaveProperty('promptCacheKey');
394
+ expect(model.modelKwargs).not.toHaveProperty('promptCacheRetention');
395
+ });
396
+
397
+ it.each([
398
+ ['auto', 'truncate'],
399
+ ['disabled', 'reject'],
400
+ ])('maps truncation=%s to the direct-context overflow policy', async (truncation, overflowBehavior) => {
401
+ const { ctx } = createContext({ model: 'test-service/test-model', input: 'Hello', truncation });
402
+
403
+ await handleResponses(ctx, {} as PluginAiApiServer);
404
+
405
+ expect(prepareDirectLlmContext).toHaveBeenCalledWith(ctx, expect.objectContaining({ overflowBehavior }));
406
+ });
407
+ });
408
+ describe('stored Responses API handlers', () => {
409
+ it('retrieves a stored response for its owner', async () => {
410
+ const { ctx } = createContext({});
411
+ vi.mocked(getResponseRecord).mockResolvedValue({
412
+ id: 1,
413
+ responseId: 'resp_saved',
414
+ userId: 42,
415
+ model: 'service/model',
416
+ input: 'Hello',
417
+ output: { id: 'resp_saved', object: 'response' } as never,
418
+ expiresAt: new Date(Date.now() + 1000),
419
+ });
420
+
421
+ await handleGetResponse(ctx, 'resp_saved');
422
+
423
+ expect(getResponseRecord).toHaveBeenCalledWith(ctx, 'resp_saved', 42);
424
+ expect(ctx.status).toBe(200);
425
+ expect(ctx.body).toEqual({ id: 'resp_saved', object: 'response' });
426
+ });
427
+
428
+ it.each([{ stream: 'true' }, { include: 'reasoning.encrypted_content' }, { starting_after: '10' }])(
429
+ 'rejects unsupported retrieve query parameters: %o',
430
+ async (query) => {
431
+ const { ctx } = createContext({});
432
+ ctx.query = query;
433
+
434
+ await handleGetResponse(ctx, 'resp_saved');
435
+
436
+ expect(ctx.status).toBe(400);
437
+ expect(ctx.body).toMatchObject({ error: { code: 'unsupported_parameter' } });
438
+ expect(getResponseRecord).not.toHaveBeenCalled();
439
+ },
440
+ );
441
+
442
+ it('returns 404 without disclosing a response owned by another user', async () => {
443
+ const { ctx } = createContext({});
444
+ vi.mocked(getResponseRecord).mockResolvedValue(null);
445
+
446
+ await handleGetResponse(ctx, 'resp_private');
447
+
448
+ expect(ctx.status).toBe(404);
449
+ expect(ctx.body).toMatchObject({ error: { code: 'response_not_found' } });
450
+ });
451
+
452
+ it('deletes only an owner-scoped stored response', async () => {
453
+ const { ctx } = createContext({});
454
+ vi.mocked(deleteResponseRecord).mockResolvedValue(true);
455
+
456
+ await handleDeleteResponse(ctx, 'resp_saved');
457
+
458
+ expect(deleteResponseRecord).toHaveBeenCalledWith(ctx, 'resp_saved', 42);
459
+ expect(ctx.status).toBe(204);
460
+ expect(ctx.body).toBeUndefined();
461
+ });
462
+ });
@@ -0,0 +1,139 @@
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 } from '@nocobase/actions';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import { checkEmployeeAccess, checkRolePermission, invalidateRolePermissionCache } from '../middleware/role-permission';
13
+
14
+ function makeCtx(roleNames: string[], permissionsByRole: Record<string, Record<string, unknown> | null> = {}) {
15
+ const findOne = vi
16
+ .fn()
17
+ .mockImplementation(({ filter }: { filter: { roleName: string } }) =>
18
+ Promise.resolve(permissionsByRole[filter.roleName] ?? null),
19
+ );
20
+ const ctx: Partial<Context> = {
21
+ app: { name: 'main' } as Context['app'],
22
+ state: { currentRoles: roleNames },
23
+ db: { getRepository: () => ({ findOne }) } as unknown as Context['db'],
24
+ };
25
+ return { ctx: ctx as Context, findOne };
26
+ }
27
+
28
+ describe('checkRolePermission', () => {
29
+ it('denies root without a permission record (no built-in bypass)', async () => {
30
+ invalidateRolePermissionCache('root');
31
+ const { ctx } = makeCtx(['root']);
32
+ await expect(checkRolePermission(ctx)).resolves.toBe(false);
33
+ expect(ctx.status).toBe(403);
34
+ });
35
+
36
+ it('allows root when an enabled permission record exists', async () => {
37
+ invalidateRolePermissionCache('root');
38
+ const { ctx } = makeCtx(['root'], { root: { roleName: 'root', enabled: true, allowAllEmployees: true } });
39
+ await expect(checkRolePermission(ctx)).resolves.toBe(true);
40
+ expect(ctx.state.aiApiRolePermissions).toHaveLength(1);
41
+ });
42
+
43
+ it('denies a role when the permission record is disabled', async () => {
44
+ invalidateRolePermissionCache('admin');
45
+ const { ctx } = makeCtx(['admin'], { admin: { roleName: 'admin', enabled: false } });
46
+ await expect(checkRolePermission(ctx)).resolves.toBe(false);
47
+ expect(ctx.status).toBe(403);
48
+ });
49
+
50
+ it('allows a multi-role user when any role is enabled (union semantics)', async () => {
51
+ invalidateRolePermissionCache();
52
+ const { ctx } = makeCtx(['member', 'operator'], {
53
+ member: null,
54
+ operator: { roleName: 'operator', enabled: true, allowAllEmployees: true },
55
+ });
56
+ await expect(checkRolePermission(ctx)).resolves.toBe(true);
57
+ const records = ctx.state.aiApiRolePermissions as Array<{ roleName: string }>;
58
+ expect(records.map((r) => r.roleName)).toEqual(['operator']);
59
+ });
60
+
61
+ it('collects every enabled role for downstream union checks', async () => {
62
+ invalidateRolePermissionCache();
63
+ const { ctx } = makeCtx(['alpha', 'beta'], {
64
+ alpha: { roleName: 'alpha', enabled: true, allowAllEmployees: false, allowedEmployees: ['a'] },
65
+ beta: { roleName: 'beta', enabled: true, allowAllEmployees: false, allowedEmployees: ['b'] },
66
+ });
67
+ await expect(checkRolePermission(ctx)).resolves.toBe(true);
68
+ expect(ctx.state.aiApiRolePermissions).toHaveLength(2);
69
+ });
70
+
71
+ it('does not share cached permissions between applications', async () => {
72
+ invalidateRolePermissionCache();
73
+ const { ctx: first } = makeCtx(['member'], { member: { roleName: 'member', enabled: true } });
74
+ const { ctx: second, findOne } = makeCtx(['member'], { member: null });
75
+ second.app.name = 'second';
76
+
77
+ await expect(checkRolePermission(first)).resolves.toBe(true);
78
+ await expect(checkRolePermission(second)).resolves.toBe(false);
79
+ expect(findOne).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it('re-reads the record after invalidateRolePermissionCache (what the sync message triggers)', async () => {
83
+ // Simulate a role being revoked on another node: the sync message handler calls
84
+ // invalidateRolePermissionCache(roleName), so the next check must re-read the DB.
85
+ invalidateRolePermissionCache('admin');
86
+ const { ctx, findOne } = makeCtx(['admin'], { admin: { roleName: 'admin', enabled: true } });
87
+ await expect(checkRolePermission(ctx)).resolves.toBe(true);
88
+ expect(findOne).toHaveBeenCalledTimes(1);
89
+
90
+ // The DB now returns a disabled record (revoked on another node).
91
+ findOne.mockImplementation(({ filter }: { filter: { roleName: string } }) =>
92
+ Promise.resolve(filter.roleName === 'admin' ? { roleName: 'admin', enabled: false } : null),
93
+ );
94
+
95
+ // Without invalidation the cached enabled record would still be served.
96
+ await expect(checkRolePermission(ctx)).resolves.toBe(true);
97
+ expect(findOne).toHaveBeenCalledTimes(1);
98
+
99
+ // After invalidation the next check re-reads and sees the revocation.
100
+ invalidateRolePermissionCache('admin');
101
+ await expect(checkRolePermission(ctx)).resolves.toBe(false);
102
+ expect(findOne).toHaveBeenCalledTimes(2);
103
+ });
104
+ });
105
+
106
+ describe('checkEmployeeAccess', () => {
107
+ it('allows when any role has allowAllEmployees', () => {
108
+ const ctx = {
109
+ state: { aiApiRolePermissions: [{ allowAllEmployees: true }] },
110
+ } as unknown as Context;
111
+ expect(checkEmployeeAccess(ctx, 'any-employee')).toBe(true);
112
+ });
113
+
114
+ it('denies when no permission records are stored', () => {
115
+ const ctx = { state: {} } as unknown as Context;
116
+ expect(checkEmployeeAccess(ctx, 'any-employee')).toBe(false);
117
+ });
118
+
119
+ it('allows an employee listed in any role (union of allowedEmployees)', () => {
120
+ const ctx = {
121
+ state: {
122
+ aiApiRolePermissions: [
123
+ { allowAllEmployees: false, allowedEmployees: ['alice'] },
124
+ { allowAllEmployees: false, allowedEmployees: ['bob'] },
125
+ ],
126
+ },
127
+ } as unknown as Context;
128
+ expect(checkEmployeeAccess(ctx, 'alice')).toBe(true);
129
+ expect(checkEmployeeAccess(ctx, 'bob')).toBe(true);
130
+ expect(checkEmployeeAccess(ctx, 'carol')).toBe(false);
131
+ });
132
+
133
+ it('denies when no role grants the employee', () => {
134
+ const ctx = {
135
+ state: { aiApiRolePermissions: [{ allowAllEmployees: false, allowedEmployees: ['alice'] }] },
136
+ } as unknown as Context;
137
+ expect(checkEmployeeAccess(ctx, 'bob')).toBe(false);
138
+ });
139
+ });
@@ -0,0 +1,88 @@
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 { createMockDatabase, type Database } from '@nocobase/database';
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import rolePermissionsCollection from '../collections/ai-api-role-permissions';
13
+ import SeedDefaultRolePermissions from '../migrations/20260902000000-seed-default-role-permissions';
14
+
15
+ describe('SeedDefaultRolePermissions migration', () => {
16
+ let db: Database;
17
+
18
+ beforeEach(async () => {
19
+ db = await createMockDatabase();
20
+ db.collection(rolePermissionsCollection);
21
+ await db.sync({ force: true });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await db.close();
26
+ });
27
+
28
+ function makeMigration() {
29
+ const app = { logger: { info: vi.fn(), warn: vi.fn() } };
30
+ const MigrationClass = SeedDefaultRolePermissions as unknown as new (context: { db: Database; app: unknown }) => {
31
+ up: () => Promise<void>;
32
+ down: () => Promise<void>;
33
+ };
34
+ return { migration: new MigrationClass({ db, app }), app, repository: db.getRepository('aiApiRolePermissions') };
35
+ }
36
+
37
+ it('seeds enabled permission rows for both root and admin', async () => {
38
+ const { migration, repository } = makeMigration();
39
+
40
+ await migration.up();
41
+
42
+ for (const roleName of ['root', 'admin']) {
43
+ const row = await repository.findOne({ filter: { roleName } });
44
+ expect(row, `expected a seeded row for ${roleName}`).not.toBeNull();
45
+ expect(row?.get('enabled')).toBe(true);
46
+ expect(row?.get('allowAllEmployees')).toBe(true);
47
+ expect(row?.get('allowedEmployees')).toEqual([]);
48
+ }
49
+ expect(await repository.count()).toBe(2);
50
+ });
51
+
52
+ it('is idempotent — rerunning does not duplicate rows', async () => {
53
+ const { migration, repository } = makeMigration();
54
+
55
+ await migration.up();
56
+ await migration.up();
57
+
58
+ expect(await repository.count()).toBe(2);
59
+ });
60
+
61
+ it('leaves a pre-existing admin row untouched and still seeds root', async () => {
62
+ const { migration, repository } = makeMigration();
63
+ await repository.create({
64
+ values: { roleName: 'admin', enabled: false, allowAllEmployees: false, allowedEmployees: ['employee-a'] },
65
+ });
66
+
67
+ await migration.up();
68
+
69
+ const adminRow = await repository.findOne({ filter: { roleName: 'admin' } });
70
+ expect(adminRow?.get('enabled')).toBe(false);
71
+ expect(adminRow?.get('allowAllEmployees')).toBe(false);
72
+ expect(adminRow?.get('allowedEmployees')).toEqual(['employee-a']);
73
+
74
+ const rootRow = await repository.findOne({ filter: { roleName: 'root' } });
75
+ expect(rootRow?.get('enabled')).toBe(true);
76
+ expect(rootRow?.get('allowAllEmployees')).toBe(true);
77
+ expect(await repository.count()).toBe(2);
78
+ });
79
+
80
+ it('down() keeps the rows so a rollback does not lock root/admin out', async () => {
81
+ const { migration, repository } = makeMigration();
82
+
83
+ await migration.up();
84
+ await migration.down();
85
+
86
+ expect(await repository.count()).toBe(2);
87
+ });
88
+ });