@codehz/ai 0.1.3 → 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/README.md +84 -79
- package/dist/index.d.mts +18 -26
- package/dist/index.mjs +113 -103
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/adapters/chat-completions.ts +5 -4
- package/src/adapters/index.ts +5 -3
- package/src/adapters/messages.ts +20 -10
- package/src/adapters/mock.ts +190 -160
- package/src/adapters/ollama.ts +30 -18
- package/src/adapters/responses.ts +4 -6
- package/src/core/validation.ts +21 -16
- package/src/helpers/adapter-auxiliary.ts +3 -1
- package/src/helpers/adapter-base.ts +5 -5
- package/src/helpers/index.ts +1 -5
- package/src/types/index.ts +1 -7
package/package.json
CHANGED
|
@@ -283,7 +283,7 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
283
283
|
item.role === "developer"
|
|
284
284
|
? "system"
|
|
285
285
|
: item.role === "system"
|
|
286
|
-
|
|
286
|
+
? "system"
|
|
287
287
|
: item.role === "user"
|
|
288
288
|
? "user"
|
|
289
289
|
: "assistant";
|
|
@@ -293,9 +293,10 @@ export class ChatCompletionsAdapter extends AdapterBase {
|
|
|
293
293
|
}
|
|
294
294
|
case "tool_call": {
|
|
295
295
|
// 只允许附着到尾部 assistant turn,否则新建一个
|
|
296
|
-
const lastAssistant =
|
|
297
|
-
|
|
298
|
-
|
|
296
|
+
const lastAssistant =
|
|
297
|
+
messages.length > 0 && messages[messages.length - 1]?.role === "assistant"
|
|
298
|
+
? messages[messages.length - 1]
|
|
299
|
+
: null;
|
|
299
300
|
const tc: ChatToolCall = {
|
|
300
301
|
id: item.id,
|
|
301
302
|
type: "function",
|
package/src/adapters/index.ts
CHANGED
|
@@ -22,11 +22,13 @@ export type { OllamaAdapterOptions } from "./ollama.js";
|
|
|
22
22
|
export { MockAdapter } from "./mock.js";
|
|
23
23
|
export type {
|
|
24
24
|
MockAdapterOptions,
|
|
25
|
+
MockHistoryRecord,
|
|
25
26
|
MockTextStreamOptions,
|
|
26
27
|
MockInputExpectation,
|
|
27
28
|
MockRequestExpectation,
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
MockHandlerContext,
|
|
30
|
+
MockHandler,
|
|
31
|
+
MockStaticHandler,
|
|
30
32
|
MockWarningStep,
|
|
31
33
|
MockAuxiliaryStep,
|
|
32
34
|
MockMessageStep,
|
|
@@ -38,5 +40,5 @@ export type {
|
|
|
38
40
|
MockInterruptStep,
|
|
39
41
|
MockThrowStep,
|
|
40
42
|
MockStep,
|
|
41
|
-
MockTurn,
|
|
42
43
|
} from "./mock.js";
|
|
44
|
+
export { assertMockRequest, withMockStreaming } from "./mock.js";
|
package/src/adapters/messages.ts
CHANGED
|
@@ -275,7 +275,9 @@ export class MessagesAdapter extends AdapterBase {
|
|
|
275
275
|
if (item.role === "system" || item.role === "developer") {
|
|
276
276
|
// Anthropic 不支持 system/developer role 在 messages 中
|
|
277
277
|
// 合并到 system prompt
|
|
278
|
-
const text = contentBlocksToText(
|
|
278
|
+
const text = contentBlocksToText(
|
|
279
|
+
ensureMessagesTextBlocks(item.content, `input message (${item.role}) content`),
|
|
280
|
+
);
|
|
279
281
|
systemPrompt = systemPrompt ? `${systemPrompt}\n${text}` : text;
|
|
280
282
|
break;
|
|
281
283
|
}
|
|
@@ -296,9 +298,7 @@ export class MessagesAdapter extends AdapterBase {
|
|
|
296
298
|
type: "tool_use",
|
|
297
299
|
id: item.id,
|
|
298
300
|
name: item.name,
|
|
299
|
-
input:
|
|
300
|
-
(item.argumentsJson as Record<string, unknown> | undefined) ??
|
|
301
|
-
parseToolUseInput(item.argumentsText),
|
|
301
|
+
input: (item.argumentsJson as Record<string, unknown> | undefined) ?? parseToolUseInput(item.argumentsText),
|
|
302
302
|
};
|
|
303
303
|
|
|
304
304
|
if (lastMsg && lastMsg.role === "assistant" && typeof lastMsg.content !== "string") {
|
|
@@ -345,7 +345,11 @@ export class MessagesAdapter extends AdapterBase {
|
|
|
345
345
|
typeof b === "object" &&
|
|
346
346
|
b !== null &&
|
|
347
347
|
"type" in b &&
|
|
348
|
-
(b.type === "text" ||
|
|
348
|
+
(b.type === "text" ||
|
|
349
|
+
b.type === "thinking" ||
|
|
350
|
+
b.type === "redacted_thinking" ||
|
|
351
|
+
b.type === "tool_use" ||
|
|
352
|
+
b.type === "tool_result"),
|
|
349
353
|
);
|
|
350
354
|
if (isValidContent) {
|
|
351
355
|
rollbackTrailingAssistantMessages(messages);
|
|
@@ -404,7 +408,10 @@ export class MessagesAdapter extends AdapterBase {
|
|
|
404
408
|
const auxiliary = this.createAuxiliaryState(request);
|
|
405
409
|
|
|
406
410
|
if (request.metadata) {
|
|
407
|
-
yield factory.responseWarning(
|
|
411
|
+
yield factory.responseWarning(
|
|
412
|
+
"Request metadata is not supported by the Messages adapter",
|
|
413
|
+
"UNSUPPORTED_METADATA",
|
|
414
|
+
);
|
|
408
415
|
}
|
|
409
416
|
|
|
410
417
|
const response = await this.fetchFn(`${this.baseUrl}/messages`, {
|
|
@@ -453,7 +460,10 @@ export class MessagesAdapter extends AdapterBase {
|
|
|
453
460
|
|
|
454
461
|
if (request.include?.providerMetadata !== "off") {
|
|
455
462
|
const headerMetadata = pickProviderHeaders(response.headers);
|
|
456
|
-
auxiliary.recordProviderMetadata(
|
|
463
|
+
auxiliary.recordProviderMetadata(
|
|
464
|
+
"header",
|
|
465
|
+
Object.keys(headerMetadata).length > 0 ? { headers: headerMetadata } : undefined,
|
|
466
|
+
);
|
|
457
467
|
}
|
|
458
468
|
|
|
459
469
|
try {
|
|
@@ -613,9 +623,9 @@ export class MessagesAdapter extends AdapterBase {
|
|
|
613
623
|
if (u) {
|
|
614
624
|
auxiliary.recordUsage(
|
|
615
625
|
{
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
626
|
+
inputTokens: u.input_tokens,
|
|
627
|
+
outputTokens: u.output_tokens,
|
|
628
|
+
totalTokens: u.input_tokens + u.output_tokens,
|
|
619
629
|
},
|
|
620
630
|
"stream",
|
|
621
631
|
u,
|
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
|
*
|
|
@@ -55,18 +56,20 @@ export type MockRequestExpectation = {
|
|
|
55
56
|
items?: MockInputExpectation[];
|
|
56
57
|
};
|
|
57
58
|
|
|
58
|
-
export type
|
|
59
|
+
export type MockHistoryRecord = {
|
|
60
|
+
turnIndex: number;
|
|
61
|
+
requestId: string;
|
|
62
|
+
replay: ReplayItem[];
|
|
63
|
+
toolCalls: ToolCallItem[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type MockHandlerContext = {
|
|
59
67
|
turnIndex: number;
|
|
60
68
|
previousReplay: ReplayItem[];
|
|
61
69
|
pendingToolCalls: readonly ToolCallItem[];
|
|
62
|
-
history: readonly
|
|
70
|
+
history: readonly MockHistoryRecord[];
|
|
63
71
|
};
|
|
64
72
|
|
|
65
|
-
export type MockTurnValidator = (
|
|
66
|
-
request: NormalizedRequest,
|
|
67
|
-
context: MockTurnContext,
|
|
68
|
-
) => void | Promise<void>;
|
|
69
|
-
|
|
70
73
|
export type MockWarningStep = {
|
|
71
74
|
type: "warning";
|
|
72
75
|
message: string;
|
|
@@ -167,32 +170,24 @@ export type MockStep =
|
|
|
167
170
|
| MockInterruptStep
|
|
168
171
|
| MockThrowStep;
|
|
169
172
|
|
|
170
|
-
export type
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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>;
|
|
175
181
|
|
|
176
182
|
export type MockAdapterOptions = {
|
|
177
|
-
|
|
178
|
-
onExhausted?: "throw" | "repeat-last" | "complete-empty";
|
|
183
|
+
handler: MockHandler;
|
|
179
184
|
providerMetadata?: Record<string, unknown>;
|
|
180
|
-
stream?: MockTextStreamOptions;
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
type MockTurnRecord = {
|
|
184
|
-
turnIndex: number;
|
|
185
|
-
turnName?: string;
|
|
186
|
-
requestId: string;
|
|
187
|
-
replay: ReplayItem[];
|
|
188
|
-
toolCalls: ToolCallItem[];
|
|
189
185
|
};
|
|
190
186
|
|
|
191
187
|
type MockProviderRequest = {
|
|
192
188
|
request: NormalizedRequest;
|
|
193
|
-
|
|
189
|
+
handlerResult: AsyncIterable<MockStep>;
|
|
194
190
|
turnIndex: number;
|
|
195
|
-
turnName?: string;
|
|
196
191
|
remainingPendingToolCalls: ToolCallItem[];
|
|
197
192
|
};
|
|
198
193
|
|
|
@@ -202,52 +197,102 @@ type ResolvedMockTextStreamOptions = {
|
|
|
202
197
|
initialDelayMs: number;
|
|
203
198
|
};
|
|
204
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
|
+
|
|
205
265
|
export class MockAdapter extends AdapterBase {
|
|
206
266
|
readonly kind = "mock" as const;
|
|
207
267
|
readonly nativeStreaming = false;
|
|
208
268
|
|
|
209
|
-
private readonly
|
|
210
|
-
private readonly onExhausted: NonNullable<MockAdapterOptions["onExhausted"]>;
|
|
269
|
+
private readonly handler: MockHandler;
|
|
211
270
|
private readonly providerMetadata?: Record<string, unknown>;
|
|
212
|
-
private readonly defaultStream?: ResolvedMockTextStreamOptions;
|
|
213
271
|
|
|
214
272
|
private cursor = 0;
|
|
215
273
|
private previousReplay: ReplayItem[] = [];
|
|
216
274
|
private pendingToolCalls: ToolCallItem[] = [];
|
|
217
|
-
private history:
|
|
275
|
+
private history: MockHistoryRecord[] = [];
|
|
218
276
|
private activeStream = false;
|
|
219
277
|
|
|
220
278
|
constructor(options: MockAdapterOptions) {
|
|
221
279
|
super();
|
|
222
|
-
this.
|
|
223
|
-
this.onExhausted = options.onExhausted ?? "throw";
|
|
280
|
+
this.handler = options.handler;
|
|
224
281
|
this.providerMetadata = options.providerMetadata;
|
|
225
|
-
this.defaultStream = resolveMockTextStreamOptions(options.stream, "adapter stream");
|
|
226
282
|
}
|
|
227
283
|
|
|
228
284
|
protected async buildRequest(request: NormalizedRequest): Promise<MockProviderRequest> {
|
|
229
285
|
const turnIndex = this.cursor;
|
|
230
|
-
const
|
|
231
|
-
const turnName = turn.name;
|
|
232
|
-
const context = this.buildTurnContext(turnIndex);
|
|
233
|
-
|
|
234
|
-
if (turn.expect) {
|
|
235
|
-
if (typeof turn.expect === "function") {
|
|
236
|
-
await turn.expect(request, context);
|
|
237
|
-
} else {
|
|
238
|
-
assertRequestMatchesExpectation(request, turn.expect, context);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
286
|
+
const context = this.buildHandlerContext(turnIndex);
|
|
242
287
|
const remainingPendingToolCalls = consumePendingToolCalls(this.pendingToolCalls, request.input);
|
|
288
|
+
const handlerResult = this.handler(request, context);
|
|
243
289
|
|
|
244
290
|
this.cursor += 1;
|
|
245
291
|
|
|
246
292
|
return {
|
|
247
293
|
request,
|
|
248
|
-
|
|
294
|
+
handlerResult,
|
|
249
295
|
turnIndex,
|
|
250
|
-
turnName,
|
|
251
296
|
remainingPendingToolCalls,
|
|
252
297
|
};
|
|
253
298
|
}
|
|
@@ -266,8 +311,11 @@ export class MockAdapter extends AdapterBase {
|
|
|
266
311
|
try {
|
|
267
312
|
const mockRequest = providerRequest as MockProviderRequest;
|
|
268
313
|
const output: OutputItem[] = [];
|
|
314
|
+
let stepCount = 0;
|
|
315
|
+
|
|
316
|
+
for await (const step of mockRequest.handlerResult) {
|
|
317
|
+
stepCount += 1;
|
|
269
318
|
|
|
270
|
-
for (const [stepIndex, step] of mockRequest.turn.steps.entries()) {
|
|
271
319
|
switch (step.type) {
|
|
272
320
|
case "warning":
|
|
273
321
|
yield factory.responseWarning(step.message, step.code);
|
|
@@ -280,14 +328,14 @@ export class MockAdapter extends AdapterBase {
|
|
|
280
328
|
});
|
|
281
329
|
break;
|
|
282
330
|
case "message": {
|
|
283
|
-
const item = createMessageFromStep(step, request, mockRequest.turnIndex,
|
|
284
|
-
yield* emitMessage(factory, item, resolveStepStreamOptions(
|
|
331
|
+
const item = createMessageFromStep(step, request, mockRequest.turnIndex, stepCount - 1);
|
|
332
|
+
yield* emitMessage(factory, item, resolveStepStreamOptions(undefined, step.stream, "message"));
|
|
285
333
|
output.push(item);
|
|
286
334
|
break;
|
|
287
335
|
}
|
|
288
336
|
case "reasoning": {
|
|
289
|
-
const item = createReasoningFromStep(step, request, mockRequest.turnIndex,
|
|
290
|
-
yield* emitReasoning(factory, item, resolveStepStreamOptions(
|
|
337
|
+
const item = createReasoningFromStep(step, request, mockRequest.turnIndex, stepCount - 1);
|
|
338
|
+
yield* emitReasoning(factory, item, resolveStepStreamOptions(undefined, step.stream, "reasoning"));
|
|
291
339
|
output.push(item);
|
|
292
340
|
break;
|
|
293
341
|
}
|
|
@@ -297,30 +345,37 @@ export class MockAdapter extends AdapterBase {
|
|
|
297
345
|
factory,
|
|
298
346
|
item,
|
|
299
347
|
step.streamArguments ?? true,
|
|
300
|
-
resolveStepStreamOptions(
|
|
348
|
+
resolveStepStreamOptions(undefined, step.stream, "tool_call"),
|
|
301
349
|
);
|
|
302
350
|
output.push(item);
|
|
303
351
|
break;
|
|
304
352
|
}
|
|
305
353
|
case "output": {
|
|
306
354
|
assertSupportedOutputItem(step.item);
|
|
307
|
-
const item = attachSyntheticId(step.item, request, mockRequest.turnIndex,
|
|
308
|
-
yield* emitOutputItem(factory, item, resolveStepStreamOptions(
|
|
355
|
+
const item = attachSyntheticId(step.item, request, mockRequest.turnIndex, stepCount - 1);
|
|
356
|
+
yield* emitOutputItem(factory, item, resolveStepStreamOptions(undefined, step.stream, "output"));
|
|
309
357
|
output.push(item);
|
|
310
358
|
break;
|
|
311
359
|
}
|
|
312
360
|
case "complete": {
|
|
313
|
-
const response = this.finalizeTurn(request, factory, mockRequest, output, step);
|
|
361
|
+
const response = this.finalizeTurn(request, factory, mockRequest, output, step, stepCount);
|
|
314
362
|
yield factory.responseCompleted(response);
|
|
315
363
|
return;
|
|
316
364
|
}
|
|
317
365
|
case "error": {
|
|
318
366
|
yield factory.responseWarning(step.message, step.code);
|
|
319
|
-
const response = this.finalizeTurn(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
+
);
|
|
324
379
|
yield factory.responseCompleted(response);
|
|
325
380
|
return;
|
|
326
381
|
}
|
|
@@ -332,9 +387,16 @@ export class MockAdapter extends AdapterBase {
|
|
|
332
387
|
}
|
|
333
388
|
}
|
|
334
389
|
|
|
335
|
-
const response = this.finalizeTurn(
|
|
336
|
-
|
|
337
|
-
|
|
390
|
+
const response = this.finalizeTurn(
|
|
391
|
+
request,
|
|
392
|
+
factory,
|
|
393
|
+
mockRequest,
|
|
394
|
+
output,
|
|
395
|
+
{
|
|
396
|
+
type: "complete",
|
|
397
|
+
},
|
|
398
|
+
stepCount,
|
|
399
|
+
);
|
|
338
400
|
yield factory.responseCompleted(response);
|
|
339
401
|
} finally {
|
|
340
402
|
this.activeStream = false;
|
|
@@ -347,6 +409,7 @@ export class MockAdapter extends AdapterBase {
|
|
|
347
409
|
mockRequest: MockProviderRequest,
|
|
348
410
|
output: OutputItem[],
|
|
349
411
|
completion: MockCompleteStep,
|
|
412
|
+
stepCount: number,
|
|
350
413
|
) {
|
|
351
414
|
const replay = completion.replay ?? replayFromOutput(output);
|
|
352
415
|
const toolCalls = output.filter((item): item is ToolCallItem => item.type === "tool_call");
|
|
@@ -355,7 +418,6 @@ export class MockAdapter extends AdapterBase {
|
|
|
355
418
|
this.pendingToolCalls = [...mockRequest.remainingPendingToolCalls, ...toolCalls];
|
|
356
419
|
this.history.push({
|
|
357
420
|
turnIndex: mockRequest.turnIndex,
|
|
358
|
-
turnName: mockRequest.turnName,
|
|
359
421
|
requestId: request.requestId,
|
|
360
422
|
replay,
|
|
361
423
|
toolCalls,
|
|
@@ -372,8 +434,7 @@ export class MockAdapter extends AdapterBase {
|
|
|
372
434
|
auxiliary: completion.auxiliary,
|
|
373
435
|
providerMetadata: {
|
|
374
436
|
turnIndex: mockRequest.turnIndex,
|
|
375
|
-
|
|
376
|
-
scriptedSteps: mockRequest.turn.steps.length,
|
|
437
|
+
stepCount,
|
|
377
438
|
pendingToolCallIds: this.pendingToolCalls.map((item) => item.id),
|
|
378
439
|
historyLength: this.history.length,
|
|
379
440
|
...this.providerMetadata,
|
|
@@ -387,28 +448,7 @@ export class MockAdapter extends AdapterBase {
|
|
|
387
448
|
);
|
|
388
449
|
}
|
|
389
450
|
|
|
390
|
-
private
|
|
391
|
-
const turn = this.turns[turnIndex];
|
|
392
|
-
if (turn !== undefined) {
|
|
393
|
-
return turn;
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const lastTurn = this.turns.at(-1);
|
|
397
|
-
if (this.onExhausted === "repeat-last" && lastTurn !== undefined) {
|
|
398
|
-
return lastTurn;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (this.onExhausted === "complete-empty") {
|
|
402
|
-
return { name: "exhausted", steps: [] };
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
throw new AIRequestError(
|
|
406
|
-
`MockAdapter turn ${turnIndex + 1} requested, but only ${this.turns.length} turn(s) were scripted`,
|
|
407
|
-
"MOCK_TURN_EXHAUSTED",
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
private buildTurnContext(turnIndex: number): MockTurnContext {
|
|
451
|
+
private buildHandlerContext(turnIndex: number): MockHandlerContext {
|
|
412
452
|
return {
|
|
413
453
|
turnIndex,
|
|
414
454
|
previousReplay: this.previousReplay.map(cloneItem),
|
|
@@ -422,6 +462,46 @@ export class MockAdapter extends AdapterBase {
|
|
|
422
462
|
}
|
|
423
463
|
}
|
|
424
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
|
+
|
|
425
505
|
function createMessageFromStep(
|
|
426
506
|
step: MockMessageStep,
|
|
427
507
|
request: NormalizedRequest,
|
|
@@ -465,7 +545,10 @@ function normalizeBlocks(content: string | ContentBlock[]): ContentBlock[] {
|
|
|
465
545
|
|
|
466
546
|
function assertSupportedOutputItem(item: OutputItem): void {
|
|
467
547
|
if (item.type === "opaque") {
|
|
468
|
-
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
|
+
);
|
|
469
552
|
}
|
|
470
553
|
}
|
|
471
554
|
|
|
@@ -680,75 +763,12 @@ function resolveStopReason(output: OutputItem[]): StopReason {
|
|
|
680
763
|
|
|
681
764
|
function consumePendingToolCalls(pending: readonly ToolCallItem[], input: readonly InputItem[]): ToolCallItem[] {
|
|
682
765
|
const fulfilledIds = new Set(
|
|
683
|
-
input
|
|
684
|
-
.filter((item): item is ToolResultItem => item.type === "tool_result")
|
|
685
|
-
.map((item) => item.callId),
|
|
766
|
+
input.filter((item): item is ToolResultItem => item.type === "tool_result").map((item) => item.callId),
|
|
686
767
|
);
|
|
687
768
|
|
|
688
769
|
return pending.filter((item) => !fulfilledIds.has(item.id)).map(cloneItem);
|
|
689
770
|
}
|
|
690
771
|
|
|
691
|
-
function assertRequestMatchesExpectation(
|
|
692
|
-
request: NormalizedRequest,
|
|
693
|
-
expectation: MockRequestExpectation,
|
|
694
|
-
context: MockTurnContext,
|
|
695
|
-
): void {
|
|
696
|
-
const prefix = `MockAdapter turn ${context.turnIndex + 1} expectation failed`;
|
|
697
|
-
|
|
698
|
-
if (expectation.minItems !== undefined && request.input.length < expectation.minItems) {
|
|
699
|
-
throw new AIRequestError(`${prefix}: expected at least ${expectation.minItems} input item(s)`, "MOCK_EXPECTATION_FAILED");
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
if (expectation.maxItems !== undefined && request.input.length > expectation.maxItems) {
|
|
703
|
-
throw new AIRequestError(`${prefix}: expected at most ${expectation.maxItems} input item(s)`, "MOCK_EXPECTATION_FAILED");
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
if (expectation.tools === "present" && (!request.tools || request.tools.length === 0)) {
|
|
707
|
-
throw new AIRequestError(`${prefix}: expected tools to be present`, "MOCK_EXPECTATION_FAILED");
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
if (expectation.tools === "absent" && request.tools && request.tools.length > 0) {
|
|
711
|
-
throw new AIRequestError(`${prefix}: expected tools to be absent`, "MOCK_EXPECTATION_FAILED");
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
if (expectation.toolChoice === "present" && request.toolChoice === undefined) {
|
|
715
|
-
throw new AIRequestError(`${prefix}: expected toolChoice to be present`, "MOCK_EXPECTATION_FAILED");
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
if (expectation.toolChoice === "absent" && request.toolChoice !== undefined) {
|
|
719
|
-
throw new AIRequestError(`${prefix}: expected toolChoice to be absent`, "MOCK_EXPECTATION_FAILED");
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
if (expectation.requireReplayFromPreviousTurn && context.previousReplay.length > 0) {
|
|
723
|
-
assertReplayIncluded(request.input, context.previousReplay, prefix);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
if (expectation.requireToolResultsForPendingCalls && context.pendingToolCalls.length > 0) {
|
|
727
|
-
const toolResultIds = new Set(
|
|
728
|
-
request.input
|
|
729
|
-
.filter((item): item is ToolResultItem => item.type === "tool_result")
|
|
730
|
-
.map((item) => item.callId),
|
|
731
|
-
);
|
|
732
|
-
|
|
733
|
-
for (const call of context.pendingToolCalls) {
|
|
734
|
-
if (!toolResultIds.has(call.id)) {
|
|
735
|
-
throw new AIRequestError(
|
|
736
|
-
`${prefix}: expected tool_result for pending tool call "${call.id}"`,
|
|
737
|
-
"MOCK_EXPECTATION_FAILED",
|
|
738
|
-
);
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
if (expectation.items && expectation.items.length > 0) {
|
|
744
|
-
if (expectation.ordered) {
|
|
745
|
-
assertOrderedItems(request.input, expectation.items, prefix);
|
|
746
|
-
} else {
|
|
747
|
-
assertUnorderedItems(request.input, expectation.items, prefix);
|
|
748
|
-
}
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
|
|
752
772
|
function assertReplayIncluded(input: readonly InputItem[], replay: readonly ReplayItem[], prefix: string): void {
|
|
753
773
|
const fingerprints = input.map(fingerprintItem);
|
|
754
774
|
let cursor = 0;
|
|
@@ -757,13 +777,20 @@ function assertReplayIncluded(input: readonly InputItem[], replay: readonly Repl
|
|
|
757
777
|
const target = fingerprintItem(replayItem);
|
|
758
778
|
const foundIndex = fingerprints.indexOf(target, cursor);
|
|
759
779
|
if (foundIndex === -1) {
|
|
760
|
-
throw new AIRequestError(
|
|
780
|
+
throw new AIRequestError(
|
|
781
|
+
`${prefix}: previous replay item was not carried into the next request`,
|
|
782
|
+
"MOCK_EXPECTATION_FAILED",
|
|
783
|
+
);
|
|
761
784
|
}
|
|
762
785
|
cursor = foundIndex + 1;
|
|
763
786
|
}
|
|
764
787
|
}
|
|
765
788
|
|
|
766
|
-
function assertOrderedItems(
|
|
789
|
+
function assertOrderedItems(
|
|
790
|
+
input: readonly InputItem[],
|
|
791
|
+
expectations: readonly MockInputExpectation[],
|
|
792
|
+
prefix: string,
|
|
793
|
+
): void {
|
|
767
794
|
let cursor = 0;
|
|
768
795
|
|
|
769
796
|
for (const expected of expectations) {
|
|
@@ -787,7 +814,11 @@ function assertOrderedItems(input: readonly InputItem[], expectations: readonly
|
|
|
787
814
|
}
|
|
788
815
|
}
|
|
789
816
|
|
|
790
|
-
function assertUnorderedItems(
|
|
817
|
+
function assertUnorderedItems(
|
|
818
|
+
input: readonly InputItem[],
|
|
819
|
+
expectations: readonly MockInputExpectation[],
|
|
820
|
+
prefix: string,
|
|
821
|
+
): void {
|
|
791
822
|
for (const expected of expectations) {
|
|
792
823
|
const matched = input.some((item) => matchesItemExpectation(item, expected));
|
|
793
824
|
if (!matched) {
|
|
@@ -811,8 +842,7 @@ function matchesItemExpectation(item: InputItem, expected: MockInputExpectation)
|
|
|
811
842
|
switch (item.type) {
|
|
812
843
|
case "message":
|
|
813
844
|
return (
|
|
814
|
-
(expected.role === undefined || item.role === expected.role) &&
|
|
815
|
-
matchesText(item.content, expected.textIncludes)
|
|
845
|
+
(expected.role === undefined || item.role === expected.role) && matchesText(item.content, expected.textIncludes)
|
|
816
846
|
);
|
|
817
847
|
case "reasoning":
|
|
818
848
|
return (
|