plugin-ai-api 1.0.15 → 1.0.21

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 (92) hide show
  1. package/dist/client/286.01c0e3c5fff3cccb.js +10 -0
  2. package/dist/client/302.fc3a3491b4ec2dfd.js +10 -0
  3. package/dist/client/562.17a0a299d2e5152c.js +10 -0
  4. package/dist/client/757.a01403fb7a1bea01.js +10 -0
  5. package/dist/client/902.92e1daaf1ab16ebf.js +10 -0
  6. package/dist/client/97.72979a11a067a7c9.js +10 -0
  7. package/dist/client/index.js +1 -1
  8. package/dist/client-v2/302.d27fe4ea9b0b3bf5.js +10 -0
  9. package/dist/client-v2/562.fb2948ee6402de95.js +10 -0
  10. package/dist/client-v2/757.a117ce1cf7119cea.js +10 -0
  11. package/dist/client-v2/902.9054d990ddc223ac.js +10 -0
  12. package/dist/client-v2/952.94100128b7757f56.js +10 -0
  13. package/dist/client-v2/97.29c663318eebbd57.js +10 -0
  14. package/dist/client-v2/index.js +1 -1
  15. package/dist/constants.js +36 -0
  16. package/dist/externalVersion.js +9 -10
  17. package/dist/locale/en-US.json +105 -10
  18. package/dist/locale/vi-VN.json +105 -0
  19. package/dist/locale/zh-CN.json +105 -10
  20. package/dist/server/billing.js +331 -0
  21. package/dist/server/collections/ai-api-config.js +18 -0
  22. package/dist/server/collections/ai-api-model-metadata.js +83 -0
  23. package/dist/server/collections/ai-api-model-prices.js +55 -0
  24. package/dist/server/collections/ai-api-usage-records.js +9 -0
  25. package/dist/server/collections/ai-api-user-quota-buckets.js +54 -0
  26. package/dist/server/collections/ai-api-user-quota-policies.js +62 -0
  27. package/dist/server/plugin.js +36 -3
  28. package/dist/server/resource/ai-api-config.js +25 -0
  29. package/dist/server/resource/ai-api-usage-monitor.js +86 -0
  30. package/dist/server/routes/agent-completions.js +62 -51
  31. package/dist/server/routes/auth.js +11 -1
  32. package/dist/server/routes/chat-completions.js +157 -6
  33. package/dist/server/routes/completions.js +20 -3
  34. package/dist/server/routes/models.js +78 -20
  35. package/dist/server/routes/router.js +108 -23
  36. package/dist/server/usage.js +19 -2
  37. package/dist/server/utils/app-observability.js +110 -0
  38. package/dist/server/utils/streaming.js +15 -1
  39. package/dist/server/validation.js +120 -0
  40. package/dist/swagger.js +32 -1
  41. package/package.json +1 -1
  42. package/src/client/components/AiApiRolePermissions.tsx +11 -169
  43. package/src/client/locale.ts +11 -21
  44. package/src/client/plugin.tsx +82 -48
  45. package/src/client-v2/__tests__/settings-registration.test.tsx +58 -0
  46. package/src/client-v2/components/AiApiRolePermissions.tsx +173 -0
  47. package/src/client-v2/locale.ts +21 -0
  48. package/src/client-v2/pages/GeneralPage.tsx +183 -0
  49. package/src/client-v2/pages/ModelMetadataPage.tsx +280 -0
  50. package/src/client-v2/pages/ModelPricingPage.tsx +285 -0
  51. package/src/client-v2/pages/RolePermissionsTab.tsx +14 -0
  52. package/src/client-v2/pages/UsagePage.tsx +248 -0
  53. package/src/client-v2/pages/UserQuotasPage.tsx +258 -0
  54. package/src/client-v2/pages/api.ts +16 -0
  55. package/src/client-v2/plugin.tsx +62 -4
  56. package/src/constants.ts +21 -0
  57. package/src/locale/en-US.json +105 -10
  58. package/src/locale/vi-VN.json +105 -0
  59. package/src/locale/zh-CN.json +105 -10
  60. package/src/server/__tests__/app-observability.test.ts +98 -0
  61. package/src/server/__tests__/billing-quota.test.ts +134 -0
  62. package/src/server/__tests__/billing.test.ts +33 -0
  63. package/src/server/__tests__/models.test.ts +74 -0
  64. package/src/server/__tests__/request-body.test.ts +310 -0
  65. package/src/server/__tests__/streaming-observability.test.ts +51 -0
  66. package/src/server/__tests__/usage-monitor.test.ts +63 -0
  67. package/src/server/__tests__/usage-route.test.ts +4 -0
  68. package/src/server/billing.ts +387 -0
  69. package/src/server/collections/ai-api-config.ts +69 -51
  70. package/src/server/collections/ai-api-model-metadata.ts +72 -0
  71. package/src/server/collections/ai-api-model-prices.ts +25 -0
  72. package/src/server/collections/ai-api-usage-records.ts +9 -0
  73. package/src/server/collections/ai-api-user-quota-buckets.ts +24 -0
  74. package/src/server/collections/ai-api-user-quota-policies.ts +32 -0
  75. package/src/server/plugin.ts +47 -5
  76. package/src/server/resource/ai-api-config.ts +105 -74
  77. package/src/server/resource/ai-api-usage-monitor.ts +74 -0
  78. package/src/server/routes/agent-completions.ts +77 -62
  79. package/src/server/routes/auth.ts +14 -1
  80. package/src/server/routes/chat-completions.ts +275 -6
  81. package/src/server/routes/completions.ts +27 -4
  82. package/src/server/routes/models.ts +290 -195
  83. package/src/server/routes/router.ts +152 -27
  84. package/src/server/usage.ts +19 -1
  85. package/src/server/utils/app-observability.ts +105 -0
  86. package/src/server/utils/streaming.ts +13 -1
  87. package/src/server/validation.ts +89 -0
  88. package/src/swagger.ts +38 -1
  89. package/dist/client/778.5c452944cb747975.js +0 -10
  90. package/dist/client/950.83390c5f1d5a97fb.js +0 -10
  91. package/dist/client-v2/950.42b30b5cc9e32b8f.js +0 -10
  92. package/src/client/AiApiConfigPage.tsx +0 -309
