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,299 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { OpenAIUsage } from '../utils/openai-format';
3
+ import {
4
+ chatResultToResponse,
5
+ extractReasoningText,
6
+ extractResponseIncompleteReason,
7
+ extractResponseOutputText,
8
+ responsesBodyToChatBody,
9
+ responsesInputToMessages,
10
+ } from '../utils/responses-format';
11
+ import {
12
+ appendResponseStreamChunk,
13
+ createResponseStartEvents,
14
+ createResponseStreamState,
15
+ finalizeResponseStream,
16
+ setResponseStreamUsage,
17
+ } from '../utils/responses-stream';
18
+
19
+ describe('Responses API wire format', () => {
20
+ it('converts text, image, file, and function output input items', () => {
21
+ expect(
22
+ responsesInputToMessages(
23
+ [
24
+ {
25
+ type: 'message',
26
+ role: 'user',
27
+ content: [
28
+ { type: 'input_text', text: 'Describe this' },
29
+ { type: 'input_image', image_url: 'https://example.com/image.png', detail: 'high' },
30
+ { type: 'input_file', file_url: 'https://example.com/file.pdf' },
31
+ ],
32
+ },
33
+ { type: 'function_call_output', call_id: 'call_1', output: 'sunny' },
34
+ ],
35
+ 'Be concise',
36
+ ),
37
+ ).toEqual([
38
+ { role: 'developer', content: 'Be concise' },
39
+ {
40
+ role: 'user',
41
+ content: [
42
+ { type: 'text', text: 'Describe this' },
43
+ { type: 'image_url', image_url: { url: 'https://example.com/image.png', detail: 'high' } },
44
+ { type: 'file_url', file_url: { url: 'https://example.com/file.pdf' } },
45
+ ],
46
+ },
47
+ { role: 'tool', content: 'sunny', tool_call_id: 'call_1' },
48
+ ]);
49
+ });
50
+
51
+ it('preserves reasoning and parallel function-call ordering for continuation', () => {
52
+ expect(
53
+ responsesInputToMessages([
54
+ { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'Weather?' }] },
55
+ {
56
+ type: 'reasoning',
57
+ id: 'rs_1',
58
+ summary: [],
59
+ content: [{ type: 'reasoning_text', text: 'Call both tools' }],
60
+ },
61
+ { type: 'function_call', call_id: 'call_1', name: 'weather', arguments: '{"city":"Hanoi"}' },
62
+ { type: 'function_call', call_id: 'call_2', name: 'time', arguments: '{"city":"Hanoi"}' },
63
+ { type: 'function_call_output', call_id: 'call_1', output: 'sunny' },
64
+ ]),
65
+ ).toEqual([
66
+ { role: 'user', content: [{ type: 'text', text: 'Weather?' }] },
67
+ expect.objectContaining({
68
+ role: 'assistant',
69
+ tool_calls: [expect.objectContaining({ id: 'call_1' }), expect.objectContaining({ id: 'call_2' })],
70
+ additional_kwargs: expect.objectContaining({ reasoning_content: 'Call both tools' }),
71
+ }),
72
+ { role: 'tool', content: 'sunny', tool_call_id: 'call_1' },
73
+ ]);
74
+ });
75
+
76
+ it('maps Responses function tools and advanced request parameters', () => {
77
+ expect(
78
+ responsesBodyToChatBody({
79
+ model: 'service/model',
80
+ input: 'Hello',
81
+ max_output_tokens: 100,
82
+ service_tier: 'flex',
83
+ prompt_cache_key: 'cache-key',
84
+ prompt_cache_retention: '24h',
85
+ reasoning: { effort: 'medium' },
86
+ text: {
87
+ format: { type: 'json_schema', name: 'answer', schema: { type: 'object' }, strict: true },
88
+ verbosity: 'low',
89
+ },
90
+ tools: [{ type: 'function', name: 'lookup', parameters: { type: 'object' } }],
91
+ tool_choice: { type: 'function', name: 'lookup' },
92
+ }),
93
+ ).toMatchObject({
94
+ max_completion_tokens: 100,
95
+ service_tier: 'flex',
96
+ promptCacheKey: 'cache-key',
97
+ promptCacheRetention: '24h',
98
+ reasoning: { effort: 'medium' },
99
+ response_format: {
100
+ type: 'json_schema',
101
+ json_schema: { name: 'answer', schema: { type: 'object' }, strict: true },
102
+ },
103
+ verbosity: 'low',
104
+ tools: [{ type: 'function', function: { name: 'lookup', parameters: { type: 'object' } } }],
105
+ tool_choice: { type: 'function', function: { name: 'lookup' } },
106
+ });
107
+ });
108
+
109
+ it('returns the OpenAI Responses output message and usage schema', () => {
110
+ const response = chatResultToResponse({
111
+ id: 'resp_test',
112
+ model: 'service/model',
113
+ content: 'Hello',
114
+ reasoningText: 'Reasoning summary',
115
+ serviceTier: 'priority',
116
+ usage: {
117
+ prompt_tokens: 10,
118
+ completion_tokens: 5,
119
+ total_tokens: 15,
120
+ prompt_cache_tokens: 2,
121
+ reasoning_tokens: 3,
122
+ },
123
+ requestBody: { input: 'Hi', service_tier: 'flex' },
124
+ });
125
+
126
+ expect(response).toMatchObject({
127
+ id: 'resp_test',
128
+ object: 'response',
129
+ created_at: expect.any(Number),
130
+ output_text: 'Hello',
131
+ service_tier: 'priority',
132
+ usage: {
133
+ input_tokens: 10,
134
+ input_tokens_details: { cached_tokens: 2 },
135
+ output_tokens: 5,
136
+ output_tokens_details: { reasoning_tokens: 3 },
137
+ total_tokens: 15,
138
+ },
139
+ output: [
140
+ { type: 'reasoning', content: [{ type: 'reasoning_text', text: 'Reasoning summary' }] },
141
+ {
142
+ type: 'message',
143
+ role: 'assistant',
144
+ content: [{ type: 'output_text', text: 'Hello', annotations: [] }],
145
+ },
146
+ ],
147
+ });
148
+ });
149
+
150
+ it('extracts native LangChain Responses text and reasoning blocks', () => {
151
+ const result = {
152
+ content: [
153
+ { type: 'reasoning', reasoning: 'step one' },
154
+ { type: 'reasoning', reasoning: ' and two' },
155
+ { type: 'text', text: 'visible ' },
156
+ { type: 'output_text', text: 'answer' },
157
+ ],
158
+ };
159
+
160
+ expect(extractReasoningText(result)).toBe('step one and two');
161
+ expect(extractResponseOutputText(result)).toBe('visible answer');
162
+ expect(
163
+ extractReasoningText({
164
+ additional_kwargs: {
165
+ reasoning: {
166
+ type: 'reasoning',
167
+ summary: [{ type: 'summary_text', text: 'summary' }],
168
+ },
169
+ },
170
+ }),
171
+ ).toBe('summary');
172
+ });
173
+
174
+ it('uses OpenAI terminal fields for incomplete responses', () => {
175
+ const response = chatResultToResponse({
176
+ id: 'resp_incomplete',
177
+ model: 'service/model',
178
+ content: 'Filtered',
179
+ finishReason: 'content_filter',
180
+ requestBody: { input: 'Hi' },
181
+ });
182
+
183
+ expect(response).toMatchObject({
184
+ status: 'incomplete',
185
+ completed_at: null,
186
+ incomplete_details: { reason: 'content_filter' },
187
+ });
188
+ });
189
+
190
+ it('reads incomplete details emitted by native Responses providers', () => {
191
+ const response = chatResultToResponse({
192
+ model: 'service/model',
193
+ content: 'Partial',
194
+ finishReason: extractResponseIncompleteReason({
195
+ response_metadata: { incomplete_details: { reason: 'max_output_tokens' } },
196
+ }),
197
+ requestBody: { input: 'Hello' },
198
+ });
199
+
200
+ expect(response).toMatchObject({
201
+ status: 'incomplete',
202
+ completed_at: null,
203
+ incomplete_details: { reason: 'max_output_tokens' },
204
+ });
205
+ });
206
+ });
207
+
208
+ describe('Responses API streaming wire format', () => {
209
+ it('emits ordered lifecycle events with sequence numbers', () => {
210
+ const state = createResponseStreamState('resp_stream', 'service/model', { input: 'Hello' });
211
+ const events = [
212
+ ...createResponseStartEvents(state),
213
+ ...appendResponseStreamChunk(state, { content: 'Hel' }),
214
+ ...appendResponseStreamChunk(state, { content: 'lo', response_metadata: { finish_reason: 'stop' } }),
215
+ ];
216
+ setResponseStreamUsage(state, {
217
+ prompt_tokens: 10,
218
+ completion_tokens: 2,
219
+ total_tokens: 12,
220
+ } as OpenAIUsage);
221
+ events.push(...finalizeResponseStream(state));
222
+
223
+ expect(events.map((event) => event.type)).toEqual([
224
+ 'response.created',
225
+ 'response.in_progress',
226
+ 'response.output_item.added',
227
+ 'response.content_part.added',
228
+ 'response.output_text.delta',
229
+ 'response.output_text.delta',
230
+ 'response.output_text.done',
231
+ 'response.content_part.done',
232
+ 'response.output_item.done',
233
+ 'response.completed',
234
+ ]);
235
+ expect(events.map((event) => event.sequence_number)).toEqual(events.map((_, index) => index));
236
+ expect(events.at(-1)).toMatchObject({
237
+ type: 'response.completed',
238
+ response: { output_text: 'Hello', usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } },
239
+ });
240
+ });
241
+
242
+ it('emits function call argument events', () => {
243
+ const state = createResponseStreamState('resp_tool', 'service/model', { input: 'Weather?' });
244
+ createResponseStartEvents(state);
245
+ const events = [
246
+ ...appendResponseStreamChunk(state, {
247
+ tool_call_chunks: [{ index: 0, id: 'call_1', name: 'weather', args: '{"city"' }],
248
+ }),
249
+ ...appendResponseStreamChunk(state, {
250
+ tool_call_chunks: [{ index: 0, args: ':"Hanoi"}' }],
251
+ }),
252
+ ...finalizeResponseStream(state),
253
+ ];
254
+
255
+ expect(events.map((event) => event.type)).toContain('response.function_call_arguments.delta');
256
+ expect(events).toContainEqual(
257
+ expect.objectContaining({
258
+ type: 'response.function_call_arguments.done',
259
+ name: 'weather',
260
+ arguments: '{"city":"Hanoi"}',
261
+ }),
262
+ );
263
+ });
264
+
265
+ it('reads an alternate finish-reason location and leaves incomplete responses without completed_at', () => {
266
+ const state = createResponseStreamState('resp_incomplete', 'service/model', { input: 'Hello' });
267
+ createResponseStartEvents(state);
268
+ appendResponseStreamChunk(state, {
269
+ content: [{ type: 'output_text', text: 'Partial' }],
270
+ additional_kwargs: { finish_reason: 'max_tokens' },
271
+ });
272
+ const events = finalizeResponseStream(state);
273
+
274
+ expect(events.at(-1)).toMatchObject({
275
+ type: 'response.incomplete',
276
+ response: {
277
+ status: 'incomplete',
278
+ completed_at: null,
279
+ incomplete_details: { reason: 'max_output_tokens' },
280
+ output_text: 'Partial',
281
+ },
282
+ });
283
+ });
284
+
285
+ it('reads native Responses incomplete details while streaming', () => {
286
+ const state = createResponseStreamState('resp_native_incomplete', 'service/model', { input: 'Hello' });
287
+ createResponseStartEvents(state);
288
+ appendResponseStreamChunk(state, {
289
+ content: 'Partial',
290
+ response_metadata: { incomplete_details: { reason: 'content_filter' } },
291
+ });
292
+ const events = finalizeResponseStream(state);
293
+
294
+ expect(events.at(-1)).toMatchObject({
295
+ type: 'response.incomplete',
296
+ response: { incomplete_details: { reason: 'content_filter' } },
297
+ });
298
+ });
299
+ });
@@ -0,0 +1,182 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest';
12
+ import type PluginAiApiServer from '../plugin';
13
+ import { createAiLlmRouter } from '../routes/router';
14
+ import { handleDeleteResponse, handleGetResponse, handleResponses } from '../routes/responses';
15
+ import { handleGetModel } from '../routes/models';
16
+ import { finishUsageRecord, startUsageRecord } from '../usage';
17
+ import { finalizeLlmBilling } from '../billing';
18
+ import { finishAiApiObservation, startAiApiObservation } from '../utils/app-observability';
19
+
20
+ vi.mock('../routes/auth', () => ({ authenticateBearer: vi.fn().mockResolvedValue(true) }));
21
+ vi.mock('../middleware/role-permission', () => ({ checkRolePermission: vi.fn().mockResolvedValue(true) }));
22
+ vi.mock('../middleware/rate-limit', () => ({
23
+ createRateLimitMiddleware: vi.fn(() => vi.fn().mockResolvedValue(true)),
24
+ }));
25
+ vi.mock('../routes/chat-completions', () => ({ handleChatCompletions: vi.fn() }));
26
+ vi.mock('../routes/completions', () => ({ handleCompletions: vi.fn() }));
27
+ vi.mock('../routes/agent-completions', () => ({ handleAgentCompletions: vi.fn() }));
28
+ vi.mock('../routes/embeddings', () => ({ handleEmbeddings: vi.fn() }));
29
+ vi.mock('../routes/models', () => ({ handleGetModel: vi.fn(), handleListModels: vi.fn() }));
30
+ vi.mock('../routes/responses', () => ({
31
+ handleResponses: vi.fn(),
32
+ handleGetResponse: vi.fn(),
33
+ handleDeleteResponse: vi.fn(),
34
+ }));
35
+ vi.mock('../usage', () => ({
36
+ startUsageRecord: vi.fn().mockResolvedValue(91),
37
+ // Simulate the real behavior: finishUsageRecord calls finalizeLlmBilling internally.
38
+ finishUsageRecord: vi.fn().mockImplementation(async (ctx, id, startedAt, status) => {
39
+ const { finalizeLlmBilling } = await import('../billing');
40
+ await finalizeLlmBilling(ctx, undefined, status === 'succeeded');
41
+ }),
42
+ }));
43
+ vi.mock('../billing', () => ({ finalizeLlmBilling: vi.fn().mockResolvedValue(undefined) }));
44
+ vi.mock('../utils/request-cache', () => ({ getAiApiConfig: vi.fn() }));
45
+ vi.mock('../utils/app-observability', () => ({
46
+ startAiApiObservation: vi.fn(),
47
+ finishAiApiObservation: vi.fn(),
48
+ }));
49
+
50
+ function createContext(method: 'POST' | 'GET' | 'DELETE', subPath: string, body?: Record<string, unknown>) {
51
+ const path = `/api/ai-llm/v1${subPath}`;
52
+ return {
53
+ path,
54
+ method,
55
+ request: { path, method, body },
56
+ state: { currentUser: { id: 42 } },
57
+ app: { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } },
58
+ log: { error: vi.fn() },
59
+ res: { headersSent: false },
60
+ get: vi.fn().mockReturnValue(''),
61
+ set: vi.fn(),
62
+ status: 0,
63
+ } as unknown as Context;
64
+ }
65
+
66
+ beforeEach(() => {
67
+ vi.clearAllMocks();
68
+ vi.mocked(startUsageRecord).mockResolvedValue(91);
69
+ vi.mocked(handleResponses).mockImplementation(async (ctx) => {
70
+ ctx.status = 200;
71
+ });
72
+ vi.mocked(handleGetResponse).mockImplementation(async (ctx) => {
73
+ ctx.status = 200;
74
+ });
75
+ vi.mocked(handleDeleteResponse).mockImplementation(async (ctx) => {
76
+ ctx.status = 204;
77
+ });
78
+ });
79
+
80
+ describe('Responses API router integration', () => {
81
+ it.each([
82
+ [undefined, false],
83
+ [false, false],
84
+ [true, true],
85
+ ])('dispatches POST /responses with stream=%s and direct LLM accounting', async (stream, expectedStreaming) => {
86
+ const body = { model: 'service/model', input: 'Hello', ...(stream === undefined ? {} : { stream }) };
87
+ const ctx = createContext('POST', '/responses', body);
88
+ const next = vi.fn() as unknown as Next;
89
+ const plugin = { rateLimiter: {} } as PluginAiApiServer;
90
+
91
+ await createAiLlmRouter(plugin)(ctx, next);
92
+
93
+ expect(next).not.toHaveBeenCalled();
94
+ expect(handleResponses).toHaveBeenCalledWith(ctx, plugin);
95
+ expect(startUsageRecord).toHaveBeenCalledWith(
96
+ ctx,
97
+ expect.stringMatching(/^req-/),
98
+ '/responses',
99
+ 'service/model',
100
+ expectedStreaming,
101
+ 'llm',
102
+ );
103
+ expect(finishUsageRecord).toHaveBeenCalledWith(ctx, 91, expect.any(Number), 'succeeded');
104
+ expect(startAiApiObservation).toHaveBeenCalledWith(ctx, {
105
+ service: 'llm.responses',
106
+ operation: '/responses',
107
+ streaming: expectedStreaming,
108
+ model: 'service/model',
109
+ mode: 'llm',
110
+ });
111
+ expect(finishAiApiObservation).toHaveBeenCalledWith(ctx, { status: 'succeeded', errorCode: undefined });
112
+ });
113
+
114
+ it.each([
115
+ ['GET', handleGetResponse, 200],
116
+ ['DELETE', handleDeleteResponse, 204],
117
+ ] as const)('dispatches %s /responses/:id without creating an LLM usage record', async (method, handler, status) => {
118
+ const ctx = createContext(method, '/responses/resp_saved');
119
+ const plugin = { rateLimiter: {} } as PluginAiApiServer;
120
+
121
+ await createAiLlmRouter(plugin)(ctx, vi.fn() as unknown as Next);
122
+
123
+ expect(handler).toHaveBeenCalledWith(ctx, 'resp_saved');
124
+ expect(ctx.status).toBe(status);
125
+ expect(startUsageRecord).not.toHaveBeenCalled();
126
+ expect(finishUsageRecord).not.toHaveBeenCalled();
127
+ expect(startAiApiObservation).not.toHaveBeenCalled();
128
+ expect(finishAiApiObservation).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('does not treat nested response subpaths as stored response IDs', async () => {
132
+ const ctx = createContext('GET', '/responses/resp_saved/input_items');
133
+ const plugin = { rateLimiter: {} } as PluginAiApiServer;
134
+
135
+ await createAiLlmRouter(plugin)(ctx, vi.fn() as unknown as Next);
136
+
137
+ expect(ctx.status).toBe(404);
138
+ expect(ctx.body).toMatchObject({ error: { code: 'unknown_url' } });
139
+ expect(handleGetResponse).not.toHaveBeenCalled();
140
+ expect(startUsageRecord).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it('finalizes embeddings billing exactly once through the usage record path', async () => {
144
+ // Regression for the double-finalize bug: handleEmbeddings used to call
145
+ // finalizeLlmBilling explicitly AND the router's finally block called it again
146
+ // via finishUsageRecord, double-decrementing quota buckets. The handler must not
147
+ // finalize on its own — the router path is the single finalization point.
148
+ const { handleEmbeddings } = await import('../routes/embeddings');
149
+ vi.mocked(handleEmbeddings).mockImplementation(async (ctx) => {
150
+ ctx.status = 200;
151
+ });
152
+ const ctx = createContext('POST', '/embeddings', { model: 'service/model', input: 'Hello' });
153
+ const plugin = { rateLimiter: {} } as PluginAiApiServer;
154
+
155
+ await createAiLlmRouter(plugin)(ctx, vi.fn() as unknown as Next);
156
+
157
+ expect(handleEmbeddings).toHaveBeenCalledWith(ctx, plugin);
158
+ expect(startUsageRecord).toHaveBeenCalledWith(
159
+ ctx,
160
+ expect.stringMatching(/^req-/),
161
+ '/embeddings',
162
+ 'service/model',
163
+ false,
164
+ 'llm',
165
+ );
166
+ expect(finishUsageRecord).toHaveBeenCalledWith(ctx, 91, expect.any(Number), 'succeeded');
167
+ // finalizeLlmBilling is only reachable through finishUsageRecord — the handler
168
+ // must not call it directly.
169
+ expect(finalizeLlmBilling).toHaveBeenCalledTimes(1);
170
+ });
171
+
172
+ it('returns 400 for a model id with invalid URL encoding', async () => {
173
+ const ctx = createContext('GET', '/models/%ZZ');
174
+ const plugin = { rateLimiter: {} } as PluginAiApiServer;
175
+
176
+ await createAiLlmRouter(plugin)(ctx, vi.fn() as unknown as Next);
177
+
178
+ expect(ctx.status).toBe(400);
179
+ expect(ctx.body).toMatchObject({ error: { type: 'invalid_request_error' } });
180
+ expect(handleGetModel).not.toHaveBeenCalled();
181
+ });
182
+ });