plugin-ai-api 1.0.24 → 1.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/client/{286.01c0e3c5fff3cccb.js → 286.a1ee0420172cd5de.js} +1 -1
  2. package/dist/client/302.fbc46ebf5bf300d7.js +10 -0
  3. package/dist/client/562.44b16aad4718b4c7.js +10 -0
  4. package/dist/client/685.ae483e17b6b49c98.js +10 -0
  5. package/dist/client/757.6568d3504ad29352.js +10 -0
  6. package/dist/client/{97.72979a11a067a7c9.js → 97.9b6b2d2b01a4c060.js} +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.3971233415999b2c.js +10 -0
  9. package/dist/client-v2/562.45d5c504433be38b.js +10 -0
  10. package/dist/client-v2/685.1030370b309b7d4b.js +10 -0
  11. package/dist/client-v2/757.f2bc9cfba07004b0.js +10 -0
  12. package/dist/client-v2/{952.94100128b7757f56.js → 952.f0249eddc153bde1.js} +1 -1
  13. package/dist/client-v2/{97.29c663318eebbd57.js → 97.36a42eff36bb3d8a.js} +1 -1
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +2 -5
  16. package/dist/externalVersion.js +8 -8
  17. package/dist/locale/en-US.json +27 -8
  18. package/dist/locale/vi-VN.json +27 -8
  19. package/dist/locale/zh-CN.json +27 -8
  20. package/dist/server/billing.js +31 -33
  21. package/dist/server/collections/ai-api-config.js +7 -7
  22. package/dist/server/collections/ai-api-group-members.js +62 -0
  23. package/dist/server/collections/ai-api-group-quota-buckets.js +63 -0
  24. package/dist/server/collections/ai-api-model-metadata.js +6 -0
  25. package/dist/server/collections/ai-api-usage-groups.js +74 -0
  26. package/dist/server/collections/ai-api-usage-records.js +2 -0
  27. package/dist/server/middleware/rate-limit.js +7 -6
  28. package/dist/server/migrations/20260813000000-add-prompt-cache-tokens.js +69 -0
  29. package/dist/server/migrations/20260815000000-add-usage-groups.js +149 -0
  30. package/dist/server/migrations/20260816000000-migrate-user-permissions-to-groups.js +169 -0
  31. package/dist/server/migrations/20260816100000-add-model-metadata-system-prompt.js +69 -0
  32. package/dist/server/plugin.js +100 -22
  33. package/dist/server/quota-groups.js +108 -0
  34. package/dist/server/resource/ai-api-config.js +5 -3
  35. package/dist/server/resource/ai-api-usage-groups.js +168 -0
  36. package/dist/server/resource/ai-api-usage-monitor.js +3 -1
  37. package/dist/server/routes/agent-completions.js +2 -1
  38. package/dist/server/routes/chat-completions.js +121 -42
  39. package/dist/server/routes/completions.js +48 -29
  40. package/dist/server/routes/embeddings.js +2 -1
  41. package/dist/server/routes/models.js +2 -1
  42. package/dist/server/routes/router.js +3 -2
  43. package/dist/server/services/file-processor.js +426 -0
  44. package/dist/server/usage.js +37 -3
  45. package/dist/server/utils/direct-llm-context.js +163 -26
  46. package/dist/server/utils/openai-format.js +21 -2
  47. package/dist/server/utils/rate-limiter.js +1 -1
  48. package/dist/server/utils/request-cache.js +61 -0
  49. package/dist/server/utils/resolve-service.js +2 -1
  50. package/dist/server/utils/user-permissions.js +25 -39
  51. package/dist/server/validation.js +7 -0
  52. package/dist/swagger.js +48 -10
  53. package/package.json +1 -1
  54. package/src/client/__tests__/settings-registration.test.tsx +6 -29
  55. package/src/client/plugin.tsx +5 -16
  56. package/src/client-v2/__tests__/settings-registration.test.tsx +6 -32
  57. package/src/client-v2/locale.ts +3 -1
  58. package/src/client-v2/pages/GeneralPage.tsx +0 -5
  59. package/src/client-v2/pages/ModelMetadataPage.tsx +20 -1
  60. package/src/client-v2/pages/UsageGroupsPage.tsx +548 -0
  61. package/src/client-v2/pages/UsagePage.tsx +9 -0
  62. package/src/client-v2/plugin.tsx +4 -13
  63. package/src/constants.ts +0 -7
  64. package/src/locale/en-US.json +27 -8
  65. package/src/locale/vi-VN.json +27 -8
  66. package/src/locale/zh-CN.json +27 -8
  67. package/src/server/__tests__/billing-quota.test.ts +28 -9
  68. package/src/server/__tests__/direct-llm-context.test.ts +209 -10
  69. package/src/server/__tests__/file-processor.test.ts +225 -0
  70. package/src/server/__tests__/models.test.ts +1 -1
  71. package/src/server/__tests__/openai-format.test.ts +12 -2
  72. package/src/server/__tests__/permission-sync.test.ts +34 -35
  73. package/src/server/__tests__/request-body.test.ts +45 -2
  74. package/src/server/__tests__/usage-groups.test.ts +160 -0
  75. package/src/server/__tests__/usage-monitor.test.ts +2 -0
  76. package/src/server/__tests__/usage-route.test.ts +382 -5
  77. package/src/server/__tests__/usage.test.ts +57 -0
  78. package/src/server/__tests__/user-permissions.test.ts +214 -133
  79. package/src/server/__tests__/validation.test.ts +11 -0
  80. package/src/server/billing.ts +36 -39
  81. package/src/server/collections/ai-api-config.ts +9 -7
  82. package/src/server/collections/ai-api-group-members.ts +41 -0
  83. package/src/server/collections/ai-api-group-quota-buckets.ts +42 -0
  84. package/src/server/collections/ai-api-model-metadata.ts +7 -0
  85. package/src/server/collections/ai-api-role-permissions.ts +41 -41
  86. package/src/server/collections/ai-api-usage-groups.ts +53 -0
  87. package/src/server/collections/ai-api-usage-records.ts +2 -0
  88. package/src/server/index.ts +10 -10
  89. package/src/server/middleware/rate-limit.ts +68 -70
  90. package/src/server/migrations/20260813000000-add-prompt-cache-tokens.ts +46 -0
  91. package/src/server/migrations/20260815000000-add-usage-groups.ts +147 -0
  92. package/src/server/migrations/20260816000000-migrate-user-permissions-to-groups.ts +190 -0
  93. package/src/server/migrations/20260816100000-add-model-metadata-system-prompt.ts +46 -0
  94. package/src/server/plugin.ts +121 -30
  95. package/src/server/quota-groups.ts +117 -0
  96. package/src/server/resource/ai-api-config.ts +5 -3
  97. package/src/server/resource/ai-api-usage-groups.ts +171 -0
  98. package/src/server/resource/ai-api-usage-monitor.ts +3 -0
  99. package/src/server/routes/agent-completions.ts +2 -1
  100. package/src/server/routes/chat-completions.ts +173 -47
  101. package/src/server/routes/completions.ts +50 -27
  102. package/src/server/routes/embeddings.ts +2 -1
  103. package/src/server/routes/models.ts +4 -3
  104. package/src/server/routes/router.ts +4 -3
  105. package/src/server/services/__tests__/file-processor.test.ts +184 -0
  106. package/src/server/services/file-processor.ts +513 -0
  107. package/src/server/usage.ts +51 -1
  108. package/src/server/utils/direct-llm-context.ts +218 -31
  109. package/src/server/utils/openai-format.ts +25 -2
  110. package/src/server/utils/rate-limiter.ts +83 -83
  111. package/src/server/utils/request-cache.ts +59 -0
  112. package/src/server/utils/resolve-service.ts +83 -82
  113. package/src/server/utils/user-permissions.ts +49 -69
  114. package/src/server/validation.ts +7 -0
  115. package/src/swagger.ts +52 -11
  116. package/dist/client/123.e6fe04c856ce6417.js +0 -10
  117. package/dist/client/302.fc3a3491b4ec2dfd.js +0 -10
  118. package/dist/client/562.17a0a299d2e5152c.js +0 -10
  119. package/dist/client/757.a01403fb7a1bea01.js +0 -10
  120. package/dist/client/902.e74518750f1e4201.js +0 -10
  121. package/dist/client-v2/123.05f1f649923f93eb.js +0 -10
  122. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +0 -10
  123. package/dist/client-v2/562.fb2948ee6402de95.js +0 -10
  124. package/dist/client-v2/757.a117ce1cf7119cea.js +0 -10
  125. package/dist/client-v2/902.c7c00a565085438a.js +0 -10
  126. package/dist/server/resource/ai-api-user-permissions.js +0 -75
  127. package/src/client-v2/pages/UserPermissionsPage.tsx +0 -322
  128. package/src/client-v2/pages/UserQuotasPage.tsx +0 -276
  129. package/src/server/__tests__/user-permissions-resource.test.ts +0 -66
  130. package/src/server/resource/ai-api-user-permissions.ts +0 -76
