UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

353 lines • 17.5 kB
"use strict"; /** * withFallback — provider decorator that falls back to a secondary * on error. * * Pattern: Decorator (GoF) — composes two `LLMProvider`s into one. * Role: Outer ring (Hexagonal). Stacks with `withRetry`: * `withRetry(withFallback(primary, fallback))` first retries * the primary, then on exhaustion falls back to the secondary. * * Common pairings: * • Anthropic primary, OpenAI fallback (vendor outage tolerance) * • Real provider primary, Mock fallback (degrade gracefully in dev) * • Premium model primary, cheaper model fallback (cost ceiling) * * `stream()` falls back too — if the primary's stream errors before * yielding any chunks, we restart on the fallback. Once the primary * has yielded chunks the stream is committed — fallback would * duplicate the partial output. * * **Status: contract-shaped and tested — independently reproduced against a * local harness, 2026-08-13.** Somebody who is not this library's author called * a failed primary once and a healthy fallback once, with * `agentfootprint.fallback.triggered` on the typed stream; a stream that failed * BEFORE its first chunk moved to the fallback, and one that failed AFTER a * chunk did not — the stream-pinning law, measured rather than asserted, and * the reason no output was duplicated. Both providers were SCRIPTED doubles and * the run was deterministic and local (the trial's own words: it *"consumed no * GCP credit"*). **No failover between two live providers has been exercised * from this repository**, so this is not the field-validated rung. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.withFallback = void 0; const cacheStrategyFor_js_1 = require("../cache/cacheStrategyFor.js"); const dispatch_js_1 = require("../thinking/dispatch.js"); const types_js_1 = require("../provider-contract/types.js"); const forcedToolChoice_js_1 = require("../provider-contract/forcedToolChoice.js"); const nativeOutputSchema_js_1 = require("../provider-contract/nativeOutputSchema.js"); const requestRefusal_js_1 = require("../provider-contract/requestRefusal.js"); const replayAttempt_js_1 = require("./replayAttempt.js"); const requestObserver_js_1 = require("../provider-contract/requestObserver.js"); const abort_js_1 = require("../lib/abort.js"); /** * The message roles BOTH providers carry — the only honest capability for a * pair where either one may serve the call. An undeclared provider counts as * the user/assistant floor, so pairing with one clamps the result to it. */ function carriedByBoth(a, b) { const left = a.carriesInMessages ?? types_js_1.DEFAULT_CARRIES_IN_MESSAGES; const right = new Set(b.carriesInMessages ?? types_js_1.DEFAULT_CARRIES_IN_MESSAGES); return Object.freeze(left.filter((role) => right.has(role))); } /** * The prompt caching of a pair where either side may serve the call. * * A COST hint, not a meaning: the pair asks the agent for breakpoints when * EITHER side takes them (clamped to the smaller count when both do), and is * automatic when either side caches on its own. Each side is then handed only * what IT declares (`requestFor`) — a side that declared nothing never sees a * `cacheMarkers` field. Usage is reported when either side reports it — a * call the other side served then reads as *unmeasured*, never as a zero. */ function cachingOfPair(a, b, model) { const sides = [(0, cacheStrategyFor_js_1.promptCachingFor)(a, model), (0, cacheStrategyFor_js_1.promptCachingFor)(b, model)].filter((c) => c !== undefined); if (sides.length === 0) return undefined; const reportsUsage = sides.some((c) => c.reportsUsage); const breakpoints = sides.flatMap((c) => (c.mode === 'breakpoints' ? [c.maxBreakpoints] : [])); if (breakpoints.length > 0) { const marked = sides.filter((side) => side.mode === 'breakpoints'); const fields = marked.every((side) => side.fields !== undefined) ? [...new Set(marked.flatMap((side) => side.fields ?? []))] : undefined; return Object.freeze({ mode: 'breakpoints', maxBreakpoints: Math.min(...breakpoints), reportsUsage, ...(fields !== undefined && { fields }), }); } return Object.freeze({ mode: 'automatic', reportsUsage }); } /** * The request as ONE side can take it: markers only for a side that declares * breakpoints, sliced to its own count; stripped for any other side. */ function requestFor(side, req) { const markers = req.cacheMarkers; if (markers === undefined) return req; const caching = (0, cacheStrategyFor_js_1.promptCachingFor)(side, req.model); if (caching?.mode === 'breakpoints') { const supported = caching.fields === undefined ? markers : markers.filter((marker) => caching.fields?.includes(marker.field)); return supported === markers && markers.length <= caching.maxBreakpoints ? req : { ...req, cacheMarkers: supported.slice(0, caching.maxBreakpoints) }; } const rest = { ...req }; delete rest.cacheMarkers; return rest; } /** * The thinking handler of a pair. The same handler on both sides is the * pair's. Different handlers (or one side with none) get a DISPATCHING * handler: the pair tags each response's `rawThinking` with the side that * answered, and the handler runs that side's own — one * wire's handler on the other's thinking would mis-read it, and dropping * both lost the signed blocks the next call must echo. */ function thinkingOfPair(a, b) { const [ha, hb] = [a.thinkingHandler, b.thinkingHandler]; if (ha === hb) return ha; const id = `${ha?.id ?? 'none'}-or-${hb?.id ?? 'none'}`; return (0, dispatch_js_1.dispatchThinkingHandler)(id, { primary: ha, fallback: hb }, { id, normalize() { throw new TypeError("withFallback: this thinking does not say which side answered, so neither side's " + 'handler can read it'); }, }); } /** Least to most a pair can promise about a model's thinking. */ const THINKING_PROMISE = ['none', 'adaptive', 'budget']; /** * The thinking mode of a pair, per model: the LEAST either declared side * promises — `'none'` if either side's model cannot think (the agent refuses * `.thinking()` at build rather than fail on the call the fallback serves), * `'adaptive'` if either side would not send the budget, `'budget'` only when * both send it. An undeclared side makes no claim. The pair never maps the * wire itself: each side's adapter sends ITS model's shape for the same * `req.thinking`. */ function thinkingModeOfPair(a, b) { const [ma, mb] = [a.thinkingMode, b.thinkingMode]; if (ma === undefined || ma === mb) return mb ?? ma; if (mb === undefined) return ma; return (model) => { const [x, y] = [ma(model), mb(model)]; // An answer that is not a mode is passed on, so the agent's check refuses // the malformed declaration instead of this pair hiding it. if (!THINKING_PROMISE.includes(x)) return x; if (!THINKING_PROMISE.includes(y)) return y; return THINKING_PROMISE.indexOf(x) <= THINKING_PROMISE.indexOf(y) ? x : y; }; } /** * Wrap a primary provider with a fallback. Tries primary first; on * error matching the policy, calls the fallback. * * @example * const provider = withFallback( * anthropic({ apiKey: A }), * openai({ apiKey: O }), * { onFallback: (err) => console.warn('primary failed, falling back:', err) }, * ); */ function withFallback(primary, fallback, options = {}) { const shouldFallback = options.shouldFallback ?? defaultShouldFallback; const onFallback = options.onFallback; /** * Report the ONE fact this decorator owns: the primary failed and the * fallback is being called instead. Uses the pairwise `primary`/ * `fallback` names — never a composite chain name — so a * `fallbackProvider(a, b, c)` right-fold reports honest `a→b`, `b→c` * hops. */ function reportFellBack(hooks, err) { hooks?.onResilience?.({ kind: 'fell-back', primary: primary.name, fallback: fallback.name, reason: err instanceof Error ? err.message : String(err), }); } const promptCaching = typeof primary.promptCaching === 'function' || typeof fallback.promptCaching === 'function' ? (model) => cachingOfPair(primary, fallback, model) : cachingOfPair(primary, fallback); const thinkingHandler = thinkingOfPair(primary, fallback); const thinkingMode = thinkingModeOfPair(primary, fallback); const tagsThinking = primary.thinkingHandler !== fallback.thinkingHandler; /** A response as the pair returns it: thinking tagged when the sides differ. */ const answered = (res, side) => tagsThinking ? (0, dispatch_js_1.tagThinkingResponse)(res, side) : res; async function* answeredStream(chunks, side) { for await (const chunk of chunks) { yield chunk.response === undefined ? chunk : { ...chunk, response: answered(chunk.response, side) }; } } const wrapped = { name: `${primary.name}|${fallback.name}`, // The INTERSECTION, and only the intersection. Either provider may serve // the call, so a role only ONE of them carries is a role the call might // drop — and a delivery that lands or vanishes depending on which side // answered is precisely the provider-dependent recording this capability // exists to prevent. Undeclared means the user/assistant floor on that // side, so an undeclared partner clamps the pair to the floor. carriesInMessages: carriedByBoth(primary, fallback), // AND, for the same reason the roles are an intersection: either side may // serve the call, so the pair constrains generation only if BOTH do. One // declared partner and one silent one is a pair whose answer is shaped // some of the time, which is the shape of guarantee nobody can use. carriesForcedToolChoice: (primary.carriesForcedToolChoice ?? false) && (fallback.carriesForcedToolChoice ?? false), ...(primary.supportsForcedToolChoice !== undefined || fallback.supportsForcedToolChoice !== undefined ? { supportsForcedToolChoice: (model) => (0, forcedToolChoice_js_1.forcedToolChoiceFor)(primary, model) && (0, forcedToolChoice_js_1.forcedToolChoiceFor)(fallback, model), } : {}), // The structured-output format (`strategy: 'native'`): AND, for the same // reason — a pair whose answer is constrained only when one side serves it. carriesNativeOutputSchema: (primary.carriesNativeOutputSchema ?? false) && (fallback.carriesNativeOutputSchema ?? false), ...(primary.supportsNativeOutputSchema !== undefined || fallback.supportsNativeOutputSchema !== undefined ? { supportsNativeOutputSchema: (model) => (0, nativeOutputSchema_js_1.nativeOutputSchemaFor)(primary, model) && (0, nativeOutputSchema_js_1.nativeOutputSchemaFor)(fallback, model), } : {}), // Unknown on either side stays unknown to automatic selection. ...(primary.supportsAutoNativeOutputSchema !== undefined || fallback.supportsAutoNativeOutputSchema !== undefined ? { supportsAutoNativeOutputSchema: (model) => (0, nativeOutputSchema_js_1.autoNativeOutputSchemaFor)(primary, model) && (0, nativeOutputSchema_js_1.autoNativeOutputSchemaFor)(fallback, model), } : {}), // The combination, not the intersection — see `cachingOfPair`. ...(promptCaching !== undefined && { promptCaching }), // The side that answered decides which handler reads its thinking — // see `thinkingOfPair`. ...(thinkingHandler !== undefined && { thinkingHandler }), // The least either side promises for a model — see `thinkingModeOfPair`. ...(thinkingMode !== undefined && { thinkingMode }), async complete(req, hooks) { const attempts = (0, replayAttempt_js_1.replayAttempts)(req, hooks); try { const primaryRequest = requestFor(primary, attempts.request); const attempt = (0, requestObserver_js_1.requestAttempt)(attempts.hooks, primaryRequest); try { return attempts.response(answered(await primary.complete(primaryRequest, attempt.hooks), 'primary')); } finally { attempt.close(); } } catch (err) { // A cancelled call never moves to the other side, whatever // `shouldFallback` says: that would start a second billed call for a // caller who has gone (lib/abort.ts). (0, abort_js_1.throwIfAborted)(req.signal); if (!shouldFallback(err)) throw err; onFallback?.(err); reportFellBack(hooks, err); const fallbackRequest = requestFor(fallback, attempts.request); const attempt = (0, requestObserver_js_1.requestAttempt)(attempts.hooks, fallbackRequest); try { return attempts.response(answered(await fallback.complete(fallbackRequest, attempt.hooks), 'fallback')); } finally { attempt.close(); } } }, }; // Stream fallback — only if the primary stream fails before any // chunk yields. Once a chunk is consumed downstream, restarting // would replay tokens. Yields from primary as long as it's working; // catches errors in the iteration setup or first chunk only. if (primary.stream || fallback.stream) { wrapped.stream = async function* fallbackStream(req, hooks) { const attempts = (0, replayAttempt_js_1.replayAttempts)(req, hooks); // No primary stream support → fallback's stream (or its complete-only). // Reports NOTHING: nothing failed here, the primary simply has no // `stream()`. Calling this a fallback would be a lie. const fromFallback = async function* () { const fallbackReq = requestFor(fallback, attempts.request); const attempt = (0, requestObserver_js_1.requestAttempt)(attempts.hooks, fallbackReq); try { for await (const chunk of answeredStream(fallback.stream ? fallback.stream(fallbackReq, attempt.hooks) : completeAsStream(fallback, fallbackReq, attempt.hooks), 'fallback')) yield attempts.chunk(chunk); } finally { attempt.close(); } }; if (!primary.stream) { yield* fromFallback(); return; } let yieldedAny = false; try { const primaryRequest = requestFor(primary, attempts.request); const attempt = (0, requestObserver_js_1.requestAttempt)(attempts.hooks, primaryRequest); try { for await (const chunk of answeredStream(primary.stream(primaryRequest, attempt.hooks), 'primary')) { yieldedAny = true; yield attempts.chunk(chunk); } } finally { attempt.close(); } } catch (err) { (0, abort_js_1.throwIfAborted)(req.signal); if (yieldedAny || !shouldFallback(err)) throw err; onFallback?.(err); reportFellBack(hooks, err); yield* fromFallback(); } }; } return wrapped; } exports.withFallback = withFallback; // ── Defaults ──────────────────────────────────────────────────────── function defaultShouldFallback(err) { if (!err || typeof err !== 'object') return true; const e = err; if (e.name === 'AbortError' || e.code === 'ABORT_ERR') return false; // A request the primary's adapter refused before sending is a wrong // REQUEST, not a failed vendor: it surfaces, rather than every call of the // run quietly moving to the other side. return !(0, requestRefusal_js_1.isRequestRefusal)(err); } /** * Synthesize a stream from a non-streaming provider's `complete()` * call: one terminal chunk carrying the whole response. Lets the * fallback chain still satisfy a `stream()` request even when the * fallback only implements `complete()`. */ async function* completeAsStream(provider, req, hooks) { const response = await provider.complete(req, hooks); yield { tokenIndex: 0, content: '', done: true, response, }; } //# sourceMappingURL=withFallback.js.map