plugin-ai-api 1.0.21 → 1.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/client/123.e6fe04c856ce6417.js +10 -0
  2. package/dist/client/902.e74518750f1e4201.js +10 -0
  3. package/dist/client/index.js +1 -1
  4. package/dist/client-v2/123.05f1f649923f93eb.js +10 -0
  5. package/dist/client-v2/902.c7c00a565085438a.js +10 -0
  6. package/dist/client-v2/index.js +1 -1
  7. package/dist/constants.js +5 -2
  8. package/dist/locale/en-US.json +15 -1
  9. package/dist/locale/vi-VN.json +15 -1
  10. package/dist/locale/zh-CN.json +15 -1
  11. package/dist/server/collections/ai-api-user-permissions.js +67 -0
  12. package/dist/server/collections/ai-api-user-quota-policies.js +2 -1
  13. package/dist/server/plugin.js +32 -0
  14. package/dist/server/resource/ai-api-user-permissions.js +75 -0
  15. package/dist/server/routes/agent-completions.js +5 -0
  16. package/dist/server/routes/chat-completions.js +52 -27
  17. package/dist/server/routes/completions.js +59 -33
  18. package/dist/server/routes/embeddings.js +6 -14
  19. package/dist/server/routes/models.js +24 -0
  20. package/dist/server/utils/direct-llm-context.js +184 -0
  21. package/dist/server/utils/openai-format.js +17 -3
  22. package/dist/server/utils/user-permissions.js +160 -0
  23. package/dist/server/validation.js +3 -0
  24. package/dist/swagger.js +4 -3
  25. package/package.json +2 -2
  26. package/src/client/__tests__/settings-registration.test.tsx +69 -0
  27. package/src/client/plugin.tsx +14 -3
  28. package/src/client-v2/__tests__/settings-registration.test.tsx +33 -4
  29. package/src/client-v2/pages/UserPermissionsPage.tsx +322 -0
  30. package/src/client-v2/pages/UserQuotasPage.tsx +18 -0
  31. package/src/client-v2/plugin.tsx +12 -3
  32. package/src/constants.ts +7 -0
  33. package/src/locale/en-US.json +15 -1
  34. package/src/locale/vi-VN.json +15 -1
  35. package/src/locale/zh-CN.json +15 -1
  36. package/src/server/__tests__/direct-llm-context.test.ts +125 -0
  37. package/src/server/__tests__/models.test.ts +44 -2
  38. package/src/server/__tests__/openai-format.test.ts +52 -1
  39. package/src/server/__tests__/permission-sync.test.ts +109 -0
  40. package/src/server/__tests__/usage-route.test.ts +265 -5
  41. package/src/server/__tests__/user-permissions-resource.test.ts +66 -0
  42. package/src/server/__tests__/user-permissions.test.ts +284 -0
  43. package/src/server/__tests__/validation.test.ts +36 -0
  44. package/src/server/collections/ai-api-user-permissions.ts +46 -0
  45. package/src/server/collections/ai-api-user-quota-policies.ts +1 -0
  46. package/src/server/plugin.ts +42 -1
  47. package/src/server/resource/ai-api-user-permissions.ts +76 -0
  48. package/src/server/routes/agent-completions.ts +7 -0
  49. package/src/server/routes/chat-completions.ts +58 -30
  50. package/src/server/routes/completions.ts +68 -34
  51. package/src/server/routes/embeddings.ts +10 -15
  52. package/src/server/routes/models.ts +28 -0
  53. package/src/server/utils/direct-llm-context.ts +216 -0
  54. package/src/server/utils/openai-format.ts +26 -0
  55. package/src/server/utils/user-permissions.ts +218 -0
  56. package/src/server/validation.ts +3 -0
  57. package/src/swagger.ts +9 -3
  58. package/dist/client/902.92e1daaf1ab16ebf.js +0 -10
  59. package/dist/client-v2/902.9054d990ddc223ac.js +0 -10
