@codehz/ai 0.1.2 → 0.1.4
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/.oxlintrc.json +1 -16
- package/AGENTS.md +37 -0
- package/README.md +102 -73
- package/dist/index.d.mts +44 -142
- package/dist/index.mjs +194 -219
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/adapters/chat-completions.ts +7 -29
- package/src/adapters/index.ts +6 -3
- package/src/adapters/messages.ts +21 -12
- package/src/adapters/mock.ts +332 -153
- package/src/adapters/ollama.ts +32 -33
- package/src/adapters/responses.ts +5 -8
- package/src/core/validation.ts +21 -16
- package/src/helpers/adapter-auxiliary.ts +4 -8
- package/src/helpers/adapter-base.ts +9 -10
- package/src/helpers/index.ts +1 -5
- package/src/types/adapter.ts +1 -82
- package/src/types/index.ts +1 -11
package/src/adapters/mock.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mock Adapter
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
4
|
+
* 面向测试的回调驱动 adapter:
|
|
5
|
+
* - 每次请求执行用户提供的 handler
|
|
6
|
+
* - 验证调用方是否正确续接 replay / tool_result
|
|
6
7
|
* - 发出可控的 message / reasoning / tool_call 流
|
|
7
8
|
* - 注入 warning / auxiliary / content_filter / 中断 / provider error
|
|
8
9
|
*
|
|
@@ -12,7 +13,6 @@
|
|
|
12
13
|
import { AIRequestError } from "../core/errors.js";
|
|
13
14
|
import { AdapterBase } from "../helpers/adapter-base.js";
|
|
14
15
|
import { messageItem, reasoningItem, replayFromOutput, textBlock } from "../helpers/mapping.js";
|
|
15
|
-
import { CAPABILITY_MATRIX } from "../types/adapter.js";
|
|
16
16
|
|
|
17
17
|
import type {
|
|
18
18
|
AIStreamEvent,
|
|
@@ -56,18 +56,20 @@ export type MockRequestExpectation = {
|
|
|
56
56
|
items?: MockInputExpectation[];
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
export type
|
|
59
|
+
export type MockHistoryRecord = {
|
|
60
|
+
turnIndex: number;
|
|
61
|
+
requestId: string;
|
|
62
|
+
replay: ReplayItem[];
|
|
63
|
+
toolCalls: ToolCallItem[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type MockHandlerContext = {
|
|
60
67
|
turnIndex: number;
|
|
61
68
|
previousReplay: ReplayItem[];
|
|
62
69
|
pendingToolCalls: readonly ToolCallItem[];
|
|
63
|
-
history: readonly
|
|
70
|
+
history: readonly MockHistoryRecord[];
|
|
64
71
|
};
|
|
65
72
|
|
|
66
|
-
export type MockTurnValidator = (
|
|
67
|
-
request: NormalizedRequest,
|
|
68
|
-
context: MockTurnContext,
|
|
69
|
-
) => void | Promise<void>;
|
|
70
|
-
|
|
71
73
|
export type MockWarningStep = {
|
|
72
74
|
type: "warning";
|
|
73
75
|
message: string;
|
|
@@ -81,10 +83,26 @@ export type MockAuxiliaryStep = {
|
|
|
81
83
|
auxiliary?: Partial<AuxiliaryInfo>;
|
|
82
84
|
};
|
|
83
85
|
|
|
86
|
+
export type MockTextStreamOptions = {
|
|
87
|
+
/**
|
|
88
|
+
* 每秒吐出的字符数。未设置时仍会按 chunk 拆分,但不会额外等待。
|
|
89
|
+
*/
|
|
90
|
+
charsPerSecond?: number;
|
|
91
|
+
/**
|
|
92
|
+
* 每个 delta 最多包含多少个字符,默认 1。
|
|
93
|
+
*/
|
|
94
|
+
chunkSize?: number;
|
|
95
|
+
/**
|
|
96
|
+
* 首个 delta 发出前的延迟。
|
|
97
|
+
*/
|
|
98
|
+
initialDelayMs?: number;
|
|
99
|
+
};
|
|
100
|
+
|
|
84
101
|
export type MockMessageStep = {
|
|
85
102
|
type: "message";
|
|
86
103
|
id?: string;
|
|
87
104
|
content: string | ContentBlock[];
|
|
105
|
+
stream?: MockTextStreamOptions | false;
|
|
88
106
|
};
|
|
89
107
|
|
|
90
108
|
export type MockReasoningStep = {
|
|
@@ -92,6 +110,7 @@ export type MockReasoningStep = {
|
|
|
92
110
|
id?: string;
|
|
93
111
|
visibility?: Extract<OutputItem, { type: "reasoning" }>["visibility"];
|
|
94
112
|
content: string | ContentBlock[];
|
|
113
|
+
stream?: MockTextStreamOptions | false;
|
|
95
114
|
};
|
|
96
115
|
|
|
97
116
|
export type MockToolCallStep = {
|
|
@@ -101,11 +120,13 @@ export type MockToolCallStep = {
|
|
|
101
120
|
argumentsText: string;
|
|
102
121
|
argumentsJson?: unknown;
|
|
103
122
|
streamArguments?: boolean;
|
|
123
|
+
stream?: MockTextStreamOptions | false;
|
|
104
124
|
};
|
|
105
125
|
|
|
106
126
|
export type MockOutputStep = {
|
|
107
127
|
type: "output";
|
|
108
128
|
item: Extract<OutputItem, { type: "message" | "reasoning" | "tool_call" }>;
|
|
129
|
+
stream?: MockTextStreamOptions | false;
|
|
109
130
|
};
|
|
110
131
|
|
|
111
132
|
export type MockCompleteStep = {
|
|
@@ -149,78 +170,129 @@ export type MockStep =
|
|
|
149
170
|
| MockInterruptStep
|
|
150
171
|
| MockThrowStep;
|
|
151
172
|
|
|
152
|
-
export type
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
173
|
+
export type MockHandler = (request: NormalizedRequest, context: MockHandlerContext) => AsyncIterable<MockStep>;
|
|
174
|
+
|
|
175
|
+
type MockHandlerSource = Iterable<MockStep> | AsyncIterable<MockStep>;
|
|
176
|
+
|
|
177
|
+
export type MockStaticHandler = (
|
|
178
|
+
request: NormalizedRequest,
|
|
179
|
+
context: MockHandlerContext,
|
|
180
|
+
) => MockHandlerSource | Promise<MockHandlerSource>;
|
|
157
181
|
|
|
158
182
|
export type MockAdapterOptions = {
|
|
159
|
-
|
|
160
|
-
onExhausted?: "throw" | "repeat-last" | "complete-empty";
|
|
183
|
+
handler: MockHandler;
|
|
161
184
|
providerMetadata?: Record<string, unknown>;
|
|
162
185
|
};
|
|
163
186
|
|
|
164
|
-
type MockTurnRecord = {
|
|
165
|
-
turnIndex: number;
|
|
166
|
-
turnName?: string;
|
|
167
|
-
requestId: string;
|
|
168
|
-
replay: ReplayItem[];
|
|
169
|
-
toolCalls: ToolCallItem[];
|
|
170
|
-
};
|
|
171
|
-
|
|
172
187
|
type MockProviderRequest = {
|
|
173
188
|
request: NormalizedRequest;
|
|
174
|
-
|
|
189
|
+
handlerResult: AsyncIterable<MockStep>;
|
|
175
190
|
turnIndex: number;
|
|
176
|
-
turnName?: string;
|
|
177
191
|
remainingPendingToolCalls: ToolCallItem[];
|
|
178
192
|
};
|
|
179
193
|
|
|
194
|
+
type ResolvedMockTextStreamOptions = {
|
|
195
|
+
charsPerSecond?: number;
|
|
196
|
+
chunkSize: number;
|
|
197
|
+
initialDelayMs: number;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export function assertMockRequest(
|
|
201
|
+
request: NormalizedRequest,
|
|
202
|
+
expectation: MockRequestExpectation,
|
|
203
|
+
context: MockHandlerContext,
|
|
204
|
+
): void {
|
|
205
|
+
const prefix = `MockAdapter turn ${context.turnIndex + 1} expectation failed`;
|
|
206
|
+
|
|
207
|
+
if (expectation.minItems !== undefined && request.input.length < expectation.minItems) {
|
|
208
|
+
throw new AIRequestError(
|
|
209
|
+
`${prefix}: expected at least ${expectation.minItems} input item(s)`,
|
|
210
|
+
"MOCK_EXPECTATION_FAILED",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (expectation.maxItems !== undefined && request.input.length > expectation.maxItems) {
|
|
215
|
+
throw new AIRequestError(
|
|
216
|
+
`${prefix}: expected at most ${expectation.maxItems} input item(s)`,
|
|
217
|
+
"MOCK_EXPECTATION_FAILED",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (expectation.tools === "present" && (!request.tools || request.tools.length === 0)) {
|
|
222
|
+
throw new AIRequestError(`${prefix}: expected tools to be present`, "MOCK_EXPECTATION_FAILED");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (expectation.tools === "absent" && request.tools && request.tools.length > 0) {
|
|
226
|
+
throw new AIRequestError(`${prefix}: expected tools to be absent`, "MOCK_EXPECTATION_FAILED");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (expectation.toolChoice === "present" && request.toolChoice === undefined) {
|
|
230
|
+
throw new AIRequestError(`${prefix}: expected toolChoice to be present`, "MOCK_EXPECTATION_FAILED");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (expectation.toolChoice === "absent" && request.toolChoice !== undefined) {
|
|
234
|
+
throw new AIRequestError(`${prefix}: expected toolChoice to be absent`, "MOCK_EXPECTATION_FAILED");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (expectation.requireReplayFromPreviousTurn && context.previousReplay.length > 0) {
|
|
238
|
+
assertReplayIncluded(request.input, context.previousReplay, prefix);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (expectation.requireToolResultsForPendingCalls && context.pendingToolCalls.length > 0) {
|
|
242
|
+
const toolResultIds = new Set(
|
|
243
|
+
request.input.filter((item): item is ToolResultItem => item.type === "tool_result").map((item) => item.callId),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
for (const call of context.pendingToolCalls) {
|
|
247
|
+
if (!toolResultIds.has(call.id)) {
|
|
248
|
+
throw new AIRequestError(
|
|
249
|
+
`${prefix}: expected tool_result for pending tool call "${call.id}"`,
|
|
250
|
+
"MOCK_EXPECTATION_FAILED",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (expectation.items && expectation.items.length > 0) {
|
|
257
|
+
if (expectation.ordered) {
|
|
258
|
+
assertOrderedItems(request.input, expectation.items, prefix);
|
|
259
|
+
} else {
|
|
260
|
+
assertUnorderedItems(request.input, expectation.items, prefix);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
180
265
|
export class MockAdapter extends AdapterBase {
|
|
181
266
|
readonly kind = "mock" as const;
|
|
182
|
-
readonly
|
|
267
|
+
readonly nativeStreaming = false;
|
|
183
268
|
|
|
184
|
-
private readonly
|
|
185
|
-
private readonly onExhausted: NonNullable<MockAdapterOptions["onExhausted"]>;
|
|
269
|
+
private readonly handler: MockHandler;
|
|
186
270
|
private readonly providerMetadata?: Record<string, unknown>;
|
|
187
271
|
|
|
188
272
|
private cursor = 0;
|
|
189
273
|
private previousReplay: ReplayItem[] = [];
|
|
190
274
|
private pendingToolCalls: ToolCallItem[] = [];
|
|
191
|
-
private history:
|
|
275
|
+
private history: MockHistoryRecord[] = [];
|
|
192
276
|
private activeStream = false;
|
|
193
277
|
|
|
194
278
|
constructor(options: MockAdapterOptions) {
|
|
195
279
|
super();
|
|
196
|
-
this.
|
|
197
|
-
this.onExhausted = options.onExhausted ?? "throw";
|
|
280
|
+
this.handler = options.handler;
|
|
198
281
|
this.providerMetadata = options.providerMetadata;
|
|
199
282
|
}
|
|
200
283
|
|
|
201
284
|
protected async buildRequest(request: NormalizedRequest): Promise<MockProviderRequest> {
|
|
202
285
|
const turnIndex = this.cursor;
|
|
203
|
-
const
|
|
204
|
-
const turnName = turn.name;
|
|
205
|
-
const context = this.buildTurnContext(turnIndex);
|
|
206
|
-
|
|
207
|
-
if (turn.expect) {
|
|
208
|
-
if (typeof turn.expect === "function") {
|
|
209
|
-
await turn.expect(request, context);
|
|
210
|
-
} else {
|
|
211
|
-
assertRequestMatchesExpectation(request, turn.expect, context);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
286
|
+
const context = this.buildHandlerContext(turnIndex);
|
|
215
287
|
const remainingPendingToolCalls = consumePendingToolCalls(this.pendingToolCalls, request.input);
|
|
288
|
+
const handlerResult = this.handler(request, context);
|
|
216
289
|
|
|
217
290
|
this.cursor += 1;
|
|
218
291
|
|
|
219
292
|
return {
|
|
220
293
|
request,
|
|
221
|
-
|
|
294
|
+
handlerResult,
|
|
222
295
|
turnIndex,
|
|
223
|
-
turnName,
|
|
224
296
|
remainingPendingToolCalls,
|
|
225
297
|
};
|
|
226
298
|
}
|
|
@@ -239,8 +311,11 @@ export class MockAdapter extends AdapterBase {
|
|
|
239
311
|
try {
|
|
240
312
|
const mockRequest = providerRequest as MockProviderRequest;
|
|
241
313
|
const output: OutputItem[] = [];
|
|
314
|
+
let stepCount = 0;
|
|
315
|
+
|
|
316
|
+
for await (const step of mockRequest.handlerResult) {
|
|
317
|
+
stepCount += 1;
|
|
242
318
|
|
|
243
|
-
for (const [stepIndex, step] of mockRequest.turn.steps.entries()) {
|
|
244
319
|
switch (step.type) {
|
|
245
320
|
case "warning":
|
|
246
321
|
yield factory.responseWarning(step.message, step.code);
|
|
@@ -253,42 +328,54 @@ export class MockAdapter extends AdapterBase {
|
|
|
253
328
|
});
|
|
254
329
|
break;
|
|
255
330
|
case "message": {
|
|
256
|
-
const item = createMessageFromStep(step, request, mockRequest.turnIndex,
|
|
257
|
-
yield* emitMessage(factory, item);
|
|
331
|
+
const item = createMessageFromStep(step, request, mockRequest.turnIndex, stepCount - 1);
|
|
332
|
+
yield* emitMessage(factory, item, resolveStepStreamOptions(undefined, step.stream, "message"));
|
|
258
333
|
output.push(item);
|
|
259
334
|
break;
|
|
260
335
|
}
|
|
261
336
|
case "reasoning": {
|
|
262
|
-
const item = createReasoningFromStep(step, request, mockRequest.turnIndex,
|
|
263
|
-
yield* emitReasoning(factory, item);
|
|
337
|
+
const item = createReasoningFromStep(step, request, mockRequest.turnIndex, stepCount - 1);
|
|
338
|
+
yield* emitReasoning(factory, item, resolveStepStreamOptions(undefined, step.stream, "reasoning"));
|
|
264
339
|
output.push(item);
|
|
265
340
|
break;
|
|
266
341
|
}
|
|
267
342
|
case "tool_call": {
|
|
268
343
|
const item = createToolCallFromStep(step);
|
|
269
|
-
yield* emitToolCall(
|
|
344
|
+
yield* emitToolCall(
|
|
345
|
+
factory,
|
|
346
|
+
item,
|
|
347
|
+
step.streamArguments ?? true,
|
|
348
|
+
resolveStepStreamOptions(undefined, step.stream, "tool_call"),
|
|
349
|
+
);
|
|
270
350
|
output.push(item);
|
|
271
351
|
break;
|
|
272
352
|
}
|
|
273
353
|
case "output": {
|
|
274
354
|
assertSupportedOutputItem(step.item);
|
|
275
|
-
const item = attachSyntheticId(step.item, request, mockRequest.turnIndex,
|
|
276
|
-
yield* emitOutputItem(factory, item);
|
|
355
|
+
const item = attachSyntheticId(step.item, request, mockRequest.turnIndex, stepCount - 1);
|
|
356
|
+
yield* emitOutputItem(factory, item, resolveStepStreamOptions(undefined, step.stream, "output"));
|
|
277
357
|
output.push(item);
|
|
278
358
|
break;
|
|
279
359
|
}
|
|
280
360
|
case "complete": {
|
|
281
|
-
const response = this.finalizeTurn(request, factory, mockRequest, output, step);
|
|
361
|
+
const response = this.finalizeTurn(request, factory, mockRequest, output, step, stepCount);
|
|
282
362
|
yield factory.responseCompleted(response);
|
|
283
363
|
return;
|
|
284
364
|
}
|
|
285
365
|
case "error": {
|
|
286
366
|
yield factory.responseWarning(step.message, step.code);
|
|
287
|
-
const response = this.finalizeTurn(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
367
|
+
const response = this.finalizeTurn(
|
|
368
|
+
request,
|
|
369
|
+
factory,
|
|
370
|
+
mockRequest,
|
|
371
|
+
output,
|
|
372
|
+
{
|
|
373
|
+
type: "complete",
|
|
374
|
+
stopReason: step.stopReason ?? "error",
|
|
375
|
+
providerMetadata: step.providerMetadata,
|
|
376
|
+
},
|
|
377
|
+
stepCount,
|
|
378
|
+
);
|
|
292
379
|
yield factory.responseCompleted(response);
|
|
293
380
|
return;
|
|
294
381
|
}
|
|
@@ -300,9 +387,16 @@ export class MockAdapter extends AdapterBase {
|
|
|
300
387
|
}
|
|
301
388
|
}
|
|
302
389
|
|
|
303
|
-
const response = this.finalizeTurn(
|
|
304
|
-
|
|
305
|
-
|
|
390
|
+
const response = this.finalizeTurn(
|
|
391
|
+
request,
|
|
392
|
+
factory,
|
|
393
|
+
mockRequest,
|
|
394
|
+
output,
|
|
395
|
+
{
|
|
396
|
+
type: "complete",
|
|
397
|
+
},
|
|
398
|
+
stepCount,
|
|
399
|
+
);
|
|
306
400
|
yield factory.responseCompleted(response);
|
|
307
401
|
} finally {
|
|
308
402
|
this.activeStream = false;
|
|
@@ -315,6 +409,7 @@ export class MockAdapter extends AdapterBase {
|
|
|
315
409
|
mockRequest: MockProviderRequest,
|
|
316
410
|
output: OutputItem[],
|
|
317
411
|
completion: MockCompleteStep,
|
|
412
|
+
stepCount: number,
|
|
318
413
|
) {
|
|
319
414
|
const replay = completion.replay ?? replayFromOutput(output);
|
|
320
415
|
const toolCalls = output.filter((item): item is ToolCallItem => item.type === "tool_call");
|
|
@@ -323,7 +418,6 @@ export class MockAdapter extends AdapterBase {
|
|
|
323
418
|
this.pendingToolCalls = [...mockRequest.remainingPendingToolCalls, ...toolCalls];
|
|
324
419
|
this.history.push({
|
|
325
420
|
turnIndex: mockRequest.turnIndex,
|
|
326
|
-
turnName: mockRequest.turnName,
|
|
327
421
|
requestId: request.requestId,
|
|
328
422
|
replay,
|
|
329
423
|
toolCalls,
|
|
@@ -340,8 +434,7 @@ export class MockAdapter extends AdapterBase {
|
|
|
340
434
|
auxiliary: completion.auxiliary,
|
|
341
435
|
providerMetadata: {
|
|
342
436
|
turnIndex: mockRequest.turnIndex,
|
|
343
|
-
|
|
344
|
-
scriptedSteps: mockRequest.turn.steps.length,
|
|
437
|
+
stepCount,
|
|
345
438
|
pendingToolCallIds: this.pendingToolCalls.map((item) => item.id),
|
|
346
439
|
historyLength: this.history.length,
|
|
347
440
|
...this.providerMetadata,
|
|
@@ -355,28 +448,7 @@ export class MockAdapter extends AdapterBase {
|
|
|
355
448
|
);
|
|
356
449
|
}
|
|
357
450
|
|
|
358
|
-
private
|
|
359
|
-
const turn = this.turns[turnIndex];
|
|
360
|
-
if (turn !== undefined) {
|
|
361
|
-
return turn;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const lastTurn = this.turns.at(-1);
|
|
365
|
-
if (this.onExhausted === "repeat-last" && lastTurn !== undefined) {
|
|
366
|
-
return lastTurn;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
if (this.onExhausted === "complete-empty") {
|
|
370
|
-
return { name: "exhausted", steps: [] };
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
throw new AIRequestError(
|
|
374
|
-
`MockAdapter turn ${turnIndex + 1} requested, but only ${this.turns.length} turn(s) were scripted`,
|
|
375
|
-
"MOCK_TURN_EXHAUSTED",
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
private buildTurnContext(turnIndex: number): MockTurnContext {
|
|
451
|
+
private buildHandlerContext(turnIndex: number): MockHandlerContext {
|
|
380
452
|
return {
|
|
381
453
|
turnIndex,
|
|
382
454
|
previousReplay: this.previousReplay.map(cloneItem),
|
|
@@ -390,6 +462,46 @@ export class MockAdapter extends AdapterBase {
|
|
|
390
462
|
}
|
|
391
463
|
}
|
|
392
464
|
|
|
465
|
+
export function withMockStreaming(handler: MockStaticHandler, options: MockTextStreamOptions): MockHandler {
|
|
466
|
+
const defaults = resolveMockTextStreamOptions(options, "mock stream wrapper");
|
|
467
|
+
if (!defaults) {
|
|
468
|
+
throw new AIRequestError("mock stream wrapper requires streaming options", "MOCK_STREAM_CONFIG_INVALID");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return async function* streamWrappedHandler(
|
|
472
|
+
request: NormalizedRequest,
|
|
473
|
+
context: MockHandlerContext,
|
|
474
|
+
): AsyncIterable<MockStep> {
|
|
475
|
+
const source = await handler(request, context);
|
|
476
|
+
|
|
477
|
+
for await (const step of source) {
|
|
478
|
+
yield applyDefaultStreaming(step, defaults);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function applyDefaultStreaming(step: MockStep, defaults: ResolvedMockTextStreamOptions): MockStep {
|
|
484
|
+
switch (step.type) {
|
|
485
|
+
case "message":
|
|
486
|
+
case "reasoning":
|
|
487
|
+
case "tool_call":
|
|
488
|
+
case "output":
|
|
489
|
+
if (step.stream !== undefined) {
|
|
490
|
+
return step;
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
...step,
|
|
494
|
+
stream: {
|
|
495
|
+
charsPerSecond: defaults.charsPerSecond,
|
|
496
|
+
chunkSize: defaults.chunkSize,
|
|
497
|
+
initialDelayMs: defaults.initialDelayMs,
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
default:
|
|
501
|
+
return step;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
393
505
|
function createMessageFromStep(
|
|
394
506
|
step: MockMessageStep,
|
|
395
507
|
request: NormalizedRequest,
|
|
@@ -433,7 +545,10 @@ function normalizeBlocks(content: string | ContentBlock[]): ContentBlock[] {
|
|
|
433
545
|
|
|
434
546
|
function assertSupportedOutputItem(item: OutputItem): void {
|
|
435
547
|
if (item.type === "opaque") {
|
|
436
|
-
throw new AIRequestError(
|
|
548
|
+
throw new AIRequestError(
|
|
549
|
+
"MockAdapter does not stream opaque output items; use complete.replay if needed",
|
|
550
|
+
"MOCK_OPAQUE_OUTPUT",
|
|
551
|
+
);
|
|
437
552
|
}
|
|
438
553
|
}
|
|
439
554
|
|
|
@@ -464,30 +579,40 @@ function attachSyntheticId(
|
|
|
464
579
|
async function* emitOutputItem(
|
|
465
580
|
factory: EventFactory,
|
|
466
581
|
item: Extract<OutputItem, { type: "message" | "reasoning" | "tool_call" }>,
|
|
582
|
+
stream?: ResolvedMockTextStreamOptions,
|
|
467
583
|
): AsyncIterable<AIStreamEvent> {
|
|
468
584
|
if (item.type === "message") {
|
|
469
|
-
yield* emitMessage(factory, item);
|
|
585
|
+
yield* emitMessage(factory, item, stream);
|
|
470
586
|
return;
|
|
471
587
|
}
|
|
472
588
|
|
|
473
589
|
if (item.type === "reasoning") {
|
|
474
|
-
yield* emitReasoning(factory, item);
|
|
590
|
+
yield* emitReasoning(factory, item, stream);
|
|
475
591
|
return;
|
|
476
592
|
}
|
|
477
593
|
|
|
478
|
-
yield* emitToolCall(factory, item, true);
|
|
594
|
+
yield* emitToolCall(factory, item, true, stream);
|
|
479
595
|
}
|
|
480
596
|
|
|
481
|
-
async function* emitMessage(
|
|
597
|
+
async function* emitMessage(
|
|
598
|
+
factory: EventFactory,
|
|
599
|
+
item: MessageItem,
|
|
600
|
+
stream?: ResolvedMockTextStreamOptions,
|
|
601
|
+
): AsyncIterable<AIStreamEvent> {
|
|
482
602
|
if (!item.id) {
|
|
483
603
|
throw new AIRequestError("Mock message output requires an id after normalization", "MOCK_MESSAGE_ID_MISSING");
|
|
484
604
|
}
|
|
485
605
|
|
|
486
606
|
yield factory.messageStarted(item.id);
|
|
487
607
|
|
|
608
|
+
let chunkIndex = 0;
|
|
488
609
|
for (const block of item.content) {
|
|
489
610
|
if (block.type === "text") {
|
|
490
|
-
|
|
611
|
+
for (const chunk of chunkText(block.text, stream)) {
|
|
612
|
+
await delayForChunk(stream, chunkIndex, chunk.length);
|
|
613
|
+
yield factory.messageDelta(item.id, chunk);
|
|
614
|
+
chunkIndex += 1;
|
|
615
|
+
}
|
|
491
616
|
}
|
|
492
617
|
}
|
|
493
618
|
|
|
@@ -497,6 +622,7 @@ async function* emitMessage(factory: EventFactory, item: MessageItem): AsyncIter
|
|
|
497
622
|
async function* emitReasoning(
|
|
498
623
|
factory: EventFactory,
|
|
499
624
|
item: Extract<OutputItem, { type: "reasoning" }>,
|
|
625
|
+
stream?: ResolvedMockTextStreamOptions,
|
|
500
626
|
): AsyncIterable<AIStreamEvent> {
|
|
501
627
|
if (!item.id) {
|
|
502
628
|
throw new AIRequestError("Mock reasoning output requires an id after normalization", "MOCK_REASONING_ID_MISSING");
|
|
@@ -504,8 +630,18 @@ async function* emitReasoning(
|
|
|
504
630
|
|
|
505
631
|
yield factory.reasoningStarted(item.id, item.visibility);
|
|
506
632
|
|
|
633
|
+
let chunkIndex = 0;
|
|
507
634
|
for (const block of item.content) {
|
|
508
|
-
|
|
635
|
+
if (block.type !== "text") {
|
|
636
|
+
yield factory.reasoningDelta(item.id, block);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (const chunk of chunkText(block.text, stream)) {
|
|
641
|
+
await delayForChunk(stream, chunkIndex, chunk.length);
|
|
642
|
+
yield factory.reasoningDelta(item.id, textBlock(chunk));
|
|
643
|
+
chunkIndex += 1;
|
|
644
|
+
}
|
|
509
645
|
}
|
|
510
646
|
|
|
511
647
|
yield factory.reasoningCompleted(item);
|
|
@@ -515,89 +651,122 @@ async function* emitToolCall(
|
|
|
515
651
|
factory: EventFactory,
|
|
516
652
|
item: ToolCallItem,
|
|
517
653
|
streamArguments: boolean,
|
|
654
|
+
stream?: ResolvedMockTextStreamOptions,
|
|
518
655
|
): AsyncIterable<AIStreamEvent> {
|
|
519
656
|
yield factory.toolCallStarted(item.id, item.name);
|
|
520
657
|
|
|
521
658
|
if (streamArguments && item.argumentsText) {
|
|
522
|
-
|
|
659
|
+
let chunkIndex = 0;
|
|
660
|
+
for (const chunk of chunkText(item.argumentsText, stream)) {
|
|
661
|
+
await delayForChunk(stream, chunkIndex, chunk.length);
|
|
662
|
+
yield factory.toolCallDelta(item.id, { argumentsText: chunk });
|
|
663
|
+
chunkIndex += 1;
|
|
664
|
+
}
|
|
523
665
|
}
|
|
524
666
|
|
|
525
667
|
yield factory.toolCallCompleted(item);
|
|
526
668
|
}
|
|
527
669
|
|
|
528
|
-
function
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
.map((item) => item.callId),
|
|
537
|
-
);
|
|
670
|
+
function resolveStepStreamOptions(
|
|
671
|
+
defaults: ResolvedMockTextStreamOptions | undefined,
|
|
672
|
+
override: MockTextStreamOptions | false | undefined,
|
|
673
|
+
label: string,
|
|
674
|
+
): ResolvedMockTextStreamOptions | undefined {
|
|
675
|
+
if (override === false) {
|
|
676
|
+
return undefined;
|
|
677
|
+
}
|
|
538
678
|
|
|
539
|
-
return
|
|
679
|
+
return resolveMockTextStreamOptions(override, `${label} stream`, defaults);
|
|
540
680
|
}
|
|
541
681
|
|
|
542
|
-
function
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
):
|
|
547
|
-
|
|
682
|
+
function resolveMockTextStreamOptions(
|
|
683
|
+
options: MockTextStreamOptions | undefined,
|
|
684
|
+
label: string,
|
|
685
|
+
defaults?: ResolvedMockTextStreamOptions,
|
|
686
|
+
): ResolvedMockTextStreamOptions | undefined {
|
|
687
|
+
if (options === undefined) {
|
|
688
|
+
return defaults;
|
|
689
|
+
}
|
|
548
690
|
|
|
549
|
-
|
|
550
|
-
|
|
691
|
+
const chunkSize = options.chunkSize ?? defaults?.chunkSize ?? 1;
|
|
692
|
+
const initialDelayMs = options.initialDelayMs ?? defaults?.initialDelayMs ?? 0;
|
|
693
|
+
const charsPerSecond = options.charsPerSecond ?? defaults?.charsPerSecond;
|
|
694
|
+
|
|
695
|
+
if (!Number.isInteger(chunkSize) || chunkSize < 1) {
|
|
696
|
+
throw new AIRequestError(`${label}: chunkSize must be a positive integer`, "MOCK_STREAM_CONFIG_INVALID");
|
|
551
697
|
}
|
|
552
698
|
|
|
553
|
-
if (
|
|
554
|
-
throw new AIRequestError(`${
|
|
699
|
+
if (!Number.isFinite(initialDelayMs) || initialDelayMs < 0) {
|
|
700
|
+
throw new AIRequestError(`${label}: initialDelayMs must be a non-negative number`, "MOCK_STREAM_CONFIG_INVALID");
|
|
555
701
|
}
|
|
556
702
|
|
|
557
|
-
if (
|
|
558
|
-
throw new AIRequestError(`${
|
|
703
|
+
if (charsPerSecond !== undefined && (!Number.isFinite(charsPerSecond) || charsPerSecond <= 0)) {
|
|
704
|
+
throw new AIRequestError(`${label}: charsPerSecond must be a positive number`, "MOCK_STREAM_CONFIG_INVALID");
|
|
559
705
|
}
|
|
560
706
|
|
|
561
|
-
|
|
562
|
-
|
|
707
|
+
return {
|
|
708
|
+
chunkSize,
|
|
709
|
+
initialDelayMs,
|
|
710
|
+
charsPerSecond,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function chunkText(text: string, stream?: ResolvedMockTextStreamOptions): string[] {
|
|
715
|
+
if (!text) {
|
|
716
|
+
return [];
|
|
563
717
|
}
|
|
564
718
|
|
|
565
|
-
if (
|
|
566
|
-
|
|
719
|
+
if (!stream) {
|
|
720
|
+
return [text];
|
|
567
721
|
}
|
|
568
722
|
|
|
569
|
-
|
|
570
|
-
|
|
723
|
+
const chars = Array.from(text);
|
|
724
|
+
const chunks: string[] = [];
|
|
725
|
+
|
|
726
|
+
for (let index = 0; index < chars.length; index += stream.chunkSize) {
|
|
727
|
+
chunks.push(chars.slice(index, index + stream.chunkSize).join(""));
|
|
571
728
|
}
|
|
572
729
|
|
|
573
|
-
|
|
574
|
-
|
|
730
|
+
return chunks;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function delayForChunk(
|
|
734
|
+
stream: ResolvedMockTextStreamOptions | undefined,
|
|
735
|
+
chunkIndex: number,
|
|
736
|
+
chunkLength: number,
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
if (!stream) {
|
|
739
|
+
return;
|
|
575
740
|
}
|
|
576
741
|
|
|
577
|
-
if (
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
.map((item) => item.callId),
|
|
582
|
-
);
|
|
742
|
+
if (chunkIndex === 0 && stream.initialDelayMs > 0) {
|
|
743
|
+
await sleep(stream.initialDelayMs);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
583
746
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
throw new AIRequestError(
|
|
587
|
-
`${prefix}: expected tool_result for pending tool call "${call.id}"`,
|
|
588
|
-
"MOCK_EXPECTATION_FAILED",
|
|
589
|
-
);
|
|
590
|
-
}
|
|
591
|
-
}
|
|
747
|
+
if (chunkIndex > 0 && stream.charsPerSecond !== undefined) {
|
|
748
|
+
await sleep((chunkLength / stream.charsPerSecond) * 1000);
|
|
592
749
|
}
|
|
750
|
+
}
|
|
593
751
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
} else {
|
|
598
|
-
assertUnorderedItems(request.input, expectation.items, prefix);
|
|
599
|
-
}
|
|
752
|
+
async function sleep(ms: number): Promise<void> {
|
|
753
|
+
if (ms <= 0) {
|
|
754
|
+
return;
|
|
600
755
|
}
|
|
756
|
+
|
|
757
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function resolveStopReason(output: OutputItem[]): StopReason {
|
|
761
|
+
return output.some((item) => item.type === "tool_call") ? "tool_call" : "end_turn";
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function consumePendingToolCalls(pending: readonly ToolCallItem[], input: readonly InputItem[]): ToolCallItem[] {
|
|
765
|
+
const fulfilledIds = new Set(
|
|
766
|
+
input.filter((item): item is ToolResultItem => item.type === "tool_result").map((item) => item.callId),
|
|
767
|
+
);
|
|
768
|
+
|
|
769
|
+
return pending.filter((item) => !fulfilledIds.has(item.id)).map(cloneItem);
|
|
601
770
|
}
|
|
602
771
|
|
|
603
772
|
function assertReplayIncluded(input: readonly InputItem[], replay: readonly ReplayItem[], prefix: string): void {
|
|
@@ -608,13 +777,20 @@ function assertReplayIncluded(input: readonly InputItem[], replay: readonly Repl
|
|
|
608
777
|
const target = fingerprintItem(replayItem);
|
|
609
778
|
const foundIndex = fingerprints.indexOf(target, cursor);
|
|
610
779
|
if (foundIndex === -1) {
|
|
611
|
-
throw new AIRequestError(
|
|
780
|
+
throw new AIRequestError(
|
|
781
|
+
`${prefix}: previous replay item was not carried into the next request`,
|
|
782
|
+
"MOCK_EXPECTATION_FAILED",
|
|
783
|
+
);
|
|
612
784
|
}
|
|
613
785
|
cursor = foundIndex + 1;
|
|
614
786
|
}
|
|
615
787
|
}
|
|
616
788
|
|
|
617
|
-
function assertOrderedItems(
|
|
789
|
+
function assertOrderedItems(
|
|
790
|
+
input: readonly InputItem[],
|
|
791
|
+
expectations: readonly MockInputExpectation[],
|
|
792
|
+
prefix: string,
|
|
793
|
+
): void {
|
|
618
794
|
let cursor = 0;
|
|
619
795
|
|
|
620
796
|
for (const expected of expectations) {
|
|
@@ -638,7 +814,11 @@ function assertOrderedItems(input: readonly InputItem[], expectations: readonly
|
|
|
638
814
|
}
|
|
639
815
|
}
|
|
640
816
|
|
|
641
|
-
function assertUnorderedItems(
|
|
817
|
+
function assertUnorderedItems(
|
|
818
|
+
input: readonly InputItem[],
|
|
819
|
+
expectations: readonly MockInputExpectation[],
|
|
820
|
+
prefix: string,
|
|
821
|
+
): void {
|
|
642
822
|
for (const expected of expectations) {
|
|
643
823
|
const matched = input.some((item) => matchesItemExpectation(item, expected));
|
|
644
824
|
if (!matched) {
|
|
@@ -662,8 +842,7 @@ function matchesItemExpectation(item: InputItem, expected: MockInputExpectation)
|
|
|
662
842
|
switch (item.type) {
|
|
663
843
|
case "message":
|
|
664
844
|
return (
|
|
665
|
-
(expected.role === undefined || item.role === expected.role) &&
|
|
666
|
-
matchesText(item.content, expected.textIncludes)
|
|
845
|
+
(expected.role === undefined || item.role === expected.role) && matchesText(item.content, expected.textIncludes)
|
|
667
846
|
);
|
|
668
847
|
case "reasoning":
|
|
669
848
|
return (
|