SPB Git

Repository Public

scripted-provider.ts 7.4 KB 154 lines typescript

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
Files main
Menu

Theme