@@ -114,6 +114,19 @@ export interface Plan {
114114 * Options for a {@link generateText} call.
115115 * Either `prompt` or `messages` (or both) must be provided.
116116 */
117+ /**
118+ * A fallback provider entry specifying an alternative provider (and optionally
119+ * model) to try when the primary provider fails with a retryable error.
120+ *
121+ * @see {@link GenerateTextOptions.fallbackProviders }
122+ */
123+ export interface FallbackProviderEntry {
124+ /** Provider identifier (e.g. `"openai"`, `"anthropic"`, `"openrouter"`). */
125+ provider : string ;
126+ /** Model identifier override. When omitted, the provider's default text model is used. */
127+ model ?: string ;
128+ }
129+
117130export interface GenerateTextOptions {
118131 /**
119132 * Provider name. When supplied without `model`, the default text model for
@@ -182,6 +195,38 @@ export interface GenerateTextOptions {
182195 * Set to `false` or omit to skip planning entirely (the default).
183196 */
184197 planning ?: boolean | PlanningConfig ;
198+ /**
199+ * Ordered list of fallback providers to try when the primary provider fails
200+ * with a retryable error (HTTP 402/429/5xx, network errors, auth failures).
201+ *
202+ * Each entry specifies a provider and an optional model override. When the
203+ * model is omitted, the provider's default text model (from
204+ * {@link PROVIDER_DEFAULTS}) is used.
205+ *
206+ * Providers are tried left-to-right; the first successful response wins.
207+ * When all fallbacks are exhausted, the last error is re-thrown.
208+ *
209+ * @example
210+ * ```ts
211+ * const result = await generateText({
212+ * provider: 'anthropic',
213+ * prompt: 'Hello',
214+ * fallbackProviders: [
215+ * { provider: 'openai', model: 'gpt-4o-mini' },
216+ * { provider: 'openrouter' },
217+ * ],
218+ * });
219+ * ```
220+ */
221+ fallbackProviders ?: FallbackProviderEntry [ ] ;
222+ /**
223+ * Callback invoked when a fallback provider is about to be tried after the
224+ * primary (or a previous fallback) failed. Useful for logging or metrics.
225+ *
226+ * @param error - The error that triggered the fallback.
227+ * @param fallbackProvider - The provider identifier being tried next.
228+ */
229+ onFallback ?: ( error : Error , fallbackProvider : string ) => void ;
185230}
186231
187232/**
@@ -370,6 +415,85 @@ function formatPlanForPrompt(plan: Plan): string {
370415 return `Follow this plan:\n${ lines . join ( '\n' ) } ` ;
371416}
372417
418+ // ---------------------------------------------------------------------------
419+ // Fallback helpers
420+ // ---------------------------------------------------------------------------
421+
422+ /**
423+ * HTTP status codes and network error patterns that indicate a transient or
424+ * provider-level failure worth retrying with a different provider.
425+ *
426+ * Matched status codes:
427+ * - `401` / `403` — authentication / authorization failure (key expired or wrong provider).
428+ * - `402` — payment required (quota exhausted).
429+ * - `429` — rate limit exceeded.
430+ * - `500` / `502` / `503` / `504` — server-side errors.
431+ *
432+ * Matched network errors:
433+ * - `fetch failed` — generic fetch rejection (DNS, TLS, etc.).
434+ * - `ECONNREFUSED` / `ETIMEDOUT` / `ENOTFOUND` — socket-level failures.
435+ *
436+ * @param error - The error to inspect.
437+ * @returns `true` when the error is likely transient and a different provider
438+ * might succeed; `false` for deterministic user-input errors.
439+ *
440+ * @internal
441+ */
442+ export function isRetryableError ( error : unknown ) : boolean {
443+ if ( ! ( error instanceof Error ) ) return false ;
444+ const msg = error . message ;
445+ // HTTP status codes that warrant a provider switch
446+ if ( / \b ( 4 0 2 | 4 2 9 | 5 0 0 | 5 0 2 | 5 0 3 | 5 0 4 | 4 0 1 | 4 0 3 ) \b / . test ( msg ) ) return true ;
447+ // Network-level failures
448+ if ( / f e t c h f a i l e d | E C O N N R E F U S E D | E T I M E D O U T | E N O T F O U N D / i. test ( msg ) ) return true ;
449+ return false ;
450+ }
451+
452+ /**
453+ * Auto-discovers available LLM providers from well-known environment variables
454+ * and builds an ordered fallback chain.
455+ *
456+ * Each entry in the returned array contains a provider identifier and an
457+ * optional cheap model suitable for fallback use. Providers are ordered by
458+ * general availability and cost-effectiveness:
459+ * 1. OpenAI (`gpt-4o-mini`)
460+ * 2. Anthropic (`claude-haiku-4-5-20251001`)
461+ * 3. OpenRouter (default model)
462+ * 4. Gemini (`gemini-2.5-flash`)
463+ *
464+ * @param excludeProvider - Provider to omit from the chain (typically the
465+ * primary provider that already failed).
466+ * @returns An array of `{ provider, model? }` entries ready for use as
467+ * {@link GenerateTextOptions.fallbackProviders}.
468+ *
469+ * @example
470+ * ```ts
471+ * // Primary is anthropic — build fallback chain from remaining providers
472+ * const chain = buildFallbackChain('anthropic');
473+ * // => [{ provider: 'openai', model: 'gpt-4o-mini' }, { provider: 'openrouter' }, ...]
474+ * ```
475+ */
476+ export function buildFallbackChain (
477+ excludeProvider ?: string ,
478+ ) : FallbackProviderEntry [ ] {
479+ const chain : FallbackProviderEntry [ ] = [ ] ;
480+
481+ if ( process . env . OPENAI_API_KEY && excludeProvider !== 'openai' ) {
482+ chain . push ( { provider : 'openai' , model : 'gpt-4o-mini' } ) ;
483+ }
484+ if ( process . env . ANTHROPIC_API_KEY && excludeProvider !== 'anthropic' ) {
485+ chain . push ( { provider : 'anthropic' , model : 'claude-haiku-4-5-20251001' } ) ;
486+ }
487+ if ( process . env . OPENROUTER_API_KEY && excludeProvider !== 'openrouter' ) {
488+ chain . push ( { provider : 'openrouter' } ) ;
489+ }
490+ if ( process . env . GEMINI_API_KEY && excludeProvider !== 'gemini' ) {
491+ chain . push ( { provider : 'gemini' } ) ;
492+ }
493+
494+ return chain ;
495+ }
496+
373497function buildHelperToolExecutionContext (
374498 source : 'generateText' ,
375499 runId : string ,
@@ -702,6 +826,49 @@ export async function generateText(opts: GenerateTextOptions): Promise<GenerateT
702826 } ;
703827 } ) ;
704828 } catch ( error ) {
829+ // ── Fallback chain ────────────────────────────────────────────────
830+ // When the primary provider fails with a retryable error and
831+ // fallbackProviders are configured, try each fallback in order.
832+ // The first successful response wins; if all fail, the last error
833+ // is re-thrown.
834+ if (
835+ opts . fallbackProviders ?. length &&
836+ isRetryableError ( error )
837+ ) {
838+ let lastError = error ;
839+ for ( const fb of opts . fallbackProviders ) {
840+ try {
841+ opts . onFallback ?.(
842+ lastError instanceof Error ? lastError : new Error ( String ( lastError ) ) ,
843+ fb . provider ,
844+ ) ;
845+ // Build a new options object targeting the fallback provider,
846+ // stripping the fallbackProviders to prevent recursive fallback.
847+ const fallbackResult = await generateText ( {
848+ ...opts ,
849+ provider : fb . provider ,
850+ model : fb . model ,
851+ // Clear explicit keys/URLs so resolution uses env vars for the
852+ // fallback provider rather than the primary's overrides.
853+ apiKey : undefined ,
854+ baseUrl : undefined ,
855+ fallbackProviders : undefined ,
856+ onFallback : undefined ,
857+ } ) ;
858+ metricStatus = 'ok' ;
859+ metricUsage = fallbackResult . usage ;
860+ metricProviderId = fallbackResult . provider ;
861+ metricModelId = fallbackResult . model ;
862+ return fallbackResult ;
863+ } catch ( fbError ) {
864+ lastError = fbError ;
865+ }
866+ }
867+ // All fallbacks exhausted — fall through to throw
868+ metricStatus = 'error' ;
869+ throw lastError ;
870+ }
871+
705872 metricStatus = 'error' ;
706873 throw error ;
707874 } finally {
0 commit comments