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.
- 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/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/index.js +1 -1
- package/dist/externalVersion.js +8 -8
- package/dist/locale/en-US.json +175 -139
- package/dist/locale/vi-VN.json +40 -2
- package/dist/locale/zh-CN.json +40 -2
- package/dist/server/collections/ai-api-model-metadata.js +26 -0
- package/dist/server/collections/ai-api-response-records.js +101 -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/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/chat-completions.js +38 -6
- package/dist/server/routes/completions.js +16 -4
- package/dist/server/routes/embeddings.js +25 -6
- 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 +25 -4
- package/dist/server/utils/direct-llm-context.js +1 -1
- 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 +44 -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/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/ModelRoutingPage.tsx +238 -0
- package/src/client-v2/pages/UsageGroupsPage.tsx +75 -38
- package/src/client-v2/plugin.tsx +8 -0
- package/src/locale/en-US.json +175 -139
- package/src/locale/vi-VN.json +40 -2
- package/src/locale/zh-CN.json +40 -2
- 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 +14 -0
- package/src/server/__tests__/validation.test.ts +66 -7
- package/src/server/__tests__/virtual-model-routing.test.ts +589 -0
- package/src/server/collections/ai-api-model-metadata.ts +26 -0
- package/src/server/collections/ai-api-response-records.ts +77 -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/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/chat-completions.ts +34 -10
- package/src/server/routes/completions.ts +16 -4
- package/src/server/routes/embeddings.ts +32 -10
- 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 +29 -2
- package/src/server/utils/app-observability.ts +1 -1
- package/src/server/utils/direct-llm-context.ts +2 -1
- 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 +49 -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-v2/562.45d5c504433be38b.js +0 -10
- package/dist/client-v2/685.1030370b309b7d4b.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,368 @@
|
|
|
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 } from '@nocobase/actions';
|
|
11
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
12
|
+
import type PluginAiApiServer from '../plugin';
|
|
13
|
+
import { handleResponses } from '../routes/responses';
|
|
14
|
+
import { resolveModelString } from '../utils/resolve-service';
|
|
15
|
+
import { storeResponseRecord } from '../utils/response-store';
|
|
16
|
+
|
|
17
|
+
vi.mock('../utils/resolve-service', () => ({ resolveModelString: vi.fn(), resolveModelReference: vi.fn() }));
|
|
18
|
+
vi.mock('../utils/virtual-models', () => ({ resolveVirtualModel: vi.fn().mockResolvedValue(null) }));
|
|
19
|
+
vi.mock('../utils/user-permissions', () => ({ enforceModelAccess: vi.fn().mockResolvedValue(true) }));
|
|
20
|
+
vi.mock('../utils/request-cache', () => ({ getAiApiConfig: vi.fn().mockResolvedValue({ enabledLlmServices: [] }) }));
|
|
21
|
+
vi.mock('../utils/response-store', () => ({
|
|
22
|
+
loadConversationChain: vi.fn().mockResolvedValue([]),
|
|
23
|
+
storeResponseRecord: vi.fn().mockResolvedValue(undefined),
|
|
24
|
+
}));
|
|
25
|
+
vi.mock('../billing', () => ({
|
|
26
|
+
AiApiQuotaError: class extends Error {
|
|
27
|
+
code = 'quota_exceeded';
|
|
28
|
+
},
|
|
29
|
+
markLlmProviderAttempted: vi.fn(),
|
|
30
|
+
prepareLlmBilling: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
}));
|
|
32
|
+
vi.mock('../utils/direct-llm-context', async (importOriginal) => {
|
|
33
|
+
const original = await importOriginal<typeof import('../utils/direct-llm-context')>();
|
|
34
|
+
return {
|
|
35
|
+
...original,
|
|
36
|
+
prepareDirectLlmContext: vi.fn().mockImplementation(async (_ctx, options) => ({
|
|
37
|
+
messages: options.messages,
|
|
38
|
+
estimatedInputTokens: 10,
|
|
39
|
+
inputTokenBudget: 1000,
|
|
40
|
+
reservedOutputTokens: 100,
|
|
41
|
+
truncated: false,
|
|
42
|
+
})),
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
class EventTargetMock {
|
|
47
|
+
aborted = false;
|
|
48
|
+
writableEnded = false;
|
|
49
|
+
destroyed = false;
|
|
50
|
+
headersSent = false;
|
|
51
|
+
private listeners = new Map<string, Set<() => void>>();
|
|
52
|
+
|
|
53
|
+
once(event: string, listener: () => void) {
|
|
54
|
+
const group = this.listeners.get(event) ?? new Set();
|
|
55
|
+
group.add(listener);
|
|
56
|
+
this.listeners.set(event, group);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
off(event: string, listener: () => void) {
|
|
60
|
+
this.listeners.get(event)?.delete(listener);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
emit(event: string) {
|
|
64
|
+
this.listeners.get(event)?.forEach((listener) => listener());
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function modelRecord() {
|
|
69
|
+
const values: Record<string, unknown> = {
|
|
70
|
+
name: 'test-service',
|
|
71
|
+
enabled: true,
|
|
72
|
+
provider: 'test-provider',
|
|
73
|
+
options: {},
|
|
74
|
+
};
|
|
75
|
+
return { get: (key: string) => values[key] };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function createStreamingContext(stream: AsyncIterable<Record<string, unknown>>) {
|
|
79
|
+
const req = new EventTargetMock();
|
|
80
|
+
const res = new EventTargetMock();
|
|
81
|
+
const writes: string[] = [];
|
|
82
|
+
res.write = vi.fn((value: unknown) => {
|
|
83
|
+
writes.push(String(value));
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
res.end = vi.fn(() => {
|
|
87
|
+
res.writableEnded = true;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const model = {
|
|
91
|
+
modelKwargs: {},
|
|
92
|
+
stream: vi.fn().mockResolvedValue(stream),
|
|
93
|
+
};
|
|
94
|
+
class TestProvider {
|
|
95
|
+
createModel() {
|
|
96
|
+
return model;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
vi.mocked(resolveModelString).mockResolvedValue({
|
|
100
|
+
service: modelRecord() as never,
|
|
101
|
+
modelId: 'test-model',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const ctx = {
|
|
105
|
+
app: {
|
|
106
|
+
pm: {
|
|
107
|
+
get: vi.fn().mockReturnValue({
|
|
108
|
+
aiManager: { llmProviders: new Map([['test-provider', { provider: TestProvider }]]) },
|
|
109
|
+
}),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
request: { body: { model: 'test-service/test-model', input: 'Hello', stream: true, store: false } },
|
|
113
|
+
state: { currentUser: { id: 42 } },
|
|
114
|
+
req,
|
|
115
|
+
res,
|
|
116
|
+
log: { error: vi.fn(), warn: vi.fn() },
|
|
117
|
+
set: vi.fn(),
|
|
118
|
+
} as unknown as Context;
|
|
119
|
+
return { ctx, writes };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function eventPayloads(writes: string[]): Array<Record<string, unknown>> {
|
|
123
|
+
return writes
|
|
124
|
+
.filter((value) => value.startsWith('data: ') && value !== 'data: [DONE]\n\n')
|
|
125
|
+
.map((value) => JSON.parse(value.slice(6)) as Record<string, unknown>);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe('Responses API streaming handler', () => {
|
|
129
|
+
it('writes OpenAI Responses SSE lifecycle events and terminates with [DONE]', async () => {
|
|
130
|
+
const { ctx, writes } = createStreamingContext({
|
|
131
|
+
async *[Symbol.asyncIterator]() {
|
|
132
|
+
yield { content: 'Hel' };
|
|
133
|
+
yield {
|
|
134
|
+
content: 'lo',
|
|
135
|
+
usage_metadata: { input_tokens: 10, output_tokens: 2, total_tokens: 12 },
|
|
136
|
+
response_metadata: { finish_reason: 'stop' },
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await handleResponses(ctx, {} as PluginAiApiServer);
|
|
142
|
+
|
|
143
|
+
const payloads = eventPayloads(writes);
|
|
144
|
+
expect(payloads.map((payload) => payload.type)).toEqual([
|
|
145
|
+
'response.created',
|
|
146
|
+
'response.in_progress',
|
|
147
|
+
'response.output_item.added',
|
|
148
|
+
'response.content_part.added',
|
|
149
|
+
'response.output_text.delta',
|
|
150
|
+
'response.output_text.delta',
|
|
151
|
+
'response.output_text.done',
|
|
152
|
+
'response.content_part.done',
|
|
153
|
+
'response.output_item.done',
|
|
154
|
+
'response.completed',
|
|
155
|
+
]);
|
|
156
|
+
expect(payloads.map((payload) => payload.sequence_number)).toEqual(payloads.map((_, index) => index));
|
|
157
|
+
expect(payloads.at(-1)).toMatchObject({
|
|
158
|
+
response: { output_text: 'Hello', usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } },
|
|
159
|
+
});
|
|
160
|
+
expect(writes.at(-1)).toBe('data: [DONE]\n\n');
|
|
161
|
+
expect(ctx.state.aiApiStreamResult).toMatchObject({ succeeded: true, id: expect.stringMatching(/^resp_/) });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('emits error and response.failed when the provider stream throws', async () => {
|
|
165
|
+
const { ctx, writes } = createStreamingContext({
|
|
166
|
+
async *[Symbol.asyncIterator]() {
|
|
167
|
+
yield { content: 'partial' };
|
|
168
|
+
throw new Error('provider stream failed');
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await handleResponses(ctx, {} as PluginAiApiServer);
|
|
173
|
+
|
|
174
|
+
const payloads = eventPayloads(writes);
|
|
175
|
+
expect(payloads.slice(-2)).toEqual([
|
|
176
|
+
expect.objectContaining({ type: 'error', code: 'server_error', message: 'provider stream failed' }),
|
|
177
|
+
expect.objectContaining({
|
|
178
|
+
type: 'response.failed',
|
|
179
|
+
response: expect.objectContaining({
|
|
180
|
+
status: 'failed',
|
|
181
|
+
error: { code: 'server_error', message: 'provider stream failed' },
|
|
182
|
+
}),
|
|
183
|
+
}),
|
|
184
|
+
]);
|
|
185
|
+
expect(payloads.at(-1)).toMatchObject({
|
|
186
|
+
response: {
|
|
187
|
+
completed_at: null,
|
|
188
|
+
output: [expect.objectContaining({ status: 'incomplete' })],
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
expect(writes).not.toContain('data: [DONE]\n\n');
|
|
192
|
+
expect(ctx.state.aiApiStreamResult).toMatchObject({ succeeded: false, errorCode: 'stream_error' });
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('still delivers the completed stream when response persistence fails', async () => {
|
|
196
|
+
vi.mocked(storeResponseRecord).mockRejectedValueOnce(new Error('database unavailable'));
|
|
197
|
+
const { ctx, writes } = createStreamingContext({
|
|
198
|
+
async *[Symbol.asyncIterator]() {
|
|
199
|
+
yield {
|
|
200
|
+
content: 'Hello',
|
|
201
|
+
usage_metadata: { input_tokens: 10, output_tokens: 2, total_tokens: 12 },
|
|
202
|
+
response_metadata: { finish_reason: 'stop' },
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
ctx.request.body = { model: 'test-service/test-model', input: 'Hello', stream: true, store: true };
|
|
207
|
+
|
|
208
|
+
await handleResponses(ctx, {} as PluginAiApiServer);
|
|
209
|
+
|
|
210
|
+
const payloads = eventPayloads(writes);
|
|
211
|
+
// The provider already produced the full result; the terminal stream must not be corrupted
|
|
212
|
+
// by a local persistence failure.
|
|
213
|
+
expect(payloads.at(-1)).toMatchObject({ type: 'response.completed' });
|
|
214
|
+
expect(writes.at(-1)).toBe('data: [DONE]\n\n');
|
|
215
|
+
expect(ctx.state.aiApiStreamResult).toMatchObject({ succeeded: true });
|
|
216
|
+
expect(ctx.log.error).toHaveBeenCalledWith(
|
|
217
|
+
'[ai-api] Failed to persist streamed response record:',
|
|
218
|
+
expect.any(Error),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('syncs the tool call id from a later provider delta onto the stored item', async () => {
|
|
223
|
+
// Providers such as OpenAI's chat-completions adapter often omit the tool call id on the
|
|
224
|
+
// first chunk and only send it on a subsequent delta. The stored item must adopt the real
|
|
225
|
+
// id so tool results submitted against it match.
|
|
226
|
+
const { ctx, writes } = createStreamingContext({
|
|
227
|
+
async *[Symbol.asyncIterator]() {
|
|
228
|
+
yield {
|
|
229
|
+
tool_call_chunks: [{ index: 0, name: 'get_weather', args: '{"city":' }],
|
|
230
|
+
};
|
|
231
|
+
yield {
|
|
232
|
+
tool_call_chunks: [{ index: 0, id: 'call_real_123', args: '"Hanoi"}' }],
|
|
233
|
+
usage_metadata: { input_tokens: 10, output_tokens: 4, total_tokens: 14 },
|
|
234
|
+
response_metadata: { finish_reason: 'tool_calls' },
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await handleResponses(ctx, {} as PluginAiApiServer);
|
|
240
|
+
|
|
241
|
+
const payloads = eventPayloads(writes);
|
|
242
|
+
const itemDone = payloads.filter((payload) => payload.type === 'response.output_item.done');
|
|
243
|
+
expect(itemDone).toHaveLength(1);
|
|
244
|
+
expect(itemDone[0]).toMatchObject({
|
|
245
|
+
item: {
|
|
246
|
+
type: 'function_call',
|
|
247
|
+
status: 'completed',
|
|
248
|
+
call_id: 'call_real_123',
|
|
249
|
+
name: 'get_weather',
|
|
250
|
+
arguments: '{"city":"Hanoi"}',
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
const completed = payloads.find((payload) => payload.type === 'response.completed');
|
|
254
|
+
expect(completed).toMatchObject({
|
|
255
|
+
response: {
|
|
256
|
+
output: [expect.objectContaining({ type: 'function_call', call_id: 'call_real_123' })],
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('handles client disconnect mid-stream gracefully', async () => {
|
|
262
|
+
const req = new EventTargetMock();
|
|
263
|
+
const res = new EventTargetMock();
|
|
264
|
+
const writes: string[] = [];
|
|
265
|
+
res.write = vi.fn((value: unknown) => {
|
|
266
|
+
writes.push(String(value));
|
|
267
|
+
return true;
|
|
268
|
+
});
|
|
269
|
+
res.end = vi.fn(() => {
|
|
270
|
+
res.writableEnded = true;
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// The provider stream pauses on a gate after the first chunk. The test emits 'close'
|
|
274
|
+
// while the stream is paused, then releases the gate: the next chunk makes the loop
|
|
275
|
+
// observe the aborted signal and throw, which the handler maps to client_disconnected.
|
|
276
|
+
let reachedGate: () => void;
|
|
277
|
+
const gateReached = new Promise<void>((resolve) => {
|
|
278
|
+
reachedGate = resolve;
|
|
279
|
+
});
|
|
280
|
+
let releaseGate: () => void;
|
|
281
|
+
const gate = new Promise<void>((resolve) => {
|
|
282
|
+
releaseGate = resolve;
|
|
283
|
+
});
|
|
284
|
+
const providerStream = {
|
|
285
|
+
async *[Symbol.asyncIterator]() {
|
|
286
|
+
yield { content: 'Hel' };
|
|
287
|
+
reachedGate();
|
|
288
|
+
await gate;
|
|
289
|
+
yield { content: 'lo' };
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const model = { modelKwargs: {}, stream: vi.fn().mockResolvedValue(providerStream) };
|
|
294
|
+
class TestProvider {
|
|
295
|
+
createModel() {
|
|
296
|
+
return model;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
vi.mocked(resolveModelString).mockResolvedValue({
|
|
300
|
+
service: modelRecord() as never,
|
|
301
|
+
modelId: 'test-model',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const ctx = {
|
|
305
|
+
app: {
|
|
306
|
+
pm: {
|
|
307
|
+
get: vi.fn().mockReturnValue({
|
|
308
|
+
aiManager: { llmProviders: new Map([['test-provider', { provider: TestProvider }]]) },
|
|
309
|
+
}),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
request: { body: { model: 'test-service/test-model', input: 'Hello', stream: true, store: false } },
|
|
313
|
+
state: { currentUser: { id: 42 } },
|
|
314
|
+
req,
|
|
315
|
+
res,
|
|
316
|
+
log: { error: vi.fn(), warn: vi.fn() },
|
|
317
|
+
set: vi.fn(),
|
|
318
|
+
} as unknown as Context;
|
|
319
|
+
|
|
320
|
+
const handlerPromise = handleResponses(ctx, {} as PluginAiApiServer);
|
|
321
|
+
// Wait until the stream is paused (abort listener is registered by then).
|
|
322
|
+
await gateReached;
|
|
323
|
+
res.emit('close');
|
|
324
|
+
releaseGate();
|
|
325
|
+
await handlerPromise;
|
|
326
|
+
|
|
327
|
+
// The handler must have caught the disconnection and set the error code.
|
|
328
|
+
expect(ctx.state.aiApiStreamResult).toMatchObject({ succeeded: false, errorCode: 'client_disconnected' });
|
|
329
|
+
expect(ctx.log.error).toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('delivers the completed stream and persists when store=true succeeds', async () => {
|
|
333
|
+
// The mock accumulates calls across tests in this file (no shared beforeEach), so clear it
|
|
334
|
+
// before asserting the exact call count.
|
|
335
|
+
vi.mocked(storeResponseRecord).mockClear();
|
|
336
|
+
vi.mocked(storeResponseRecord).mockResolvedValue(undefined);
|
|
337
|
+
const { ctx, writes } = createStreamingContext({
|
|
338
|
+
async *[Symbol.asyncIterator]() {
|
|
339
|
+
yield { content: 'Hello' };
|
|
340
|
+
yield {
|
|
341
|
+
content: '',
|
|
342
|
+
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
|
|
343
|
+
response_metadata: { finish_reason: 'stop' },
|
|
344
|
+
};
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
ctx.request.body = { model: 'test-service/test-model', input: 'Hello', stream: true, store: true };
|
|
348
|
+
|
|
349
|
+
await handleResponses(ctx, {} as PluginAiApiServer);
|
|
350
|
+
|
|
351
|
+
const payloads = eventPayloads(writes);
|
|
352
|
+
// Stream completes normally.
|
|
353
|
+
expect(payloads.at(-1)).toMatchObject({ type: 'response.completed' });
|
|
354
|
+
expect(writes.at(-1)).toBe('data: [DONE]\n\n');
|
|
355
|
+
expect(ctx.state.aiApiStreamResult).toMatchObject({ succeeded: true });
|
|
356
|
+
// storeResponseRecord was called with the fully accumulated response.
|
|
357
|
+
expect(storeResponseRecord).toHaveBeenCalledTimes(1);
|
|
358
|
+
const storeArgs = vi.mocked(storeResponseRecord).mock.calls[0];
|
|
359
|
+
expect(storeArgs[0]).toBe(ctx); // first arg is ctx
|
|
360
|
+
expect(storeArgs[2]).toMatchObject({ model: 'test-service/test-model', input: 'Hello', stream: true, store: true }); // third arg is body
|
|
361
|
+
expect(storeArgs[3]).toBe(42); // fourth arg is userId
|
|
362
|
+
// The response object (second arg) should be a complete ResponseObject.
|
|
363
|
+
const storedResponse = storeArgs[1] as Record<string, unknown>;
|
|
364
|
+
expect(storedResponse.object).toBe('response');
|
|
365
|
+
expect(storedResponse.status).toBe('completed');
|
|
366
|
+
expect(storedResponse.model).toBe('test-service/test-model');
|
|
367
|
+
});
|
|
368
|
+
});
|