@@ -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
+ });
@@ -2,6 +2,7 @@ import type { Context } from '@nocobase/actions';
2
2
  import { beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import type PluginAiApiServer from '../plugin';
4
4
  import { handleChatCompletions } from '../routes/chat-completions';
5
+ import { handleCompletions } from '../routes/completions';
5
6
  import { resolveModelString } from '../utils/resolve-service';
6
7
 
7
8
  vi.mock('../utils/resolve-service', () => ({
@@ -14,18 +15,20 @@ interface ModelResult {
14
15
  usage_metadata?: Record<string, unknown>;
15
16
  }
16
17
 
17
- function createContext(result: ModelResult): Context {
18
+ function createContext(result: ModelResult) {
18
19
  const model = {
19
20
  invoke: vi.fn().mockResolvedValue(result),
20
21
  modelKwargs: {},
21
22
  };
23
+ let providerCreateCount = 0;
22
24
  class TestProvider {
23
25
  createModel() {
26
+ providerCreateCount += 1;
24
27
  return model;
25
28
  }
26
29
  }
27
30
 
28
- return {
31
+ const ctx = {
29
32
  app: {
30
33
  pm: {
31
34
  get: vi.fn().mockReturnValue({
@@ -36,7 +39,16 @@ function createContext(result: ModelResult): Context {
36
39
  },
37
40
  },
38
41
  db: {
39
- getRepository: vi.fn().mockReturnValue({ findOne: vi.fn().mockResolvedValue(null) }),
42
+ getRepository: vi.fn((name: string) => {
43
+ if (name === 'aiApiModelMetadata') {
44
+ return {
45
+ findOne: vi.fn().mockResolvedValue({
46
+ get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
47
+ }),
48
+ };
49
+ }
50
+ return { findOne: vi.fn().mockResolvedValue(null) };
51
+ }),
40
52
  },
41
53
  log: { error: vi.fn() },
42
54
  request: {
@@ -49,6 +61,125 @@ function createContext(result: ModelResult): Context {
49
61
  state: {},
50
62
  set: vi.fn(),
51
63
  } as unknown as Context;
64
+
65
+ return { ctx, model, getProviderCreateCount: () => providerCreateCount };
66
+ }
67
+
68
+ class ListenerTarget {
69
+ private listeners = new Map<string, Set<() => void>>();
70
+ aborted = false;
71
+ writableEnded = false;
72
+
73
+ once(event: string, listener: () => void) {
74
+ const wrapped = () => {
75
+ this.off(event, wrapped);
76
+ listener();
77
+ };
78
+ const group = this.listeners.get(event) ?? new Set();
79
+ group.add(wrapped);
80
+ this.listeners.set(event, group);
81
+ }
82
+
83
+ off(event: string, listener: () => void) {
84
+ this.listeners.get(event)?.delete(listener);
85
+ }
86
+
87
+ emit(event: string) {
88
+ for (const listener of [...(this.listeners.get(event) ?? [])]) listener();
89
+ }
90
+ }
91
+
92
+ function createStreamingContext(
93
+ result: ModelResult,
94
+ streamOptions?: Record<string, unknown>,
95
+ requestBody?: Record<string, unknown>,
96
+ ) {
97
+ const req = new ListenerTarget();
98
+ const res = new ListenerTarget();
99
+ const writes: string[] = [];
100
+
101
+ res.write = vi.fn((data: unknown) => {
102
+ writes.push(String(data));
103
+ return true;
104
+ });
105
+ res.end = vi.fn(() => {
106
+ res.writableEnded = true;
107
+ });
108
+
109
+ const contentChunks = (typeof result.content === 'string' ? [result.content] : []).filter(Boolean);
110
+ const chunks = [
111
+ ...contentChunks.map((content) => ({ content })),
112
+ { content: '', usage_metadata: result.usage_metadata, response_metadata: result.response_metadata },
113
+ ];
114
+
115
+ const model = {
116
+ invoke: vi.fn().mockResolvedValue({
117
+ content: result.content,
118
+ usage_metadata: result.usage_metadata,
119
+ }),
120
+ stream: vi.fn().mockResolvedValue({
121
+ [Symbol.asyncIterator]() {
122
+ let index = 0;
123
+ return {
124
+ async next() {
125
+ if (index >= chunks.length) return { done: true, value: undefined };
126
+ return { done: false, value: chunks[index++] };
127
+ },
128
+ async return() {
129
+ index = chunks.length;
130
+ return { done: true, value: undefined };
131
+ },
132
+ };
133
+ },
134
+ }),
135
+ modelKwargs: {},
136
+ };
137
+
138
+ class TestProvider {
139
+ createModel() {
140
+ return model;
141
+ }
142
+ }
143
+
144
+ const ctx = {
145
+ app: {
146
+ pm: {
147
+ get: vi.fn().mockReturnValue({
148
+ aiManager: {
149
+ llmProviders: new Map([['test-provider', { provider: TestProvider }]]),
150
+ },
151
+ }),
152
+ },
153
+ },
154
+ db: {
155
+ getRepository: vi.fn((name: string) => {
156
+ if (name === 'aiApiModelMetadata') {
157
+ return {
158
+ findOne: vi.fn().mockResolvedValue({
159
+ get: (key: string) => (key === 'contextWindow' ? 128_000 : key === 'maxCompletionTokens' ? 16_384 : true),
160
+ }),
161
+ };
162
+ }
163
+ return { findOne: vi.fn().mockResolvedValue(null) };
164
+ }),
165
+ },
166
+ log: { error: vi.fn() },
167
+ req,
168
+ res,
169
+ request: {
170
+ body: {
171
+ model: 'test-service/test-model',
172
+ messages: [{ role: 'user', content: 'Hello' }],
173
+ stream: true,
174
+ stream_options: streamOptions,
175
+ ...requestBody,
176
+ },
177
+ },
178
+ state: {} as Record<string, unknown>,
179
+ set: vi.fn(),
180
+ } as unknown as Context;
181
+
182
+ return { ctx, model, writes };
52
183
  }
53
184
 
54
185
  describe('AI API chat usage collection', () => {
@@ -65,7 +196,7 @@ describe('AI API chat usage collection', () => {
65
196
  });
66
197
 
67
198
  it('keeps the public zero fallback but marks missing provider usage as unavailable internally', async () => {
68
- const ctx = createContext({ content: 'Hello back' });
199
+ const { ctx } = createContext({ content: 'Hello back' });
69
200
 
70
201
  await handleChatCompletions(ctx, {} as PluginAiApiServer);
71
202
 
@@ -86,7 +217,7 @@ describe('AI API chat usage collection', () => {
86
217
  });
87
218
 
88
219
  it('stores provider usage and provider request ID separately from the gateway response ID', async () => {
89
- const ctx = createContext({
220
+ const { ctx } = createContext({
90
221
  content: 'Hello back',
91
222
  response_metadata: { request_id: 'provider-request-1' },
92
223
  usage_metadata: { input_tokens: 8, output_tokens: 3, total_tokens: 11 },
@@ -106,4 +237,133 @@ describe('AI API chat usage collection', () => {
106
237
  usage: { prompt_tokens: 8, completion_tokens: 3, total_tokens: 11 },
107
238
  });
108
239
  });
240
+
241
+ it('emits a usage-only chunk immediately before [DONE] for streaming chat completions', async () => {
242
+ const { ctx, writes } = createStreamingContext(
243
+ {
244
+ content: 'Hi',
245
+ response_metadata: { request_id: 'provider-request-stream-1' },
246
+ usage_metadata: { input_tokens: 5, output_tokens: 4, total_tokens: 9 },
247
+ },
248
+ { include_usage: true },
249
+ );
250
+
251
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
252
+
253
+ const dataLines = writes.filter((line) => line.startsWith('data: '));
254
+ const doneFrame = dataLines.find((line) => line.includes('[DONE]'));
255
+ expect(doneFrame).toBeDefined();
256
+ const frames = dataLines.filter((line) => !line.includes('[DONE]')).map((line) => JSON.parse(line.slice(6)));
257
+ const doneIndex = frames.length;
258
+
259
+ const finishChunk = frames[doneIndex - 2];
260
+ const usageChunk = frames[doneIndex - 1];
261
+
262
+ expect(finishChunk.choices[0].finish_reason).toBe('stop');
263
+ expect(usageChunk.choices).toEqual([]);
264
+ expect(usageChunk.usage).toEqual({ prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 });
265
+ expect(usageChunk).toHaveProperty('usage.prompt_tokens', 5);
266
+ expect(ctx.state.aiApiUsageResult).toMatchObject({
267
+ source: 'provider',
268
+ providerRequestId: 'provider-request-stream-1',
269
+ });
270
+ });
271
+
272
+ it('always emits chat usage and forces provider collection when include_usage is false', async () => {
273
+ const { ctx, model, writes } = createStreamingContext(
274
+ {
275
+ content: 'Hi',
276
+ usage_metadata: { input_tokens: 5, output_tokens: 4, total_tokens: 9 },
277
+ },
278
+ { include_usage: false, include_obfuscation: false },
279
+ );
280
+
281
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
282
+
283
+ const frames = writes
284
+ .filter((line) => line.startsWith('data: ') && !line.includes('[DONE]'))
285
+ .map((line) => JSON.parse(line.slice(6)));
286
+ const usageChunk = frames[frames.length - 1];
287
+
288
+ expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
289
+ expect(usageChunk.choices).toEqual([]);
290
+ expect(usageChunk.usage).toEqual({ prompt_tokens: 5, completion_tokens: 4, total_tokens: 9 });
291
+ expect(model.stream).toHaveBeenCalledWith(
292
+ expect.anything(),
293
+ expect.objectContaining({
294
+ stream_options: { include_usage: true, include_obfuscation: false },
295
+ }),
296
+ );
297
+ });
298
+
299
+ it('always emits legacy completion usage and forwards all stream options', async () => {
300
+ const { ctx, model, writes } = createStreamingContext(
301
+ {
302
+ content: 'Hi',
303
+ usage_metadata: { input_tokens: 2, output_tokens: 5, total_tokens: 7 },
304
+ },
305
+ { include_usage: false, include_obfuscation: false },
306
+ { prompt: 'Hello' },
307
+ );
308
+
309
+ await handleCompletions(ctx, {} as PluginAiApiServer);
310
+
311
+ const frames = writes
312
+ .filter((line) => line.startsWith('data: ') && !line.includes('[DONE]'))
313
+ .map((line) => JSON.parse(line.slice(6)));
314
+ const usageChunk = frames[frames.length - 1];
315
+
316
+ expect(frames.slice(0, -1).every((frame) => frame.usage === null)).toBe(true);
317
+ expect(usageChunk.object).toBe('text_completion');
318
+ expect(usageChunk.choices).toEqual([]);
319
+ expect(usageChunk.usage).toEqual({ prompt_tokens: 2, completion_tokens: 5, total_tokens: 7 });
320
+ expect(model.stream).toHaveBeenCalledWith(
321
+ expect.anything(),
322
+ expect.objectContaining({
323
+ stream_options: { include_usage: true, include_obfuscation: false },
324
+ }),
325
+ );
326
+ });
327
+
328
+ it('rejects context overflow before creating a provider or reserving quota', async () => {
329
+ const { ctx, getProviderCreateCount } = createContext({ content: 'unused' });
330
+ (ctx.request.body as Record<string, unknown>).messages = [{ role: 'user', content: 'x'.repeat(600_000) }];
331
+
332
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
333
+
334
+ expect(ctx.status).toBe(400);
335
+ expect(ctx.body).toMatchObject({ error: { code: 'context_length_exceeded' } });
336
+ expect(ctx.state.aiApiLlmBilling).toBeUndefined();
337
+ expect(getProviderCreateCount()).toBe(0);
338
+ });
339
+
340
+ it('rejects oversized legacy prompts before creating a provider or reserving quota', async () => {
341
+ const { ctx, model, getProviderCreateCount } = createContext({ content: 'unused' });
342
+ (ctx.request.body as Record<string, unknown>).prompt = 'x'.repeat(600_000);
343
+
344
+ await handleCompletions(ctx, {} as PluginAiApiServer);
345
+
346
+ expect(ctx.status).toBe(400);
347
+ expect(ctx.body).toMatchObject({ error: { code: 'context_length_exceeded' } });
348
+ expect(ctx.state.aiApiLlmBilling).toBeUndefined();
349
+ expect(getProviderCreateCount()).toBe(0);
350
+ expect(model.invoke).not.toHaveBeenCalled();
351
+ });
352
+
353
+ it('does not emit a usage-only chunk when the provider omits usage metadata', async () => {
354
+ const { ctx, writes } = createStreamingContext({ content: 'Silent' }, { include_usage: true });
355
+
356
+ await handleChatCompletions(ctx, {} as PluginAiApiServer);
357
+
358
+ const dataLines = writes.filter((line) => line.startsWith('data: '));
359
+ const doneFrame = dataLines.find((line) => line.includes('[DONE]'));
360
+ expect(doneFrame).toBeDefined();
361
+ const frames = dataLines.filter((line) => !line.includes('[DONE]')).map((line) => JSON.parse(line.slice(6)));
362
+ const doneIndex = frames.length;
363
+
364
+ const precedingChunk = frames[doneIndex - 1];
365
+ expect(precedingChunk.choices[0].finish_reason).toBe('stop');
366
+ expect(precedingChunk.usage).toBeNull();
367
+ expect(ctx.state.aiApiUsageResult).toMatchObject({ source: 'unavailable' });
368
+ });
109
369
  });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * This file is part of the NocoBase (R) project.
3
+ * Copyright (c) 2020-2024 NocoBase Co., Ltd.
4
+ * Authors: NocoBase Team.
5
+ *
6
+ * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
7
+ * For more information, please refer to: https://www.nocobase.com/agreement.
8
+ */
9
+
10
+ import type { Context, Next } from '@nocobase/actions';
11
+ import { describe, expect, it, vi } from 'vitest';
12
+ import aiApiUserPermissionsResource from '../resource/ai-api-user-permissions';
13
+
14
+ type ActionHandler = (ctx: Context, next: Next) => Promise<void>;
15
+
16
+ const listUsers = aiApiUserPermissionsResource.actions?.listUsers as ActionHandler;
17
+
18
+ function model(values: Record<string, unknown>) {
19
+ return { get: (key: string) => values[key] };
20
+ }
21
+
22
+ describe('aiApiUserPermissions:listUsers', () => {
23
+ it('returns the canonical rows shape for NocoBase data wrapping', async () => {
24
+ const user = model({
25
+ id: 7,
26
+ nickname: 'Ada',
27
+ username: 'ada',
28
+ email: 'ada@example.com',
29
+ password: 'must-not-leak',
30
+ });
31
+ const findAndCount = vi.fn(async () => [[user], 1] as const);
32
+ const ctx = {
33
+ action: { params: {} },
34
+ db: { getRepository: () => ({ findAndCount }) },
35
+ } as unknown as Context;
36
+
37
+ await listUsers(ctx, async () => undefined);
38
+
39
+ expect(ctx.body).toEqual({
40
+ rows: [{ id: 7, nickname: 'Ada', username: 'ada', email: 'ada@example.com' }],
41
+ count: 1,
42
+ page: 1,
43
+ pageSize: 50,
44
+ });
45
+ expect(findAndCount).toHaveBeenCalledWith(
46
+ expect.objectContaining({ fields: ['id', 'nickname', 'username', 'email'] }),
47
+ );
48
+ });
49
+
50
+ it('excludes users that already hold a grant when requested', async () => {
51
+ const findAndCount = vi.fn(async () => [[], 0] as const);
52
+ const ctx = {
53
+ action: { params: { excludeGranted: true } },
54
+ db: {
55
+ getRepository: (name: string) =>
56
+ name === 'aiApiUserPermissions'
57
+ ? { find: vi.fn(async () => [model({ userId: 3 }), model({ userId: 8 })]) }
58
+ : { findAndCount },
59
+ },
60
+ } as unknown as Context;
61
+
62
+ await listUsers(ctx, async () => undefined);
63
+
64
+ expect(findAndCount).toHaveBeenCalledWith(expect.objectContaining({ filter: { id: { $notIn: [3, 8] } } }));
65
+ });
66
+ });