Repository Public
1/**2 * Scripted fake provider: plays a list of model turns and records every request it receives. No network.3 *4 * The turn to play is chosen from the TRANSCRIPT (number of assistant entries already in it), not from a5 * call counter, so a fresh process resuming a run after a crash continues the script at the right place.6 * With `logFile`, every request is also appended as a JSON line — the crash tests read it back to compare7 * the transcripts seen across several worker processes.8 */9import fs from 'node:fs';10import { ProviderError, type ModelRequest, type ProviderAdapter, type ProviderResponse } from '@aos/providers';11import type { ContentBlock, Provider, StopReason, ToolResult, Usage } from '@aos/shared';1213export type FakeEntry =14 | { role: 'user'; text: string }15 | { role: 'assistant'; blocks: ContentBlock[] }16 | { role: 'tool'; results: { callId: string; name: string; ok: boolean; content: string }[] }17 | { role: 'operator'; text: string };1819export interface ScriptedTurn {20 text?: string;21 reasoning?: string;22 calls?: { name: string; input: unknown; id?: string }[];23 /** Default: 'tool_use' when there are calls, else 'end_turn'. */24 stop?: StopReason;25 usage?: Partial<Usage>;26 refusal?: { category: string | null; explanation: string | null };27 /** Throw this instead of answering (a `ProviderError`, as the real adapters do). */28 error?: { message: string; code?: string; status?: number; retryable?: boolean };29 /** Wait before answering (aborts with the request signal). */30 delayMs?: number;31}32export interface TurnInfo {33 /** Index of this turn in the conversation (assistant entries already in the transcript). */34 index: number;35 /** How many times this provider instance has been called before (all runs). */36 callNo: number;37 transcript: FakeEntry[];38}39export type ScriptItem = ScriptedTurn | ((req: ModelRequest, info: TurnInfo) => ScriptedTurn);4041export interface RecordedRequest {42 index: number;43 model: string;44 system: string;45 toolNames: string[];46 transcript: FakeEntry[];47}48export interface ScriptedProvider extends ProviderAdapter {49 readonly requests: RecordedRequest[];50 /** The last transcript received, i.e. everything the model has been shown. */51 lastTranscript(): FakeEntry[];52}53export interface ScriptedProviderOptions {54 id?: Provider;55 /** Append every request as a JSON line (for multi-process tests). */56 logFile?: string;57 /** Default usage of a turn. */58 usage?: Partial<Usage>;59}6061const DEFAULT_USAGE: Usage = { usd: 0.001, inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, turns: 1, toolCalls: 0 };6263function sleep(ms: number, signal?: AbortSignal): Promise<void> {64 return new Promise((resolve, reject) => {65 const t = setTimeout(() => {66 signal?.removeEventListener('abort', onAbort);67 resolve();68 }, ms);69 const onAbort = (): void => {70 clearTimeout(t);71 reject(new ProviderError('Request aborted', { provider: 'anthropic', retryable: false, code: 'aborted' }));72 };73 if (signal?.aborted) onAbort();74 else signal?.addEventListener('abort', onAbort, { once: true });75 });76}7778export function scriptedProvider(script: ScriptItem[], opts: ScriptedProviderOptions = {}): ScriptedProvider {79 const id = opts.id ?? 'anthropic';80 const requests: RecordedRequest[] = [];81 let callNo = 0;82 const flat = (t: unknown[]): FakeEntry[] => t.flatMap((e) => (Array.isArray(e) ? e : [e])) as FakeEntry[];8384 return {85 id,86 requests,87 lastTranscript: () => requests[requests.length - 1]?.transcript ?? [],88 initTranscript: (userText) => [{ role: 'user', text: userText }],89 appendAssistant: (transcript, native) => [...transcript, ...(Array.isArray(native) ? native : [native])],90 appendToolResults: (transcript, results: ToolResult[]) => [91 ...transcript,92 { role: 'tool', results: results.map((r) => ({ callId: r.callId, name: r.name, ok: r.ok, content: r.content })) },93 ],94 appendUser: (transcript, text) => [...transcript, { role: 'user', text }],95 appendOperatorNote: (transcript, text) => [...transcript, { role: 'operator', text }],9697 async call(req): Promise<ProviderResponse> {98 const transcript = flat(req.transcript);99 const index = transcript.filter((e) => e.role === 'assistant').length;100 const rec: RecordedRequest = { index, model: req.model, system: req.system, toolNames: req.tools.map((t) => t.name), transcript: structuredClone(transcript) };101 requests.push(rec);102 if (opts.logFile) fs.appendFileSync(opts.logFile, `${JSON.stringify(rec)}\n`);103 const item = script[index];104 const info: TurnInfo = { index, callNo: callNo++, transcript };105 if (item === undefined) {106 throw new ProviderError(`scripted provider: no turn ${index} in the script`, { provider: id, retryable: false, code: 'script_exhausted' });107 }108 const turn = typeof item === 'function' ? item(req, info) : item;109 if (turn.delayMs) await sleep(turn.delayMs, req.signal);110 if (req.signal?.aborted) throw new ProviderError('Request aborted', { provider: id, retryable: false, code: 'aborted' });111 if (turn.error) {112 throw new ProviderError(turn.error.message, { provider: id, retryable: turn.error.retryable ?? false, code: turn.error.code ?? null, status: turn.error.status ?? null });113 }114 const blocks: ContentBlock[] = [];115 if (turn.reasoning) {116 blocks.push({ type: 'reasoning', summary: turn.reasoning });117 req.onDelta?.({ type: 'reasoning', text: turn.reasoning });118 }119 if (turn.text) {120 blocks.push({ type: 'text', text: turn.text });121 for (let i = 0; i < turn.text.length; i += 16) req.onDelta?.({ type: 'text', text: turn.text.slice(i, i + 16) });122 }123 const refused = turn.refusal !== undefined || turn.stop === 'refusal';124 if (!refused) (turn.calls ?? []).forEach((c, n) => blocks.push({ type: 'tool_call', id: c.id ?? `call_${index}_${n}`, name: c.name, input: c.input }));125 const hasCalls = blocks.some((b) => b.type === 'tool_call');126 const stop: StopReason = refused ? 'refusal' : hasCalls ? 'tool_use' : (turn.stop ?? 'end_turn');127 const usage: Usage = { ...DEFAULT_USAGE, ...(opts.usage ?? {}), ...(turn.usage ?? {}), turns: 1, toolCalls: 0 };128 return {129 blocks, stop, usage, model: req.model,130 native: blocks.length > 0 ? [{ role: 'assistant', blocks }] : [],131 ...(refused ? { refusal: turn.refusal ?? { category: null, explanation: null } } : {}),132 nativeStop: stop,133 attempts: 1,134 };135 },136 listModels: async () => [],137 listLiveModelIds: async () => [],138 price: (_model, usage) => (usage.inputTokens + usage.outputTokens) / 1_000_000,139 };140}141142/** Requests logged by one or several processes through `logFile`. */143export function readRequestLog(logFile: string): RecordedRequest[] {144 if (!fs.existsSync(logFile)) return [];145 return fs.readFileSync(logFile, 'utf8').split('\n').filter((l) => l.length > 0).map((l) => JSON.parse(l) as RecordedRequest);146}147148/** Tool results the model was shown, flattened, in order. */149export function toolResultsOf(transcript: FakeEntry[]): { callId: string; name: string; ok: boolean; content: string }[] {150 return transcript.flatMap((e) => (e.role === 'tool' ? e.results : []));151}152export const operatorNotesOf = (transcript: FakeEntry[]): string[] => transcript.flatMap((e) => (e.role === 'operator' ? [e.text] : []));153export const userTextsOf = (transcript: FakeEntry[]): string[] => transcript.flatMap((e) => (e.role === 'user' ? [e.text] : []));154