@@ -0,0 +1,310 @@
1
+ import http from 'http';
2
+ import type { AddressInfo } from 'net';
3
+ import { MAX_REQUEST_BODY_MB_LIMIT, getRawBody, normalizeMaxRequestBodyMb } from '../routes/router';
4
+ import { findContentBlockProblem, findMessageProblem, normalizeMessageContent } from '../routes/chat-completions';
5
+
6
+ describe('AI API max request body configuration', () => {
7
+ it('falls back to 10 MB when the configured value is missing or unusable', () => {
8
+ expect(normalizeMaxRequestBodyMb(undefined)).toBe(10);
9
+ expect(normalizeMaxRequestBodyMb(null)).toBe(10);
10
+ expect(normalizeMaxRequestBodyMb('not a number')).toBe(10);
11
+ expect(normalizeMaxRequestBodyMb(0)).toBe(10);
12
+ expect(normalizeMaxRequestBodyMb(-5)).toBe(10);
13
+ expect(normalizeMaxRequestBodyMb(2.5)).toBe(10);
14
+ });
15
+
16
+ it('accepts a raised limit and clamps anything above the ceiling', () => {
17
+ expect(normalizeMaxRequestBodyMb(25)).toBe(25);
18
+ expect(normalizeMaxRequestBodyMb('25')).toBe(25);
19
+ expect(normalizeMaxRequestBodyMb(MAX_REQUEST_BODY_MB_LIMIT)).toBe(MAX_REQUEST_BODY_MB_LIMIT);
20
+ expect(normalizeMaxRequestBodyMb(MAX_REQUEST_BODY_MB_LIMIT + 1)).toBe(MAX_REQUEST_BODY_MB_LIMIT);
21
+ expect(normalizeMaxRequestBodyMb(Number.MAX_SAFE_INTEGER)).toBe(MAX_REQUEST_BODY_MB_LIMIT);
22
+ });
23
+ });
24
+
25
+ /**
26
+ * Drives the real getRawBody over a live socket.
27
+ *
28
+ * The point is the transport, not the arithmetic: ctx.req and the response share
29
+ * one TCP connection, so destroying the request also destroys the reply and the
30
+ * client sees ECONNRESET instead of our 413 JSON. Only an end-to-end socket test
31
+ * can catch that regression, so this must call the production helper rather than
32
+ * a copy of it.
33
+ */
34
+ describe('AI API oversized request handling', () => {
35
+ const MAX_BYTES = 1024;
36
+
37
+ interface Outcome {
38
+ status?: number;
39
+ body: string;
40
+ clientError?: string;
41
+ }
42
+
43
+ async function reply(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
44
+ res.setHeader('Content-Type', 'application/json');
45
+ try {
46
+ const raw = await getRawBody({ req } as Parameters<typeof getRawBody>[0], MAX_BYTES);
47
+ res.statusCode = 200;
48
+ res.end(JSON.stringify({ received: raw.length }));
49
+ } catch (err) {
50
+ const { message, statusCode } = err as Error & { statusCode?: number };
51
+ res.statusCode = statusCode === 413 ? 413 : 400;
52
+ res.end(JSON.stringify({ error: { message, type: 'invalid_request_error' } }));
53
+ }
54
+ }
55
+
56
+ async function post(payloadBytes: number, options: { declareLength?: boolean } = {}): Promise<Outcome> {
57
+ const server = http.createServer(reply);
58
+
59
+ try {
60
+ await new Promise<void>((resolve) => server.listen(0, resolve));
61
+ const { port } = server.address() as AddressInfo;
62
+
63
+ return await new Promise<Outcome>((resolve) => {
64
+ const payload = Buffer.alloc(payloadBytes, 'x');
65
+ const req = http.request(
66
+ {
67
+ port,
68
+ method: 'POST',
69
+ path: '/api/ai-llm/v1/chat/completions',
70
+ headers: options.declareLength === false ? {} : { 'Content-Length': String(payload.length) },
71
+ },
72
+ (res) => {
73
+ let body = '';
74
+ res.on('data', (chunk) => (body += chunk));
75
+ res.on('end', () => resolve({ status: res.statusCode, body }));
76
+ },
77
+ );
78
+ req.on('error', (err: NodeJS.ErrnoException) => resolve({ body: '', clientError: err.code }));
79
+ req.end(payload);
80
+ });
81
+ } finally {
82
+ await new Promise<void>((resolve) => server.close(() => resolve()));
83
+ }
84
+ }
85
+
86
+ it('answers an oversized body with a readable 413 rather than resetting the connection', async () => {
87
+ const response = await post(200 * 1024);
88
+
89
+ expect(response.clientError).toBeUndefined();
90
+ expect(response.status).toBe(413);
91
+ expect(JSON.parse(response.body).error.message).toContain('too large');
92
+ });
93
+
94
+ it('reports 413 for a chunked upload that only exceeds the cap mid-stream', async () => {
95
+ const response = await post(64 * 1024, { declareLength: false });
96
+
97
+ expect(response.clientError).toBeUndefined();
98
+ expect(response.status).toBe(413);
99
+ });
100
+
101
+ it('returns the exact bytes for a body within the limit', async () => {
102
+ const response = await post(512);
103
+
104
+ expect(response.clientError).toBeUndefined();
105
+ expect(response.status).toBe(200);
106
+ expect(JSON.parse(response.body).received).toBe(512);
107
+ });
108
+
109
+ it('accepts a body sitting exactly on the cap', async () => {
110
+ const response = await post(MAX_BYTES);
111
+
112
+ expect(response.status).toBe(200);
113
+ expect(JSON.parse(response.body).received).toBe(MAX_BYTES);
114
+ });
115
+ });
116
+
117
+ describe('AI API multimodal content block validation', () => {
118
+ const wrap = (content: unknown) => [{ role: 'user', content }];
119
+
120
+ it('accepts plain string content and well-formed text/image_url blocks', () => {
121
+ expect(findContentBlockProblem(wrap('Hello'))).toBeUndefined();
122
+ expect(
123
+ findContentBlockProblem(
124
+ wrap([
125
+ { type: 'text', text: 'What is in this picture?' },
126
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' } },
127
+ { type: 'image_url', image_url: { url: 'https://example.com/cat.jpg' } },
128
+ ]),
129
+ ),
130
+ ).toBeUndefined();
131
+ });
132
+
133
+ it('accepts a bare string image_url but rewrites it to the object form every provider converts', () => {
134
+ const block = { type: 'image_url', image_url: 'https://example.com/a.png' };
135
+
136
+ expect(findContentBlockProblem(wrap([block]))).toBeUndefined();
137
+ // `isOpenAIDataBlock` requires `image_url` to be an object, so the string
138
+ // form would otherwise reach the provider unconverted.
139
+ expect(normalizeMessageContent([block])).toEqual([
140
+ { type: 'image_url', image_url: { url: 'https://example.com/a.png' } },
141
+ ]);
142
+ });
143
+
144
+ it('leaves the object form and plain text blocks untouched', () => {
145
+ const blocks = [
146
+ { type: 'text', text: 'hi' },
147
+ { type: 'image_url', image_url: { url: 'https://example.com/a.png' } },
148
+ ];
149
+
150
+ expect(normalizeMessageContent(blocks)).toEqual(blocks);
151
+ expect(normalizeMessageContent('plain')).toBe('plain');
152
+ });
153
+
154
+ it('rejects base64 payloads that match the grammar but cannot be decoded', () => {
155
+ // Each of these passes LangChain's regex and then throws inside `atob`,
156
+ // which used to surface as an HTTP 500.
157
+ for (const payload of ['A===', 'A=', 'AAAAA', 'AAAA=']) {
158
+ const problem = findContentBlockProblem(
159
+ wrap([{ type: 'image_url', image_url: { url: `data:image/png;base64,${payload}` } }]),
160
+ );
161
+
162
+ expect(problem, payload).toBeDefined();
163
+ expect(problem?.reason, payload).toContain('not decodable');
164
+ }
165
+ });
166
+
167
+ it('still accepts correctly padded base64 payloads', () => {
168
+ for (const payload of ['QUJD', 'QQ==', 'QUI=', 'iVBORw0KGgo=']) {
169
+ expect(
170
+ findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: `data:image/png;base64,${payload}` } }])),
171
+ payload,
172
+ ).toBeUndefined();
173
+ }
174
+ });
175
+
176
+ it('rejects an unsupported block type and names it', () => {
177
+ const problem = findContentBlockProblem([
178
+ { role: 'system', content: 'You are helpful.' },
179
+ { role: 'user', content: [{ type: 'file', file: { file_data: 'data:application/pdf;base64,JVBERi0=' } }] },
180
+ ]);
181
+
182
+ expect(problem?.index).toBe(1);
183
+ expect(problem?.reason).toContain("'file' is not supported");
184
+ });
185
+
186
+ it('rejects a text block with no text payload', () => {
187
+ expect(findContentBlockProblem(wrap([{ type: 'text' }]))?.reason).toContain("requires a string 'text' field");
188
+ });
189
+
190
+ it('rejects an image_url block with no url', () => {
191
+ expect(findContentBlockProblem(wrap([{ type: 'image_url' }]))?.reason).toContain(
192
+ "requires a non-empty 'image_url.url'",
193
+ );
194
+ expect(findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: '' } }]))?.reason).toContain(
195
+ "requires a non-empty 'image_url.url'",
196
+ );
197
+ });
198
+
199
+ it('rejects a non-image data URL that the provider would treat as an image', () => {
200
+ const problem = findContentBlockProblem(
201
+ wrap([{ type: 'image_url', image_url: { url: 'data:application/pdf;base64,JVBERi0=' } }]),
202
+ );
203
+
204
+ expect(problem?.reason).toContain('application/pdf');
205
+ expect(problem?.reason).toContain('not an image');
206
+ });
207
+
208
+ it('rejects a malformed base64 data URL instead of letting the adapter throw a 500', () => {
209
+ for (const url of [
210
+ 'data:image/png;base64,iVBORw0 KGgo=',
211
+ 'data:image/png;base64,iVBORw0-KGgo=',
212
+ 'data:image/png,notbase64',
213
+ 'data:image/svg+xml;base64,PHN2Zz4=',
214
+ ]) {
215
+ const problem = findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url } }]));
216
+ expect(problem, url).toBeDefined();
217
+ }
218
+ });
219
+
220
+ it('rejects a non-http(s) URL protocol', () => {
221
+ expect(
222
+ findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: 'ftp://example.com/a.png' } }]))?.reason,
223
+ ).toContain("protocol 'ftp:'");
224
+ expect(
225
+ findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: 'file:///etc/passwd' } }]))?.reason,
226
+ ).toContain("protocol 'file:'");
227
+ });
228
+
229
+ it('rejects a garbage url string', () => {
230
+ expect(findContentBlockProblem(wrap([{ type: 'image_url', image_url: { url: 'not a url' } }]))?.reason).toContain(
231
+ 'not a valid URL',
232
+ );
233
+ });
234
+
235
+ it('rejects a block that is not an object or has no type', () => {
236
+ expect(findContentBlockProblem(wrap([42]))?.reason).toContain('must be an object');
237
+ expect(findContentBlockProblem(wrap([{ text: 'no type field' }]))?.reason).toContain("requires a 'type' field");
238
+ });
239
+
240
+ it('ignores messages whose content is not an array', () => {
241
+ expect(findContentBlockProblem([{ role: 'assistant', content: null }, { role: 'tool' }])).toBeUndefined();
242
+ });
243
+ });
244
+
245
+ /**
246
+ * `findContentBlockProblem` only inspects array content, so these malformed
247
+ * messages used to reach `messages.some((m) => m.role === ...)` — a TypeError
248
+ * reported as HTTP 500 — or died inside LangChain with MESSAGE_COERCION_FAILURE.
249
+ */
250
+ describe('AI API message schema validation', () => {
251
+ it('rejects a non-object message instead of throwing on m.role', () => {
252
+ expect(findMessageProblem([null])?.reason).toContain('must be an object');
253
+ expect(findMessageProblem([null])?.index).toBe(0);
254
+ expect(findMessageProblem(['hello'])?.reason).toContain('must be an object');
255
+ expect(findMessageProblem([42])?.reason).toContain('must be an object');
256
+ });
257
+
258
+ it('names the offending index', () => {
259
+ const problem = findMessageProblem([{ role: 'user', content: 'ok' }, null]);
260
+
261
+ expect(problem?.index).toBe(1);
262
+ });
263
+
264
+ it('rejects a missing or unsupported role before LangChain coerces it', () => {
265
+ expect(findMessageProblem([{ content: 'no role' }])?.reason).toContain("requires a string 'role'");
266
+ expect(findMessageProblem([{ role: 'function', content: 'x' }])?.reason).toContain(
267
+ "role 'function' is not supported",
268
+ );
269
+ expect(findMessageProblem([{ role: 'moderator', content: 'x' }])?.reason).toContain(
270
+ "role 'moderator' is not supported",
271
+ );
272
+ });
273
+
274
+ it('accepts every role LangChain can coerce', () => {
275
+ for (const role of ['system', 'developer', 'user', 'human', 'assistant', 'ai']) {
276
+ expect(findMessageProblem([{ role, content: 'hi' }]), role).toBeUndefined();
277
+ }
278
+ expect(findMessageProblem([{ role: 'tool', content: 'out', tool_call_id: 'call_1' }])).toBeUndefined();
279
+ });
280
+
281
+ it('requires tool_call_id on a tool message', () => {
282
+ expect(findMessageProblem([{ role: 'tool', content: 'out' }])?.reason).toContain(
283
+ "requires a string 'tool_call_id'",
284
+ );
285
+ });
286
+
287
+ it('requires content unless an assistant turn only carries tool_calls', () => {
288
+ expect(findMessageProblem([{ role: 'user' }])?.reason).toContain("requires a 'content' field");
289
+ expect(findMessageProblem([{ role: 'assistant', content: null }])?.reason).toContain("requires a 'content' field");
290
+ expect(
291
+ findMessageProblem([{ role: 'assistant', tool_calls: [{ id: 'call_1', type: 'function', function: {} }] }]),
292
+ ).toBeUndefined();
293
+ });
294
+
295
+ it('rejects a content value that is neither string nor array', () => {
296
+ expect(findMessageProblem([{ role: 'user', content: 42 }])?.reason).toContain('must be a string or an array');
297
+ expect(findMessageProblem([{ role: 'user', content: { text: 'x' } }])?.reason).toContain(
298
+ 'must be a string or an array',
299
+ );
300
+ });
301
+
302
+ it('accepts a well-formed multimodal conversation', () => {
303
+ expect(
304
+ findMessageProblem([
305
+ { role: 'system', content: 'You are helpful.' },
306
+ { role: 'user', content: [{ type: 'text', text: 'What is this?' }] },
307
+ ]),
308
+ ).toBeUndefined();
309
+ });
310
+ });
@@ -0,0 +1,51 @@
1
+ import type { Context } from '@nocobase/actions';
2
+ import { AiApiClientDisconnectedError, createRequestAbortController, isClientDisconnected } from '../utils/streaming';
3
+
4
+ class ListenerTarget {
5
+ private listeners = new Map<string, Set<() => void>>();
6
+ aborted = false;
7
+ writableEnded = false;
8
+
9
+ once(event: string, listener: () => void) {
10
+ const wrapped = () => {
11
+ this.off(event, wrapped);
12
+ listener();
13
+ };
14
+ const group = this.listeners.get(event) ?? new Set();
15
+ group.add(wrapped);
16
+ this.listeners.set(event, group);
17
+ }
18
+
19
+ off(event: string, listener: () => void) {
20
+ this.listeners.get(event)?.delete(listener);
21
+ }
22
+
23
+ emit(event: string) {
24
+ for (const listener of [...(this.listeners.get(event) ?? [])]) listener();
25
+ }
26
+ }
27
+
28
+ describe('AI API streaming cancellation classification', () => {
29
+ it('uses a recognizable client-disconnect abort reason', () => {
30
+ const req = new ListenerTarget();
31
+ const res = new ListenerTarget();
32
+ const ctx = { req, res } as unknown as Context;
33
+ const controller = createRequestAbortController(ctx);
34
+
35
+ req.aborted = true;
36
+ req.emit('aborted');
37
+
38
+ expect(controller.signal.aborted).toBe(true);
39
+ expect(controller.signal.reason).toBeInstanceOf(AiApiClientDisconnectedError);
40
+ expect(isClientDisconnected(ctx, controller.signal.reason)).toBe(true);
41
+ controller.dispose();
42
+ });
43
+
44
+ it('does not treat an unrelated provider abort as client cancellation', () => {
45
+ const req = new ListenerTarget();
46
+ const res = new ListenerTarget();
47
+ const ctx = { req, res } as unknown as Context;
48
+
49
+ expect(isClientDisconnected(ctx, new DOMException('Provider timeout', 'AbortError'))).toBe(false);
50
+ });
51
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { Context } from '@nocobase/actions';
3
+ import { Op } from 'sequelize';
4
+ import aiApiUsageMonitorResource from '../resource/ai-api-usage-monitor';
5
+
6
+ describe('AI API usage monitor summary', () => {
7
+ it('aggregates filtered token and cost totals', async () => {
8
+ const findOne = vi.fn().mockResolvedValue({
9
+ requestCount: '3',
10
+ inputTokens: '100',
11
+ outputTokens: '25',
12
+ totalTokens: '125',
13
+ });
14
+ const findAll = vi.fn().mockResolvedValue([
15
+ { currency: 'USD', totalCost: '0.12500000' },
16
+ { currency: 'EUR', totalCost: '0.05000000' },
17
+ ]);
18
+ const context = {
19
+ action: {
20
+ params: {
21
+ start: '2026-08-01T00:00:00.000Z',
22
+ end: '2026-08-01T23:59:59.999Z',
23
+ userId: 7,
24
+ resolvedService: 'custom-llm',
25
+ resolvedModel: 'model-a',
26
+ status: 'succeeded',
27
+ },
28
+ },
29
+ db: {
30
+ getCollection: () => ({ model: { findOne, findAll } }),
31
+ },
32
+ body: undefined,
33
+ } as unknown as Context;
34
+ const next = vi.fn();
35
+ const summary = aiApiUsageMonitorResource.actions?.summary;
36
+ if (typeof summary !== 'function') throw new Error('summary action is not registered');
37
+
38
+ await summary(context, next);
39
+
40
+ const totalsQuery = findOne.mock.calls[0][0];
41
+ expect(totalsQuery.where).toEqual(
42
+ expect.objectContaining({
43
+ userId: 7,
44
+ resolvedService: 'custom-llm',
45
+ resolvedModel: 'model-a',
46
+ status: 'succeeded',
47
+ }),
48
+ );
49
+ expect(totalsQuery.where.startedAt[Op.gte]).toEqual(new Date('2026-08-01T00:00:00.000Z'));
50
+ expect(totalsQuery.where.startedAt[Op.lte]).toEqual(new Date('2026-08-01T23:59:59.999Z'));
51
+ expect(context.body).toEqual({
52
+ requestCount: 3,
53
+ inputTokens: 100,
54
+ outputTokens: 25,
55
+ totalTokens: 125,
56
+ costsByCurrency: [
57
+ { currency: 'USD', totalCost: '0.12500000' },
58
+ { currency: 'EUR', totalCost: '0.05000000' },
59
+ ],
60
+ });
61
+ expect(next).toHaveBeenCalledOnce();
62
+ });
63
+ });
@@ -79,6 +79,10 @@ describe('AI API chat usage collection', () => {
79
79
  source: 'unavailable',
80
80
  gatewayResponseId: expect.stringMatching(/^chatcmpl-/),
81
81
  });
82
+ expect(ctx.state.aiApiLlmBilling).toMatchObject({
83
+ resolution: { service: 'test-service', provider: 'test-provider', model: 'test-model' },
84
+ providerAttempted: true,
85
+ });
82
86
  });
83
87
 
84
88
  it('stores provider usage and provider request ID separately from the gateway response ID', async () => {