/** * Shared fetch utilities — retry, timeout, backoff. * * Both AnthropicProvider and OpenAICompatProvider use these for * resilient API communication. */ export interface FetchWithRetryOptions { /** Request timeout in ms (applied via AbortController). */ timeout?: number /** Maximum retry attempts (default 0 = no retry). */ maxRetries?: number /** Base delay for exponential backoff in ms (default 1000). */ baseDelay?: number } const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]) function isRetryableError(err: unknown): boolean { if (err instanceof DOMException && err.name === 'AbortError') return false return true } /** * Fetch with optional timeout and retry with exponential backoff. * * Retries on: network errors, 5xx, 429, and the internal timeout firing * (first-byte never arrived) — retried once as a transient network issue. * Does NOT retry on: caller-initiated abort (init.signal), 4xx (except 429). */ export async function fetchWithRetry( url: string, init: RequestInit, options: FetchWithRetryOptions = {}, ): Promise { const { timeout = 60_000, maxRetries = 2, baseDelay = 1000 } = options let lastErr: unknown for (let attempt = 0; attempt <= maxRetries; attempt++) { const controller = new AbortController() let timedOut = false const timer = setTimeout(() => { timedOut = true controller.abort() }, timeout) const signal = init.signal ? anySignal([init.signal, controller.signal]) : controller.signal try { const response = await fetch(url, { ...init, signal }) // 429 / 5xx → retry if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) { const retryAfter = response.headers.get('Retry-After') const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : baseDelay * Math.pow(2, attempt) await sleep(delay) continue } return response } catch (err) { lastErr = err // First-byte timeout is transient → retry once. Caller abort is not. const retryable = timedOut || isRetryableError(err) if (!retryable || attempt >= maxRetries) { if (timedOut) { throw new Error( `API Error: No response from API (timed out after ${Math.round(timeout / 1000)}s)`, ) } throw err } await sleep(baseDelay * Math.pow(2, attempt)) } finally { clearTimeout(timer) // Clean up combined signal if we created one if (init.signal) { try { controller.abort() } catch { /* best effort */ } } } } throw lastErr } /** Simple promise-based sleep. */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } // ── Streaming idle-timeout (stall guard) with effort multiplier ────────── // // Reasoning models think silently for long stretches. The idle timeout between // stream chunks is scaled by reasoning effort so a long thinking pass isn't // mistaken for a stalled connection (jcode stall-guard alignment). /** Base idle timeout between stream chunks, in ms. */ export const STREAM_IDLE_TIMEOUT_BASE_MS = 90_000 /** Effort level → idle-timeout multiplier. Unknown/absent effort → 1× (base). */ const EFFORT_TIMEOUT_MULTIPLIER: Record = { high: 2, xhigh: 3, max: 4, } /** Compute the streaming idle timeout for a given reasoning-effort level. */ export function streamIdleTimeoutMs(effort?: string): number { const multiplier = effort ? (EFFORT_TIMEOUT_MULTIPLIER[effort] ?? 1) : 1 return STREAM_IDLE_TIMEOUT_BASE_MS * multiplier } /** * Combine multiple AbortSignals into one — any signal aborting * triggers the combined signal. */ function anySignal(signals: AbortSignal[]): AbortSignal { const controller = new AbortController() const onAbort = () => { controller.abort() for (const s of signals) s.removeEventListener('abort', onAbort) } for (const s of signals) { if (s.aborted) { controller.abort() return controller.signal } s.addEventListener('abort', onAbort) } return controller.signal }