Multi-model fallback
Provider failure is just stream failure recovery.
Try OpenAI (gpt-5.4-mini). If it returns RateLimited or Unavailable,
advance the loop state to the next tier (Gemini gemini-3-flash-preview)
and try again with the same state.history. Other typed AiError variants -
ContentFiltered, AuthFailed, ContextLengthExceeded, InvalidRequest -
propagate to the caller. The first successful turn ends the loop.
The same shape works for any number of tiers. Add more entries to the
tiers array; the loop walks them in order until one succeeds or the
list is exhausted.
What it shows
- Building two distinct
LanguageModelServicevalues viaResponses.makeandGemini.make, then selecting between them by an index threaded through state. - Catching specific
AiErrortags withStream.catchTagto convert a failure into a state advance (nextAfter(Stream.empty, { tier: tier + 1 })) instead of letting it terminate the stream. - Letting non-retryable errors fall through unchanged - the rest of the
union (
ContentFiltered,InvalidRequest, etc.) crosses the loop boundary and surfaces in the caller’s error channel.
The loop, in shape
const conversation = (tiers: ReadonlyArray<Tier>) => pipe( initial, loop((state) => Effect.gen(function* () { const tier = tiers[state.tier] if (tier === undefined) return stop // exhausted
// Fallback is a state transition: same history, next provider tier. const advanceTier = (reason: string) => Effect.logWarning(`${tier.name}: ${reason} - falling back`).pipe( Effect.as(nextAfter(Stream.empty, { ...state, tier: state.tier + 1 })), )
return tier.service.streamTurn({ history: state.history, model: tier.model }).pipe( onTurnComplete(() => Effect.sync(() => stop)), // Only retry errors become continuation; everything else crosses the boundary. Stream.catchTag("RateLimited", () => Stream.unwrap(advanceTier("rate-limited"))), Stream.catchTag("Unavailable", () => Stream.unwrap(advanceTier("unavailable"))), ) }), ), )The same state.history is reused on the fallback because the body
returned nextAfter(..., { ...state, tier: state.tier + 1 }) rather than
calling Turn.appendTurn(state, turn) - we never advanced past the failed
turn.
Forcing the fallback in the live demo
To see the fallback fire against real APIs, the recipe configures the
primary tier with a deliberately broken baseUrl:
const openai = yield * makeResponses({ apiKey: openaiKey, baseUrl: "https://invalid-host.example.invalid/v1", })The HTTP client fails to resolve the host, the provider maps it to
AiError.Unavailable, and the loop advances to the Gemini tier which
runs against the real endpoint and produces the answer.
Run it
OPENAI_API_KEY=sk-... GOOGLE_API_KEY=... pnpm tsx recipes/multi-model-fallback/index.tsThe full source lives next to this README at
index.ts.