plugin-ai-api 1.1.0 → 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.
- package/README.md +51 -12
- package/dist/client/185.c47663fefaeb0e5b.js +10 -0
- package/dist/client/562.9012cfd1fa04303d.js +10 -0
- package/dist/client/685.b5b1e0a5b825d253.js +10 -0
- package/dist/client/97.1bc5103fd9d995a8.js +10 -0
- package/dist/client/index.js +1 -1
- package/dist/client-v2/185.b552dc91ec2371ba.js +10 -0
- package/dist/client-v2/562.db2984167250b1be.js +10 -0
- package/dist/client-v2/685.cf16e5b829e06f85.js +10 -0
- package/dist/client-v2/97.96da323832251796.js +10 -0
- package/dist/client-v2/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +38 -1
- package/dist/locale/vi-VN.json +177 -138
- package/dist/locale/zh-CN.json +177 -138
- package/dist/server/billing.js +17 -9
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-model-prices.js +8 -0
- package/dist/server/collections/ai-api-response-records.js +101 -0
- package/dist/server/collections/ai-api-usage-records.js +1 -0
- package/dist/server/collections/ai-api-virtual-models.js +68 -0
- package/dist/server/middleware/response-record-resource.js +66 -0
- package/dist/server/middleware/role-permission.js +43 -18
- package/dist/server/migrations/20260827000000-add-cache-input-price.js +66 -0
- package/dist/server/migrations/20260901000000-remove-default-group-members.js +60 -0
- package/dist/server/migrations/20260902000000-seed-default-role-permissions.js +55 -0
- package/dist/server/migrations/20260903000000-seed-sample-response-records.js +170 -0
- package/dist/server/plugin.js +66 -16
- package/dist/server/routes/auth.js +16 -0
- package/dist/server/routes/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +34 -5
- package/dist/server/routes/models.js +29 -0
- package/dist/server/routes/responses.js +530 -0
- package/dist/server/routes/router.js +65 -10
- package/dist/server/usage.js +26 -4
- package/dist/server/utils/direct-llm-context.js +10 -9
- package/dist/server/utils/resolve-service.js +24 -0
- package/dist/server/utils/response-store.js +138 -0
- package/dist/server/utils/responses-format.js +686 -0
- package/dist/server/utils/responses-stream.js +330 -0
- package/dist/server/utils/virtual-models.js +238 -0
- package/dist/server/validation.js +45 -2
- package/dist/swagger.js +137 -0
- package/package.json +34 -32
- package/src/__tests__/locale.test.ts +43 -0
- package/src/client/__tests__/settings-registration.test.tsx +1 -0
- package/src/client/index.tsx +10 -10
- package/src/client/models/index.ts +12 -12
- package/src/client/plugin.tsx +9 -1
- package/src/client-v2/__tests__/settings-registration.test.tsx +1 -0
- package/src/client-v2/pages/ModelMetadataPage.tsx +44 -0
- package/src/client-v2/pages/ModelPricingPage.tsx +15 -0
- package/src/client-v2/pages/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/index.ts +11 -11
- package/src/locale/en-US.json +38 -1
- package/src/locale/vi-VN.json +177 -138
- package/src/locale/zh-CN.json +177 -138
- package/src/server/__tests__/billing-quota.test.ts +8 -2
- package/src/server/__tests__/billing.test.ts +13 -0
- package/src/server/__tests__/embeddings.test.ts +184 -0
- package/src/server/__tests__/models.test.ts +21 -1
- package/src/server/__tests__/response-record-resource.test.ts +50 -0
- package/src/server/__tests__/response-store-integration.test.ts +341 -0
- package/src/server/__tests__/response-store.test.ts +195 -0
- package/src/server/__tests__/responses-contract.test.ts +469 -0
- package/src/server/__tests__/responses-format.test.ts +299 -0
- package/src/server/__tests__/responses-router.test.ts +182 -0
- package/src/server/__tests__/responses-streaming.test.ts +368 -0
- package/src/server/__tests__/responses.test.ts +462 -0
- package/src/server/__tests__/role-permission.test.ts +139 -0
- package/src/server/__tests__/seed-role-permission.test.ts +88 -0
- package/src/server/__tests__/types/responses-sdk.types.test-d.ts +23 -0
- package/src/server/__tests__/usage-groups.test.ts +96 -0
- package/src/server/__tests__/usage-route.test.ts +1 -0
- package/src/server/__tests__/usage.test.ts +15 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/billing.ts +29 -6
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-model-prices.ts +8 -0
- package/src/server/collections/ai-api-response-records.ts +77 -0
- package/src/server/collections/ai-api-usage-records.ts +1 -0
- package/src/server/collections/ai-api-virtual-models.ts +58 -0
- package/src/server/middleware/response-record-resource.ts +44 -0
- package/src/server/middleware/role-permission.ts +69 -35
- package/src/server/migrations/20260827000000-add-cache-input-price.ts +49 -0
- package/src/server/migrations/20260901000000-remove-default-group-members.ts +56 -0
- package/src/server/migrations/20260902000000-seed-default-role-permissions.ts +46 -0
- package/src/server/migrations/20260903000000-seed-sample-response-records.ts +162 -0
- package/src/server/plugin.ts +84 -20
- package/src/server/resource/ai-api-config.ts +2 -1
- package/src/server/routes/agent-completions.ts +3 -0
- package/src/server/routes/auth.ts +21 -1
- package/src/server/routes/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +42 -6
- package/src/server/routes/models.ts +34 -0
- package/src/server/routes/responses.ts +640 -0
- package/src/server/routes/router.ts +81 -12
- package/src/server/services/__tests__/file-processor.test.ts +1 -0
- package/src/server/usage.ts +30 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +23 -12
- package/src/server/utils/openai-format.ts +1 -0
- package/src/server/utils/resolve-service.ts +39 -1
- package/src/server/utils/response-store.ts +148 -0
- package/src/server/utils/responses-format.ts +974 -0
- package/src/server/utils/responses-stream.ts +384 -0
- package/src/server/utils/virtual-models.ts +320 -0
- package/src/server/validation.ts +50 -0
- package/src/swagger.ts +139 -0
- package/dist/client/562.44b16aad4718b4c7.js +0 -10
- package/dist/client/685.ae483e17b6b49c98.js +0 -10
- package/dist/client/97.9b6b2d2b01a4c060.js +0 -10
- package/dist/client-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.js +0 -10
- package/dist/client-v2/97.36a42eff36bb3d8a.js +0 -10
- package/dist/server/collections/ai-api-user-permissions.js +0 -67
- package/dist/server/collections/ai-api-user-quota-buckets.js +0 -54
- package/dist/server/collections/ai-api-user-quota-policies.js +0 -63
- package/dist/server/resource/ai-api-usage-groups.js +0 -168
- package/src/server/collections/ai-api-user-permissions.ts +0 -46
- package/src/server/collections/ai-api-user-quota-buckets.ts +0 -24
- package/src/server/collections/ai-api-user-quota-policies.ts +0 -33
- 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
|
+
});
|