@@ -0,0 +1,225 @@
1
+ import dns from 'dns';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+ import {
4
+ FileProcessorError,
5
+ fetchFileAsBase64,
6
+ httpFileUrlFetcher,
7
+ isBlockedAddress,
8
+ type FileProcessorContext,
9
+ } from '../services/file-processor';
10
+
11
+ const PUBLIC_ADDRESS = { address: '93.184.216.34', family: 4 };
12
+
13
+ function mockDnsLookup(...results: Array<Array<{ address: string; family: number }>>) {
14
+ const spy = vi.spyOn(dns.promises, 'lookup');
15
+ for (const addresses of results) {
16
+ // The overloaded lookup signatures defeat vitest's inferred mock value type.
17
+ spy.mockResolvedValueOnce(addresses as never);
18
+ }
19
+ spy.mockRejectedValue(new Error('unexpected dns lookup'));
20
+ return spy;
21
+ }
22
+
23
+ function mockFetch(...responses: Response[]) {
24
+ const fetchMock = vi.fn();
25
+ for (const response of responses) {
26
+ fetchMock.mockResolvedValueOnce(response);
27
+ }
28
+ fetchMock.mockRejectedValue(new Error('unexpected fetch'));
29
+ vi.stubGlobal('fetch', fetchMock);
30
+ return fetchMock;
31
+ }
32
+
33
+ function okResponse(body: string, contentType = 'text/plain') {
34
+ return new Response(body, {
35
+ status: 200,
36
+ headers: { 'content-type': contentType, 'content-length': String(Buffer.byteLength(body)) },
37
+ });
38
+ }
39
+
40
+ function redirectResponse(location: string, status = 302) {
41
+ return new Response(null, { status, headers: { location } });
42
+ }
43
+
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ vi.unstubAllGlobals();
47
+ });
48
+
49
+ describe('isBlockedAddress', () => {
50
+ it('blocks private, loopback, link-local and reserved IPv4 ranges', () => {
51
+ expect(isBlockedAddress('0.0.0.0')).toBe(true);
52
+ expect(isBlockedAddress('10.0.0.1')).toBe(true);
53
+ expect(isBlockedAddress('100.64.0.1')).toBe(true);
54
+ expect(isBlockedAddress('100.127.255.255')).toBe(true);
55
+ expect(isBlockedAddress('127.0.0.1')).toBe(true);
56
+ expect(isBlockedAddress('169.254.169.254')).toBe(true);
57
+ expect(isBlockedAddress('172.16.0.1')).toBe(true);
58
+ expect(isBlockedAddress('172.31.255.255')).toBe(true);
59
+ expect(isBlockedAddress('192.0.0.1')).toBe(true);
60
+ expect(isBlockedAddress('192.168.0.10')).toBe(true);
61
+ expect(isBlockedAddress('198.18.0.1')).toBe(true);
62
+ expect(isBlockedAddress('198.19.0.1')).toBe(true);
63
+ expect(isBlockedAddress('224.0.0.1')).toBe(true);
64
+ expect(isBlockedAddress('255.255.255.255')).toBe(true);
65
+ });
66
+
67
+ it('allows public IPv4 addresses at the range boundaries', () => {
68
+ expect(isBlockedAddress('8.8.8.8')).toBe(false);
69
+ expect(isBlockedAddress('100.63.255.255')).toBe(false);
70
+ expect(isBlockedAddress('100.128.0.0')).toBe(false);
71
+ expect(isBlockedAddress('172.15.255.255')).toBe(false);
72
+ expect(isBlockedAddress('172.32.0.0')).toBe(false);
73
+ expect(isBlockedAddress('192.167.0.1')).toBe(false);
74
+ expect(isBlockedAddress('198.17.255.255')).toBe(false);
75
+ expect(isBlockedAddress('198.20.0.0')).toBe(false);
76
+ });
77
+
78
+ it('blocks unspecified, loopback, ULA, link-local and multicast IPv6 addresses', () => {
79
+ expect(isBlockedAddress('::')).toBe(true);
80
+ expect(isBlockedAddress('::1')).toBe(true);
81
+ expect(isBlockedAddress('fc00::1')).toBe(true);
82
+ expect(isBlockedAddress('fd12:3456::1')).toBe(true);
83
+ expect(isBlockedAddress('fe80::1')).toBe(true);
84
+ expect(isBlockedAddress('febf::1')).toBe(true);
85
+ expect(isBlockedAddress('ff02::1')).toBe(true);
86
+ });
87
+
88
+ it('blocks IPv6 forms that embed blocked IPv4 addresses', () => {
89
+ expect(isBlockedAddress('::ffff:127.0.0.1')).toBe(true);
90
+ expect(isBlockedAddress('::ffff:10.0.0.1')).toBe(true);
91
+ expect(isBlockedAddress('::ffff:169.254.169.254')).toBe(true);
92
+ expect(isBlockedAddress('::10.0.0.1')).toBe(true);
93
+ expect(isBlockedAddress('64:ff9b::7f00:1')).toBe(true);
94
+ });
95
+
96
+ it('allows public IPv6 addresses', () => {
97
+ expect(isBlockedAddress('2001:4860:4860::8888')).toBe(false);
98
+ expect(isBlockedAddress('2606:4700:4700::1111')).toBe(false);
99
+ expect(isBlockedAddress('::ffff:8.8.8.8')).toBe(false);
100
+ expect(isBlockedAddress('64:ff9b::808:808')).toBe(false);
101
+ });
102
+
103
+ it('blocks unparseable input defensively', () => {
104
+ expect(isBlockedAddress('')).toBe(true);
105
+ expect(isBlockedAddress('not-an-ip')).toBe(true);
106
+ expect(isBlockedAddress('1::2::3')).toBe(true);
107
+ expect(isBlockedAddress('1.2.3')).toBe(true);
108
+ expect(isBlockedAddress('1.2.3.256')).toBe(true);
109
+ });
110
+ });
111
+
112
+ describe('fetchFileAsBase64 SSRF protection', () => {
113
+ it('blocks hosts that resolve to private addresses before fetching', async () => {
114
+ const lookupSpy = mockDnsLookup([{ address: '169.254.169.254', family: 4 }]);
115
+ const fetchMock = mockFetch();
116
+ await expect(fetchFileAsBase64('https://evil.example.com/latest/meta-data')).rejects.toMatchObject({
117
+ code: 'blocked_host',
118
+ });
119
+ expect(lookupSpy).toHaveBeenCalledWith('evil.example.com', { all: true });
120
+ expect(fetchMock).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it('blocks when any resolved address is private', async () => {
124
+ mockDnsLookup([PUBLIC_ADDRESS, { address: '10.0.0.1', family: 4 }]);
125
+ const fetchMock = mockFetch();
126
+ await expect(fetchFileAsBase64('https://dual.example.com/file')).rejects.toMatchObject({
127
+ code: 'blocked_host',
128
+ });
129
+ expect(fetchMock).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it('fetches files from public hosts', async () => {
133
+ mockDnsLookup([PUBLIC_ADDRESS]);
134
+ const fetchMock = mockFetch(okResponse('hello world'));
135
+ const result = await fetchFileAsBase64('https://example.com/doc.txt');
136
+ expect(fetchMock).toHaveBeenCalledWith(
137
+ 'https://example.com/doc.txt',
138
+ expect.objectContaining({ redirect: 'manual' }),
139
+ );
140
+ expect(result.mimeType).toBe('text/plain');
141
+ expect(result.filename).toBe('doc.txt');
142
+ expect(result.fileData).toBe(`data:text/plain;base64,${Buffer.from('hello world').toString('base64')}`);
143
+ });
144
+
145
+ it('blocks redirects that point at private hosts', async () => {
146
+ const lookupSpy = mockDnsLookup([PUBLIC_ADDRESS], [{ address: '127.0.0.1', family: 4 }]);
147
+ const fetchMock = mockFetch(redirectResponse('http://internal.example/secret'));
148
+ await expect(fetchFileAsBase64('https://example.com/file')).rejects.toMatchObject({ code: 'blocked_host' });
149
+ expect(fetchMock).toHaveBeenCalledTimes(1);
150
+ expect(lookupSpy).toHaveBeenCalledTimes(2);
151
+ });
152
+
153
+ it('follows redirects to allowed public hosts', async () => {
154
+ mockDnsLookup([PUBLIC_ADDRESS], [{ address: '104.16.0.1', family: 4 }]);
155
+ const fetchMock = mockFetch(redirectResponse('https://cdn.example.com/doc.txt', 301), okResponse('payload'));
156
+ const result = await fetchFileAsBase64('https://example.com/doc.txt');
157
+ expect(fetchMock).toHaveBeenCalledTimes(2);
158
+ expect(result.fileData).toBe(`data:text/plain;base64,${Buffer.from('payload').toString('base64')}`);
159
+ expect(result.filename).toBe('doc.txt');
160
+ });
161
+
162
+ it('resolves relative redirect locations against the current URL', async () => {
163
+ mockDnsLookup([PUBLIC_ADDRESS], [PUBLIC_ADDRESS]);
164
+ const fetchMock = mockFetch(redirectResponse('/moved/doc.txt'), okResponse('data'));
165
+ await fetchFileAsBase64('https://example.com/doc.txt');
166
+ expect(fetchMock).toHaveBeenLastCalledWith(
167
+ 'https://example.com/moved/doc.txt',
168
+ expect.objectContaining({ redirect: 'manual' }),
169
+ );
170
+ });
171
+
172
+ it('blocks literal private IP hosts without DNS lookup or fetch', async () => {
173
+ const lookupSpy = vi.spyOn(dns.promises, 'lookup');
174
+ const fetchMock = mockFetch();
175
+ await expect(fetchFileAsBase64('http://192.168.0.10/x')).rejects.toMatchObject({ code: 'blocked_host' });
176
+ expect(lookupSpy).not.toHaveBeenCalled();
177
+ expect(fetchMock).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it('blocks bracketed IPv6 loopback literals', async () => {
181
+ const fetchMock = mockFetch();
182
+ await expect(fetchFileAsBase64('http://[::1]/x')).rejects.toMatchObject({ code: 'blocked_host' });
183
+ expect(fetchMock).not.toHaveBeenCalled();
184
+ });
185
+
186
+ it('stops after the redirect limit', async () => {
187
+ const lookupSpy = vi.spyOn(dns.promises, 'lookup');
188
+ lookupSpy.mockResolvedValue([PUBLIC_ADDRESS] as never);
189
+ const fetchMock = vi.fn().mockResolvedValue(redirectResponse('https://example.com/loop'));
190
+ vi.stubGlobal('fetch', fetchMock);
191
+ await expect(fetchFileAsBase64('https://example.com/loop', { maxRedirects: 3 })).rejects.toMatchObject({
192
+ code: 'too_many_redirects',
193
+ });
194
+ expect(fetchMock).toHaveBeenCalledTimes(4);
195
+ });
196
+
197
+ it('reports DNS failures as fetch_failed', async () => {
198
+ const lookupSpy = vi.spyOn(dns.promises, 'lookup');
199
+ lookupSpy.mockRejectedValue(new Error('getaddrinfo ENOTFOUND'));
200
+ const fetchMock = mockFetch();
201
+ await expect(fetchFileAsBase64('https://no-such-host.example/x')).rejects.toMatchObject({
202
+ code: 'fetch_failed',
203
+ });
204
+ expect(fetchMock).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it('keeps the existing URL validation errors', async () => {
208
+ await expect(fetchFileAsBase64('not a url')).rejects.toMatchObject({ code: 'invalid_url' });
209
+ await expect(fetchFileAsBase64('ftp://example.com/file')).rejects.toMatchObject({
210
+ code: 'unsupported_protocol',
211
+ });
212
+ });
213
+
214
+ it('surfaces blocked hosts through the httpFileUrlFetcher processor', async () => {
215
+ const fetchMock = mockFetch();
216
+ const context = { ctx: {} } as unknown as FileProcessorContext;
217
+ await expect(
218
+ httpFileUrlFetcher.process({ type: 'file_url', file_url: { url: 'http://10.0.0.5/data.pdf' } }, context),
219
+ ).rejects.toBeInstanceOf(FileProcessorError);
220
+ await expect(
221
+ httpFileUrlFetcher.process({ type: 'file_url', file_url: { url: 'http://10.0.0.5/data.pdf' } }, context),
222
+ ).rejects.toMatchObject({ code: 'blocked_host' });
223
+ expect(fetchMock).not.toHaveBeenCalled();
224
+ });
225
+ });
@@ -21,7 +21,7 @@ function permissionLookupFailureContext() {
21
21
  getRepository: (name: string) => {
22
22
  if (name === 'aiApiConfig') return { findOne: vi.fn(async () => null) };
23
23
  if (name === 'llmServices') return { find: vi.fn(async () => []) };
24
- if (name === 'aiApiUserPermissions') {
24
+ if (name === 'aiApiGroupMembers') {
25
25
  return { findOne: vi.fn(async () => Promise.reject(new Error('permission database unavailable'))) };
26
26
  }
27
27
  return { find: vi.fn(async () => []) };
@@ -86,7 +86,12 @@ describe('AI API OpenAI usage-only streaming chunks', () => {
86
86
 
87
87
  expect(chunk.object).toBe('chat.completion.chunk');
88
88
  expect(chunk.choices).toEqual([]);
89
- expect(chunk.usage).toEqual({ prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 });
89
+ expect(chunk.usage).toEqual({
90
+ prompt_tokens: 8,
91
+ completion_tokens: 3,
92
+ total_tokens: 11,
93
+ prompt_tokens_details: { cached_tokens: null },
94
+ });
90
95
  });
91
96
 
92
97
  it('formats a usage-only legacy text completion chunk', () => {
@@ -99,7 +104,12 @@ describe('AI API OpenAI usage-only streaming chunks', () => {
99
104
 
100
105
  expect(chunk.object).toBe('text_completion');
101
106
  expect(chunk.choices).toEqual([]);
102
- expect(chunk.usage).toEqual({ prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 });
107
+ expect(chunk.usage).toEqual({
108
+ prompt_tokens: 2,
109
+ completion_tokens: 5,
110
+ total_tokens: 7,
111
+ prompt_tokens_details: { cached_tokens: null },
112
+ });
103
113
  });
104
114
  });
105
115
 
@@ -10,60 +10,61 @@
10
10
  import { Context } from '@nocobase/actions';
11
11
  import { beforeEach, describe, expect, it, vi } from 'vitest';
12
12
  import { PluginAiApiServer } from '../plugin';
13
- import { invalidateUserPermissionCache, resolveUserAccessScope } from '../utils/user-permissions';
13
+ import { invalidateGroupAccessCache, resolveUserAccessScope } from '../utils/user-permissions';
14
14
 
15
15
  /**
16
- * Covers the cross-node half of permission revocation.
16
+ * Covers the cross-node half of group-access invalidation.
17
17
  *
18
18
  * syncMessageManager hardcodes `skipSelf: true` (sync-message-manager.ts:59,73), so the node
19
19
  * that writes the change never receives its own broadcast. That makes two things load-bearing
20
20
  * and easy to regress: the writer must invalidate its own cache locally, and every other node
21
21
  * must invalidate on receipt. Neither is observable from a single-node test of the cache alone.
22
22
  */
23
- function mockContext(userId: number, row: unknown) {
24
- const findOne = vi.fn(async () => row);
23
+ function row(values: Record<string, unknown>) {
24
+ return { get: (key?: string) => (key === undefined ? values : values[key]) };
25
+ }
26
+
27
+ function mockContext(userId: number, groupValues: Record<string, unknown>) {
28
+ const group = row({ allowedLlmServices: [], allowAllModels: true, allowedModels: [], ...groupValues });
29
+ const findOne = vi.fn(async () => row({ group }));
25
30
  const ctx = {
26
31
  state: { currentUser: { id: userId } },
27
- db: { getRepository: () => ({ findOne }) },
32
+ db: {
33
+ getRepository: (name: string) =>
34
+ name === 'aiApiGroupMembers' ? { findOne } : { findOne: async () => null, create: vi.fn() },
35
+ },
28
36
  app: { name: 'main' },
29
37
  log: { warn: vi.fn(), error: vi.fn() },
30
38
  } as unknown as Context;
31
39
  return { ctx, findOne };
32
40
  }
33
41
 
34
- function row(values: Record<string, unknown>) {
35
- return { get: (key: string) => values[key] };
36
- }
37
-
38
42
  beforeEach(() => {
39
- invalidateUserPermissionCache();
43
+ invalidateGroupAccessCache();
40
44
  });
41
45
 
42
- describe('cross-node permission invalidation', () => {
46
+ describe('cross-node group access invalidation', () => {
43
47
  it("drops the receiving node's cached scope", async () => {
44
48
  const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
45
- const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
49
+ const { ctx } = mockContext(1, { id: 5, allowedLlmServices: ['openai'] });
46
50
 
47
- await resolveUserAccessScope(ctx);
48
- await resolveUserAccessScope(ctx);
49
- expect(findOne).toHaveBeenCalledTimes(1);
51
+ const before = await resolveUserAccessScope(ctx);
52
+ expect(await resolveUserAccessScope(ctx)).toBe(before);
50
53
 
51
- await plugin.handleSyncMessage({ type: 'invalidateUserPermissions', userId: 1 });
54
+ await plugin.handleSyncMessage({ type: 'invalidateGroupAccess', groupId: 5 });
52
55
 
53
- await resolveUserAccessScope(ctx);
54
- expect(findOne).toHaveBeenCalledTimes(2);
56
+ expect(await resolveUserAccessScope(ctx)).not.toBe(before);
55
57
  });
56
58
 
57
- it('ignores unrelated message types and other users', async () => {
59
+ it('ignores unrelated message types and other groups', async () => {
58
60
  const plugin = Object.create(PluginAiApiServer.prototype) as PluginAiApiServer;
59
- const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
60
- await resolveUserAccessScope(ctx);
61
+ const { ctx } = mockContext(1, { id: 5, allowedLlmServices: ['openai'] });
62
+ const before = await resolveUserAccessScope(ctx);
61
63
 
62
- await plugin.handleSyncMessage({ type: 'somethingElse', userId: 1 });
63
- await plugin.handleSyncMessage({ type: 'invalidateUserPermissions', userId: 2 });
64
+ await plugin.handleSyncMessage({ type: 'somethingElse', groupId: 5 });
65
+ await plugin.handleSyncMessage({ type: 'invalidateGroupAccess', groupId: 6 });
64
66
 
65
- await resolveUserAccessScope(ctx);
66
- expect(findOne).toHaveBeenCalledTimes(1);
67
+ expect(await resolveUserAccessScope(ctx)).toBe(before);
67
68
  });
68
69
 
69
70
  it('tolerates a malformed message instead of throwing into the subscriber', async () => {
@@ -77,18 +78,16 @@ describe('cross-node permission invalidation', () => {
77
78
  const sendSyncMessage = vi.fn(async () => undefined);
78
79
  Object.assign(plugin, { sendSyncMessage });
79
80
 
80
- const { ctx, findOne } = mockContext(1, row({ allowedLlmServices: ['openai'] }));
81
- await resolveUserAccessScope(ctx);
82
- expect(findOne).toHaveBeenCalledTimes(1);
81
+ const { ctx } = mockContext(1, { id: 5, allowedLlmServices: ['openai'] });
82
+ const before = await resolveUserAccessScope(ctx);
83
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);
84
+ // invalidateGroupAccess is private; reach it the way the db hook does.
85
+ (plugin as unknown as { invalidateGroupAccess: (id: unknown, tx?: unknown) => void }).invalidateGroupAccess(5);
86
86
 
87
87
  // Local cache cleared without any message coming back to us.
88
- await resolveUserAccessScope(ctx);
89
- expect(findOne).toHaveBeenCalledTimes(2);
88
+ expect(await resolveUserAccessScope(ctx)).not.toBe(before);
90
89
  expect(sendSyncMessage).toHaveBeenCalledWith(
91
- { type: 'invalidateUserPermissions', userId: 1 },
90
+ { type: 'invalidateGroupAccess', groupId: 5 },
92
91
  { transaction: undefined },
93
92
  );
94
93
  });
@@ -99,11 +98,11 @@ describe('cross-node permission invalidation', () => {
99
98
  Object.assign(plugin, { sendSyncMessage });
100
99
  const transaction = { id: 'tx-1' };
101
100
 
102
- (plugin as unknown as { revokeUserPermissions: (id: unknown, tx?: unknown) => void }).revokeUserPermissions(
101
+ (plugin as unknown as { invalidateGroupAccess: (id: unknown, tx?: unknown) => void }).invalidateGroupAccess(
103
102
  7,
104
103
  transaction,
105
104
  );
106
105
 
107
- expect(sendSyncMessage).toHaveBeenCalledWith({ type: 'invalidateUserPermissions', userId: 7 }, { transaction });
106
+ expect(sendSyncMessage).toHaveBeenCalledWith({ type: 'invalidateGroupAccess', groupId: 7 }, { transaction });
108
107
  });
109
108
  });
@@ -176,11 +176,54 @@ describe('AI API multimodal content block validation', () => {
176
176
  it('rejects an unsupported block type and names it', () => {
177
177
  const problem = findContentBlockProblem([
178
178
  { role: 'system', content: 'You are helpful.' },
179
- { role: 'user', content: [{ type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } }] },
179
+ { role: 'user', content: [{ type: 'audio', audio: { url: 'https://example.com/x.mp3' } }] },
180
180
  ]);
181
181
 
182
182
  expect(problem?.index).toBe(1);
183
- expect(problem?.reason).toContain("'file' is not supported");
183
+ expect(problem?.reason).toContain("'audio' is not supported");
184
+ });
185
+
186
+ it('accepts well-formed file and file_url blocks', () => {
187
+ expect(
188
+ findContentBlockProblem(wrap([{ type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } }])),
189
+ ).toBeUndefined();
190
+ expect(
191
+ findContentBlockProblem(wrap([{ type: 'file_url', file_url: { url: 'https://example.com/doc.pdf' } }])),
192
+ ).toBeUndefined();
193
+ // Complex MIME types with hyphens, dots, or '+' used to be rejected by the
194
+ // image_url grammar even though they are valid file attachments.
195
+ expect(
196
+ findContentBlockProblem(
197
+ wrap([
198
+ {
199
+ type: 'file',
200
+ file: {
201
+ file_data: 'data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,JVBERi0=',
202
+ },
203
+ },
204
+ ]),
205
+ ),
206
+ ).toBeUndefined();
207
+ expect(
208
+ findContentBlockProblem(wrap([{ type: 'file', file: { file_data: 'data:image/svg+xml;base64,JVBERi0=' } }])),
209
+ ).toBeUndefined();
210
+ });
211
+
212
+ it('rejects a file block with missing or malformed file_data', () => {
213
+ expect(findContentBlockProblem(wrap([{ type: 'file' }]))?.reason).toContain("object 'file' field");
214
+ expect(findContentBlockProblem(wrap([{ type: 'file', file: { file_data: 'not-a-data-url' } }]))?.reason).toContain(
215
+ "'data:'",
216
+ );
217
+ expect(
218
+ findContentBlockProblem(wrap([{ type: 'file', file: { file_data: 'data:application/pdf;base64,!!!' } }]))?.reason,
219
+ ).toContain('malformed base64');
220
+ });
221
+
222
+ it('rejects a file_url block with missing or unsupported URL', () => {
223
+ expect(findContentBlockProblem(wrap([{ type: 'file_url' }]))?.reason).toContain("object 'file_url' field");
224
+ expect(
225
+ findContentBlockProblem(wrap([{ type: 'file_url', file_url: { url: 'ftp://example.com/doc.pdf' } }]))?.reason,
226
+ ).toContain("protocol 'ftp:'");
184
227
  });
185
228
 
186
229
  it('rejects a text block with no text payload', () => {
@@ -0,0 +1,160 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { createMockDatabase, type Database } from '@nocobase/database';
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4
+ import { resolveUserGroup, getDefaultGroup } from '../quota-groups';
5
+
6
+ describe('AI API usage groups', () => {
7
+ let db: Database;
8
+
9
+ beforeEach(async () => {
10
+ db = await createMockDatabase();
11
+ db.collection({
12
+ name: 'aiApiUsageGroups',
13
+ fields: [
14
+ { name: 'name', type: 'string' },
15
+ { name: 'isDefault', type: 'boolean' },
16
+ { name: 'quotaMode', type: 'string' },
17
+ { name: 'rateLimitPerMinute', type: 'integer' },
18
+ { name: 'enabled', type: 'boolean' },
19
+ { name: 'periodType', type: 'string' },
20
+ { name: 'timezone', type: 'string' },
21
+ { name: 'requestLimit', type: 'bigInt' },
22
+ { name: 'totalTokenLimit', type: 'bigInt' },
23
+ { name: 'costLimit', type: 'decimal', precision: 20, scale: 8 },
24
+ { name: 'currency', type: 'string' },
25
+ { name: 'rejectUnpricedModel', type: 'boolean' },
26
+ { name: 'missingUsageBehavior', type: 'string' },
27
+ { name: 'contextOverflowBehavior', type: 'string' },
28
+ { name: 'allowedLlmServices', type: 'json' },
29
+ { name: 'allowAllModels', type: 'boolean' },
30
+ { name: 'allowedModels', type: 'json' },
31
+ ],
32
+ });
33
+ db.collection({
34
+ name: 'aiApiGroupMembers',
35
+ fields: [
36
+ { name: 'groupId', type: 'bigInt' },
37
+ {
38
+ name: 'group',
39
+ type: 'belongsTo',
40
+ target: 'aiApiUsageGroups',
41
+ targetKey: 'id',
42
+ foreignKey: 'groupId',
43
+ },
44
+ { name: 'userId', type: 'bigInt' },
45
+ ],
46
+ indexes: [{ fields: ['userId'], unique: true }],
47
+ });
48
+ await db.sync({ force: true });
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await db.close();
53
+ });
54
+
55
+ function context(): Context {
56
+ return { db, request: {}, state: {} } as unknown as Context;
57
+ }
58
+
59
+ it('creates the default group lazily with open model access', async () => {
60
+ const group = await getDefaultGroup(context());
61
+ expect(group.name).toBe('Default');
62
+ expect(group.isDefault).toBe(true);
63
+ expect(group.quotaMode).toBe('per_user');
64
+ // The default group must never lock everyone out: empty lists mean "no narrowing".
65
+ expect(group.allowedLlmServices).toEqual([]);
66
+ expect(group.allowAllModels).toBe(true);
67
+ expect(group.allowedModels).toEqual([]);
68
+
69
+ const second = await getDefaultGroup(context());
70
+ expect(second.id).toBe(group.id);
71
+ });
72
+
73
+ it('resolves an unassigned user to the default group', async () => {
74
+ const group = await resolveUserGroup(context(), 99);
75
+ expect(group.name).toBe('Default');
76
+ expect(group.isDefault).toBe(true);
77
+ });
78
+
79
+ it('resolves an assigned user to their explicit group', async () => {
80
+ const custom = await db.getRepository('aiApiUsageGroups').create({
81
+ values: {
82
+ name: 'Pro',
83
+ isDefault: false,
84
+ quotaMode: 'share',
85
+ rateLimitPerMinute: 120,
86
+ enabled: true,
87
+ periodType: 'monthly',
88
+ timezone: 'UTC',
89
+ currency: 'USD',
90
+ rejectUnpricedModel: true,
91
+ missingUsageBehavior: 'use_reserved',
92
+ contextOverflowBehavior: 'reject',
93
+ },
94
+ });
95
+ await db.getRepository('aiApiGroupMembers').create({
96
+ values: { groupId: custom.get('id'), userId: 42 },
97
+ });
98
+
99
+ const group = await resolveUserGroup(context(), 42);
100
+ expect(group.name).toBe('Pro');
101
+ expect(group.quotaMode).toBe('share');
102
+ });
103
+
104
+ it('carries the model access fields through group resolution', async () => {
105
+ const custom = await db.getRepository('aiApiUsageGroups').create({
106
+ values: {
107
+ name: 'Restricted',
108
+ isDefault: false,
109
+ quotaMode: 'per_user',
110
+ rateLimitPerMinute: 60,
111
+ enabled: false,
112
+ periodType: 'monthly',
113
+ timezone: 'UTC',
114
+ currency: 'USD',
115
+ rejectUnpricedModel: true,
116
+ missingUsageBehavior: 'use_reserved',
117
+ contextOverflowBehavior: 'reject',
118
+ allowedLlmServices: ['svc'],
119
+ allowAllModels: false,
120
+ allowedModels: ['svc/model-a'],
121
+ },
122
+ });
123
+ await db.getRepository('aiApiGroupMembers').create({
124
+ values: { groupId: custom.get('id'), userId: 43 },
125
+ });
126
+
127
+ const group = await resolveUserGroup(context(), 43);
128
+ expect(group.allowedLlmServices).toEqual(['svc']);
129
+ expect(group.allowAllModels).toBe(false);
130
+ expect(group.allowedModels).toEqual(['svc/model-a']);
131
+ });
132
+
133
+ it('drops non-string entries from the access lists', async () => {
134
+ const custom = await db.getRepository('aiApiUsageGroups').create({
135
+ values: {
136
+ name: 'Messy',
137
+ isDefault: false,
138
+ quotaMode: 'per_user',
139
+ rateLimitPerMinute: 60,
140
+ enabled: false,
141
+ periodType: 'monthly',
142
+ timezone: 'UTC',
143
+ currency: 'USD',
144
+ rejectUnpricedModel: true,
145
+ missingUsageBehavior: 'use_reserved',
146
+ contextOverflowBehavior: 'reject',
147
+ allowedLlmServices: ['svc', null, 42],
148
+ allowAllModels: false,
149
+ allowedModels: [{ k: 1 }, 'svc/model-a'],
150
+ },
151
+ });
152
+ await db.getRepository('aiApiGroupMembers').create({
153
+ values: { groupId: custom.get('id'), userId: 44 },
154
+ });
155
+
156
+ const group = await resolveUserGroup(context(), 44);
157
+ expect(group.allowedLlmServices).toEqual(['svc']);
158
+ expect(group.allowedModels).toEqual(['svc/model-a']);
159
+ });
160
+ });
@@ -10,6 +10,7 @@ describe('AI API usage monitor summary', () => {
10
10
  inputTokens: '100',
11
11
  outputTokens: '25',
12
12
  totalTokens: '125',
13
+ promptCacheTokens: '40',
13
14
  });
14
15
  const findAll = vi.fn().mockResolvedValue([
15
16
  { currency: 'USD', totalCost: '0.12500000' },
@@ -53,6 +54,7 @@ describe('AI API usage monitor summary', () => {
53
54
  inputTokens: 100,
54
55
  outputTokens: 25,
55
56
  totalTokens: 125,
57
+ promptCacheTokens: 40,
56
58
  costsByCurrency: [
57
59
  { currency: 'USD', totalCost: '0.12500000' },
58
60
  { currency: 'EUR', totalCost: '0.05000000' },