SPB Git

Repository Public

fixtures.ts 11.0 KB 203 lines typescript

1/**2 * Hand-written fixtures of provider-native HTTP responses and SSE stream events. They are fed to the REAL3 * vendor SDKs through an injected `fetch`, so the adapters are exercised end to end without any network.4 * Nothing here is recorded from a production account; IDs and signatures are obviously synthetic.5 */67export interface RecordedRequest {8  url: string;9  method: string;10  headers: Headers;11  body: Record<string, unknown> | null;12}1314export type Responder = (req: RecordedRequest, signal: AbortSignal | undefined) => Response | Promise<Response>;1516/** A fetch double that serves queued responders in order and records every request. */17export function fakeFetch(responders: Responder[]): { fetch: typeof globalThis.fetch; requests: RecordedRequest[] } {18  const requests: RecordedRequest[] = [];19  const queue = [...responders];20  const fetch = (async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit): Promise<Response> => {21    const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;22    const rawBody = typeof init?.body === 'string' ? init.body : null;23    const req: RecordedRequest = {24      url, method: init?.method ?? 'GET', headers: new Headers(init?.headers),25      body: rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : null,26    };27    requests.push(req);28    const next = queue.shift();29    if (!next) throw new Error(`fakeFetch: no responder left for ${req.method} ${url}`);30    return next(req, init?.signal ?? undefined);31  }) as typeof globalThis.fetch;32  return { fetch, requests };33}3435export function sseResponse(events: { event?: string; data: unknown }[], headers: Record<string, string> = {}): Response {36  const text = events.map((e) => `${e.event ? `event: ${e.event}\n` : ''}data: ${typeof e.data === 'string' ? e.data : JSON.stringify(e.data)}\n\n`).join('');37  return new Response(text, { status: 200, headers: { 'content-type': 'text/event-stream', ...headers } });38}3940export function jsonResponse(status: number, body: unknown, headers: Record<string, string> = {}): Response {41  return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } });42}4344/** A fetch that never answers until the request is aborted (for AbortSignal tests). */45export const hangUntilAborted: Responder = (_req, signal) => new Promise<Response>((_resolve, reject) => {46  const fail = (): void => reject(new DOMException('This operation was aborted', 'AbortError'));47  if (signal?.aborted) fail();48  else signal?.addEventListener('abort', fail, { once: true });49});5051// ------------------------------------------------------------------------------------------------52// Anthropic Messages API5354export type AnthropicBlockFixture =55  | { type: 'text'; text: string; chunks?: string[] }56  | { type: 'thinking'; thinking: string; signature: string; chunks?: string[] }57  | { type: 'tool_use'; id: string; name: string; /** raw JSON text streamed as input_json_delta fragments */ json: string; chunks?: string[] }58  | { type: 'raw'; start: Record<string, unknown> };5960export interface AnthropicMessageFixture {61  id?: string;62  model?: string;63  content: AnthropicBlockFixture[];64  stop_reason: string;65  stop_details?: Record<string, unknown> | null;66  usage?: { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; iterations?: unknown[] };67}6869const split = (s: string, n = 2): string[] => {70  if (s.length === 0) return [];71  const size = Math.max(1, Math.ceil(s.length / n));72  const out: string[] = [];73  for (let i = 0; i < s.length; i += size) out.push(s.slice(i, i + size));74  return out;75};7677/** The event sequence of a streamed message: message_start → content_block_* → message_delta → message_stop. */78export function anthropicEvents(m: AnthropicMessageFixture): { event: string; data: unknown }[] {79  const u = m.usage ?? {};80  const events: { event: string; data: unknown }[] = [{81    event: 'message_start',82    data: {83      type: 'message_start',84      message: {85        id: m.id ?? 'msg_fixture_01', type: 'message', role: 'assistant', model: m.model ?? 'claude-haiku-5-5', content: [],86        stop_reason: null, stop_sequence: null, stop_details: null,87        usage: {88          input_tokens: u.input_tokens ?? 10, output_tokens: 1,89          cache_read_input_tokens: u.cache_read_input_tokens ?? 0, cache_creation_input_tokens: u.cache_creation_input_tokens ?? 0,90        },91      },92    },93  }];94  m.content.forEach((b, index) => {95    const start = (content_block: unknown): void => { events.push({ event: 'content_block_start', data: { type: 'content_block_start', index, content_block } }); };96    const delta = (d: unknown): void => { events.push({ event: 'content_block_delta', data: { type: 'content_block_delta', index, delta: d } }); };97    if (b.type === 'text') {98      start({ type: 'text', text: '' });99      for (const c of b.chunks ?? split(b.text)) delta({ type: 'text_delta', text: c });100    } else if (b.type === 'thinking') {101      start({ type: 'thinking', thinking: '', signature: '' });102      for (const c of b.chunks ?? split(b.thinking)) delta({ type: 'thinking_delta', thinking: c });103      delta({ type: 'signature_delta', signature: b.signature });104    } else if (b.type === 'tool_use') {105      start({ type: 'tool_use', id: b.id, name: b.name, input: {} });106      for (const c of b.chunks ?? split(b.json, 3)) delta({ type: 'input_json_delta', partial_json: c });107    } else {108      start(b.start);109    }110    events.push({ event: 'content_block_stop', data: { type: 'content_block_stop', index } });111  });112  events.push({113    event: 'message_delta',114    data: {115      type: 'message_delta',116      delta: { stop_reason: m.stop_reason, stop_sequence: null, stop_details: m.stop_details ?? null },117      usage: { output_tokens: u.output_tokens ?? 20, ...(u.iterations ? { iterations: u.iterations } : {}) },118    },119  });120  events.push({ event: 'message_stop', data: { type: 'message_stop' } });121  return events;122}123124export const anthropicStream = (m: AnthropicMessageFixture): Responder => () => sseResponse(anthropicEvents(m), { 'request-id': 'req_fixture_01' });125126export const anthropicError = (status: number, type: string, message: string, headers: Record<string, string> = {}): Responder =>127  () => jsonResponse(status, { type: 'error', error: { type, message }, request_id: 'req_fixture_err' }, { 'request-id': 'req_fixture_err', ...headers });128129// ------------------------------------------------------------------------------------------------130// OpenAI Responses API131132export interface OpenAIResponseFixture {133  id?: string;134  model?: string;135  status?: 'completed' | 'incomplete' | 'failed';136  incomplete_details?: { reason: string } | null;137  error?: { code: string; message: string } | null;138  output: Record<string, unknown>[];139  usage?: { input_tokens: number; output_tokens: number; cached_tokens?: number; cache_write_tokens?: number; reasoning_tokens?: number } | null;140}141142export function openaiResponseObject(f: OpenAIResponseFixture): Record<string, unknown> {143  const u = f.usage === undefined ? { input_tokens: 12, output_tokens: 7 } : f.usage;144  return {145    id: f.id ?? 'resp_fixture_01', object: 'response', created_at: 1_794_000_000, model: f.model ?? 'gpt-6-luna',146    status: f.status ?? 'completed', incomplete_details: f.incomplete_details ?? null, error: f.error ?? null,147    output: f.output, store: false, parallel_tool_calls: true,148    usage: u === null ? null : {149      input_tokens: u.input_tokens, output_tokens: u.output_tokens, total_tokens: u.input_tokens + u.output_tokens,150      input_tokens_details: { cached_tokens: u.cached_tokens ?? 0, cache_write_tokens: u.cache_write_tokens ?? 0 },151      output_tokens_details: { reasoning_tokens: u.reasoning_tokens ?? 0 },152    },153  };154}155156/** The event sequence of a streamed response, with deltas for text, reasoning summaries and function arguments. */157export function openaiEvents(f: OpenAIResponseFixture): { event: string; data: unknown }[] {158  let seq = 0;159  const events: { event: string; data: unknown }[] = [];160  const push = (type: string, rest: Record<string, unknown>): void => { events.push({ event: type, data: { type, sequence_number: seq++, ...rest } }); };161  const final = openaiResponseObject(f);162  push('response.created', { response: { ...final, status: 'in_progress', output: [], usage: null } });163  f.output.forEach((item, output_index) => {164    const item_id = String(item.id ?? `item_${output_index}`);165    push('response.output_item.added', { output_index, item: { ...item, status: 'in_progress' } });166    if (item.type === 'reasoning') {167      (item.summary as { text: string }[] | undefined ?? []).forEach((part, summary_index) => {168        push('response.reasoning_summary_part.added', { item_id, output_index, summary_index, part: { type: 'summary_text', text: '' } });169        for (const c of split(part.text)) push('response.reasoning_summary_text.delta', { item_id, output_index, summary_index, delta: c });170        push('response.reasoning_summary_text.done', { item_id, output_index, summary_index, text: part.text });171        push('response.reasoning_summary_part.done', { item_id, output_index, summary_index, part });172      });173    } else if (item.type === 'message') {174      (item.content as Record<string, unknown>[]).forEach((part, content_index) => {175        if (part.type === 'output_text') {176          for (const c of split(String(part.text))) push('response.output_text.delta', { item_id, output_index, content_index, delta: c, logprobs: [] });177          push('response.output_text.done', { item_id, output_index, content_index, text: part.text, logprobs: [] });178        } else if (part.type === 'refusal') {179          push('response.refusal.delta', { item_id, output_index, content_index, delta: part.refusal });180          push('response.refusal.done', { item_id, output_index, content_index, refusal: part.refusal });181        }182      });183    } else if (item.type === 'function_call') {184      for (const c of split(String(item.arguments), 3)) push('response.function_call_arguments.delta', { item_id, output_index, delta: c });185      push('response.function_call_arguments.done', { item_id, output_index, arguments: item.arguments, name: item.name });186    }187    push('response.output_item.done', { output_index, item });188  });189  const status = f.status ?? 'completed';190  push(status === 'completed' ? 'response.completed' : status === 'incomplete' ? 'response.incomplete' : 'response.failed', { response: final });191  return events;192}193194export const openaiStream = (f: OpenAIResponseFixture): Responder => () => sseResponse(openaiEvents(f), { 'x-request-id': 'req_fixture_oa' });195196export const openaiError = (status: number, code: string | null, message: string, headers: Record<string, string> = {}, type = 'invalid_request_error'): Responder =>197  () => jsonResponse(status, { error: { message, type, param: null, code } }, { 'x-request-id': 'req_fixture_oa_err', ...headers });198199export const noSleep = (): { sleep: (ms: number) => Promise<void>; waits: number[] } => {200  const waits: number[] = [];201  return { waits, sleep: async (ms: number) => { waits.push(ms); } };202};203
Files main
Menu

Theme