Skip to content

Multi-Provider Fallback

Automatic failover between LLM providers for high availability.

Features

  • Provider Chain - OpenAI → Anthropic → Ollama fallback
  • Automatic Retry - Per-provider retry with backoff
  • Universal Fallback - Falls back on ANY error for maximum availability
  • Circuit Breaker - Prevents repeated failures to unhealthy providers
  • Health Check - Verify provider status before critical operations
  • Streaming Support - Works with both chat and streaming
  • Provider Logging - Shows which provider succeeded

Quick Start

bash
export OPENAI_API_KEY=your_key
export ANTHROPIC_API_KEY=your_key
npm run recipe:multi-provider-fallback

How It Works

This recipe creates a resilient chat system that automatically fails over between multiple LLM providers when errors occur.

Provider priority:

  1. OpenAI (primary)
  2. Anthropic (secondary)
  3. Ollama (tertiary/local)

Flow:

  1. Send request to primary provider (OpenAI)
  2. If error occurs, retry with exponential backoff
  3. After max retries, fall back to secondary (Anthropic)
  4. If secondary fails, try tertiary (Ollama)
  5. Return response from whichever provider succeeds
  6. Throw error only if all providers fail

Provider Chain

Example Output

╭─────────────────────────────────────────────╮
│  Multi-Provider Fallback                    │
│  High Availability LLM Client               │
╰─────────────────────────────────────────────╯

Provider chain:
  1. ● OpenAI (gpt-4o-mini)
  2. ● Anthropic (claude-3-haiku-20240307)
  3. ● Ollama (llama3.2)

ℹ Configured OpenAI (openai/gpt-4o-mini)
ℹ Configured Anthropic (anthropic/claude-3-haiku-20240307)
ℹ Configured Ollama (ollama/llama3.2)

━━━ Regular Chat ━━━

ℹ Trying OpenAI...
✓ OpenAI succeeded

Response: Four
Provider: OpenAI

━━━ Streaming Chat ━━━

ℹ Trying OpenAI (streaming)...
✓ OpenAI started streaming
Response: 1
2
3
4
5
Provider: OpenAI

Fallback Triggers

The client falls back on ANY error to maximize availability. This is a deliberate design choice - when uptime matters more than knowing exactly what failed, universal fallback ensures requests succeed whenever possible.

ErrorDescriptionFallback?
RateLimitErrorProvider rate limit exceeded
TimeoutErrorRequest timed out
ServerErrorProvider server error (5xx)
AuthenticationErrorInvalid API key
Other errorsNetwork issues, parsing errors, etc.

Streaming Behavior

When streaming fails mid-response, the partial response is lost and fallback starts fresh with the next provider. This is intentional:

  • Simpler implementation
  • Consistent user experience (no partial + retry concatenation)
  • Most streaming failures happen at connection, not mid-stream

If you need to preserve partial responses, consider buffering chunks yourself and implementing custom recovery.

Code Walkthrough

Types and Configuration

typescript
/**
 * Multi-Provider Fallback Library
 *
 * Exported functions for the Multi-Provider Fallback recipe.
 * Snippet markers allow VitePress to extract code for documentation.
 */

import { ChatClient, ChatError } from '../../../src';

// [start:colors]
export const colors = {
  reset: '\x1b[0m',
  dim: '\x1b[2m',
  bold: '\x1b[1m',
  red: '\x1b[31m',
  yellow: '\x1b[33m',
  green: '\x1b[32m',
  cyan: '\x1b[36m',
  magenta: '\x1b[35m'
};
// [end:colors]

// [start:types]
export interface CircuitBreakerConfig {
  failureThreshold: number; // Open circuit after N failures
  resetTimeoutMs: number; // Try again after N ms
}

export interface ProviderConfig {
  name: string;
  provider: 'openai' | 'anthropic' | 'ollama';
  model: string;
  apiKey?: string;
  baseUrl?: string;
  retryConfig: {
    maxAttempts: number;
    initialBackoffMs: number;
    maxBackoffMs: number;
    backoffMultiplier: number;
  };
  circuitBreaker?: CircuitBreakerConfig;
}

export interface CircuitState {
  failures: number;
  lastFailure: number;
  isOpen: boolean;
  config: CircuitBreakerConfig;
}

export interface HealthCheckResult {
  name: string;
  healthy: boolean;
  latencyMs?: number;
  error?: string;
}

export interface ChatResult {
  content: string;
  provider: string;
}

export interface StreamChunk {
  content: string;
  provider: string;
  done: boolean;
}

export interface CircuitStatus {
  name: string;
  isOpen: boolean;
  failures: number;
}
// [end:types]

// [start:default-config]
export const DEFAULT_CIRCUIT_BREAKER: CircuitBreakerConfig = {
  failureThreshold: 3,
  resetTimeoutMs: 30000
};
// [end:default-config]

// [start:logging]
export function formatError(error: unknown): string {
  if (error instanceof ChatError) {
    return `${error.name}: ${error.message}`;
  }
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

export function printBanner(): void {
  console.log(`
${colors.cyan}╭─────────────────────────────────────────────╮
│  ${colors.reset}${colors.bold}Multi-Provider Fallback${colors.reset}${colors.cyan}                    │
│  ${colors.dim}High Availability LLM Client${colors.reset}${colors.cyan}                │
╰─────────────────────────────────────────────╯${colors.reset}
`);
}

export function log(level: 'info' | 'warn' | 'error' | 'success', message: string): void {
  const prefix = {
    info: `${colors.cyan}ℹ${colors.reset}`,
    warn: `${colors.yellow}⚠${colors.reset}`,
    error: `${colors.red}✗${colors.reset}`,
    success: `${colors.green}✓${colors.reset}`
  };
  console.log(`${prefix[level]} ${message}`);
}
// [end:logging]

// [start:fallback-client]
/**
 * Multi-provider client with automatic fallback and circuit breaker
 */
export class FallbackClient {
  private providers: ProviderConfig[];
  private clients: Map<string, ChatClient> = new Map();
  private circuits: Map<string, CircuitState> = new Map();

  constructor(providers: ProviderConfig[]) {
    this.providers = providers;

    // Pre-create clients for each provider
    for (const config of providers) {
      try {
        const client = new ChatClient({
          provider: config.provider,
          model: config.model,
          apiKey: config.apiKey,
          baseUrl: config.baseUrl,
          retryConfig: config.retryConfig
        });
        this.clients.set(config.name, client);
        this.circuits.set(config.name, {
          failures: 0,
          lastFailure: 0,
          isOpen: false,
          config: config.circuitBreaker ?? DEFAULT_CIRCUIT_BREAKER
        });
        log('info', `Configured ${config.name} (${config.provider}/${config.model})`);
      } catch (error) {
        log('warn', `Failed to configure ${config.name}: ${formatError(error)}`);
      }
    }

    if (this.clients.size === 0) {
      throw new Error('No providers could be configured');
    }
  }

  /**
   * Check if circuit breaker allows requests to this provider
   */
  private isCircuitClosed(name: string): boolean {
    const circuit = this.circuits.get(name);
    if (!circuit) return false;

    if (!circuit.isOpen) return true;

    // Check if enough time has passed to try again
    const elapsed = Date.now() - circuit.lastFailure;
    if (elapsed >= circuit.config.resetTimeoutMs) {
      circuit.isOpen = false;
      circuit.failures = 0;
      log('info', `Circuit closed for ${name} (reset after ${circuit.config.resetTimeoutMs}ms)`);
      return true;
    }

    return false;
  }

  /**
   * Record a failure for circuit breaker
   */
  private recordFailure(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures++;
    circuit.lastFailure = Date.now();

    if (circuit.failures >= circuit.config.failureThreshold) {
      circuit.isOpen = true;
      log('warn', `Circuit opened for ${name} (${circuit.failures} failures)`);
    }
  }

  /**
   * Record a success - reset circuit breaker
   */
  private recordSuccess(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures = 0;
    circuit.isOpen = false;
  }

  /**
   * Health check - ping all providers
   * @param message Optional message to send (default: "ping")
   */
  async healthCheck(message = 'ping'): Promise<HealthCheckResult[]> {
    const results: HealthCheckResult[] = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) {
        results.push({ name: config.name, healthy: false, error: 'Not configured' });
        continue;
      }

      const start = Date.now();
      try {
        await client.chat(message);
        results.push({
          name: config.name,
          healthy: true,
          latencyMs: Date.now() - start
        });
      } catch (error) {
        results.push({
          name: config.name,
          healthy: false,
          latencyMs: Date.now() - start,
          error: formatError(error)
        });
      }
    }

    return results;
  }

  /**
   * Send a chat message with automatic fallback
   * Falls back on ANY error for maximum availability
   */
  async chat(message: string): Promise<ChatResult> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name}...`);

      try {
        const response = await client.chat(message);
        this.recordSuccess(config.name);
        log('success', `${config.name} succeeded`);
        return {
          content: response.content,
          provider: config.name
        };
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }

  // [start:streaming]
  /**
   * Stream a chat message with automatic fallback
   *
   * Note: If streaming fails mid-stream, partial response is lost.
   * Fallback starts fresh from the beginning with next provider.
   */
  async *stream(message: string): AsyncGenerator<StreamChunk> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name} (streaming)...`);

      try {
        let hasYielded = false;
        for await (const chunk of client.stream(message)) {
          if (!hasYielded) {
            log('success', `${config.name} started streaming`);
            hasYielded = true;
          }
          yield {
            content: chunk.content,
            provider: config.name,
            done: false
          };
        }
        this.recordSuccess(config.name);
        yield {
          content: '',
          provider: config.name,
          done: true
        };
        return; // Success, exit generator
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }
  // [end:streaming]

  /**
   * Get list of configured providers
   */
  getProviders(): string[] {
    return Array.from(this.clients.keys());
  }

  /**
   * Get circuit breaker status for all providers
   */
  getCircuitStatus(): CircuitStatus[] {
    return this.providers
      .filter((p) => this.circuits.has(p.name))
      .map((p) => {
        const circuit = this.circuits.get(p.name)!;
        return {
          name: p.name,
          isOpen: circuit.isOpen,
          failures: circuit.failures
        };
      });
  }
}
// [end:fallback-client]

// [start:create-providers]
export function createDefaultProviders(): ProviderConfig[] {
  const providers: ProviderConfig[] = [];

  if (process.env.OPENAI_API_KEY) {
    providers.push({
      name: 'OpenAI',
      provider: 'openai',
      model: 'gpt-4o-mini',
      apiKey: process.env.OPENAI_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  if (process.env.ANTHROPIC_API_KEY) {
    providers.push({
      name: 'Anthropic',
      provider: 'anthropic',
      model: 'claude-3-haiku-20240307',
      apiKey: process.env.ANTHROPIC_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  // Always add Ollama as local fallback (may not be running)
  providers.push({
    name: 'Ollama',
    provider: 'ollama',
    model: 'llama3.2',
    baseUrl: 'http://localhost:11434',
    retryConfig: {
      maxAttempts: 1,
      initialBackoffMs: 500,
      maxBackoffMs: 2000,
      backoffMultiplier: 2
    }
  });

  return providers;
}
// [end:create-providers]

Default Circuit Breaker

typescript
/**
 * Multi-Provider Fallback Library
 *
 * Exported functions for the Multi-Provider Fallback recipe.
 * Snippet markers allow VitePress to extract code for documentation.
 */

import { ChatClient, ChatError } from '../../../src';

// [start:colors]
export const colors = {
  reset: '\x1b[0m',
  dim: '\x1b[2m',
  bold: '\x1b[1m',
  red: '\x1b[31m',
  yellow: '\x1b[33m',
  green: '\x1b[32m',
  cyan: '\x1b[36m',
  magenta: '\x1b[35m'
};
// [end:colors]

// [start:types]
export interface CircuitBreakerConfig {
  failureThreshold: number; // Open circuit after N failures
  resetTimeoutMs: number; // Try again after N ms
}

export interface ProviderConfig {
  name: string;
  provider: 'openai' | 'anthropic' | 'ollama';
  model: string;
  apiKey?: string;
  baseUrl?: string;
  retryConfig: {
    maxAttempts: number;
    initialBackoffMs: number;
    maxBackoffMs: number;
    backoffMultiplier: number;
  };
  circuitBreaker?: CircuitBreakerConfig;
}

export interface CircuitState {
  failures: number;
  lastFailure: number;
  isOpen: boolean;
  config: CircuitBreakerConfig;
}

export interface HealthCheckResult {
  name: string;
  healthy: boolean;
  latencyMs?: number;
  error?: string;
}

export interface ChatResult {
  content: string;
  provider: string;
}

export interface StreamChunk {
  content: string;
  provider: string;
  done: boolean;
}

export interface CircuitStatus {
  name: string;
  isOpen: boolean;
  failures: number;
}
// [end:types]

// [start:default-config]
export const DEFAULT_CIRCUIT_BREAKER: CircuitBreakerConfig = {
  failureThreshold: 3,
  resetTimeoutMs: 30000
};
// [end:default-config]

// [start:logging]
export function formatError(error: unknown): string {
  if (error instanceof ChatError) {
    return `${error.name}: ${error.message}`;
  }
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

export function printBanner(): void {
  console.log(`
${colors.cyan}╭─────────────────────────────────────────────╮
│  ${colors.reset}${colors.bold}Multi-Provider Fallback${colors.reset}${colors.cyan}                    │
│  ${colors.dim}High Availability LLM Client${colors.reset}${colors.cyan}                │
╰─────────────────────────────────────────────╯${colors.reset}
`);
}

export function log(level: 'info' | 'warn' | 'error' | 'success', message: string): void {
  const prefix = {
    info: `${colors.cyan}ℹ${colors.reset}`,
    warn: `${colors.yellow}⚠${colors.reset}`,
    error: `${colors.red}✗${colors.reset}`,
    success: `${colors.green}✓${colors.reset}`
  };
  console.log(`${prefix[level]} ${message}`);
}
// [end:logging]

// [start:fallback-client]
/**
 * Multi-provider client with automatic fallback and circuit breaker
 */
export class FallbackClient {
  private providers: ProviderConfig[];
  private clients: Map<string, ChatClient> = new Map();
  private circuits: Map<string, CircuitState> = new Map();

  constructor(providers: ProviderConfig[]) {
    this.providers = providers;

    // Pre-create clients for each provider
    for (const config of providers) {
      try {
        const client = new ChatClient({
          provider: config.provider,
          model: config.model,
          apiKey: config.apiKey,
          baseUrl: config.baseUrl,
          retryConfig: config.retryConfig
        });
        this.clients.set(config.name, client);
        this.circuits.set(config.name, {
          failures: 0,
          lastFailure: 0,
          isOpen: false,
          config: config.circuitBreaker ?? DEFAULT_CIRCUIT_BREAKER
        });
        log('info', `Configured ${config.name} (${config.provider}/${config.model})`);
      } catch (error) {
        log('warn', `Failed to configure ${config.name}: ${formatError(error)}`);
      }
    }

    if (this.clients.size === 0) {
      throw new Error('No providers could be configured');
    }
  }

  /**
   * Check if circuit breaker allows requests to this provider
   */
  private isCircuitClosed(name: string): boolean {
    const circuit = this.circuits.get(name);
    if (!circuit) return false;

    if (!circuit.isOpen) return true;

    // Check if enough time has passed to try again
    const elapsed = Date.now() - circuit.lastFailure;
    if (elapsed >= circuit.config.resetTimeoutMs) {
      circuit.isOpen = false;
      circuit.failures = 0;
      log('info', `Circuit closed for ${name} (reset after ${circuit.config.resetTimeoutMs}ms)`);
      return true;
    }

    return false;
  }

  /**
   * Record a failure for circuit breaker
   */
  private recordFailure(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures++;
    circuit.lastFailure = Date.now();

    if (circuit.failures >= circuit.config.failureThreshold) {
      circuit.isOpen = true;
      log('warn', `Circuit opened for ${name} (${circuit.failures} failures)`);
    }
  }

  /**
   * Record a success - reset circuit breaker
   */
  private recordSuccess(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures = 0;
    circuit.isOpen = false;
  }

  /**
   * Health check - ping all providers
   * @param message Optional message to send (default: "ping")
   */
  async healthCheck(message = 'ping'): Promise<HealthCheckResult[]> {
    const results: HealthCheckResult[] = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) {
        results.push({ name: config.name, healthy: false, error: 'Not configured' });
        continue;
      }

      const start = Date.now();
      try {
        await client.chat(message);
        results.push({
          name: config.name,
          healthy: true,
          latencyMs: Date.now() - start
        });
      } catch (error) {
        results.push({
          name: config.name,
          healthy: false,
          latencyMs: Date.now() - start,
          error: formatError(error)
        });
      }
    }

    return results;
  }

  /**
   * Send a chat message with automatic fallback
   * Falls back on ANY error for maximum availability
   */
  async chat(message: string): Promise<ChatResult> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name}...`);

      try {
        const response = await client.chat(message);
        this.recordSuccess(config.name);
        log('success', `${config.name} succeeded`);
        return {
          content: response.content,
          provider: config.name
        };
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }

  // [start:streaming]
  /**
   * Stream a chat message with automatic fallback
   *
   * Note: If streaming fails mid-stream, partial response is lost.
   * Fallback starts fresh from the beginning with next provider.
   */
  async *stream(message: string): AsyncGenerator<StreamChunk> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name} (streaming)...`);

      try {
        let hasYielded = false;
        for await (const chunk of client.stream(message)) {
          if (!hasYielded) {
            log('success', `${config.name} started streaming`);
            hasYielded = true;
          }
          yield {
            content: chunk.content,
            provider: config.name,
            done: false
          };
        }
        this.recordSuccess(config.name);
        yield {
          content: '',
          provider: config.name,
          done: true
        };
        return; // Success, exit generator
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }
  // [end:streaming]

  /**
   * Get list of configured providers
   */
  getProviders(): string[] {
    return Array.from(this.clients.keys());
  }

  /**
   * Get circuit breaker status for all providers
   */
  getCircuitStatus(): CircuitStatus[] {
    return this.providers
      .filter((p) => this.circuits.has(p.name))
      .map((p) => {
        const circuit = this.circuits.get(p.name)!;
        return {
          name: p.name,
          isOpen: circuit.isOpen,
          failures: circuit.failures
        };
      });
  }
}
// [end:fallback-client]

// [start:create-providers]
export function createDefaultProviders(): ProviderConfig[] {
  const providers: ProviderConfig[] = [];

  if (process.env.OPENAI_API_KEY) {
    providers.push({
      name: 'OpenAI',
      provider: 'openai',
      model: 'gpt-4o-mini',
      apiKey: process.env.OPENAI_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  if (process.env.ANTHROPIC_API_KEY) {
    providers.push({
      name: 'Anthropic',
      provider: 'anthropic',
      model: 'claude-3-haiku-20240307',
      apiKey: process.env.ANTHROPIC_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  // Always add Ollama as local fallback (may not be running)
  providers.push({
    name: 'Ollama',
    provider: 'ollama',
    model: 'llama3.2',
    baseUrl: 'http://localhost:11434',
    retryConfig: {
      maxAttempts: 1,
      initialBackoffMs: 500,
      maxBackoffMs: 2000,
      backoffMultiplier: 2
    }
  });

  return providers;
}
// [end:create-providers]

Fallback Client

typescript
/**
 * Multi-Provider Fallback Library
 *
 * Exported functions for the Multi-Provider Fallback recipe.
 * Snippet markers allow VitePress to extract code for documentation.
 */

import { ChatClient, ChatError } from '../../../src';

// [start:colors]
export const colors = {
  reset: '\x1b[0m',
  dim: '\x1b[2m',
  bold: '\x1b[1m',
  red: '\x1b[31m',
  yellow: '\x1b[33m',
  green: '\x1b[32m',
  cyan: '\x1b[36m',
  magenta: '\x1b[35m'
};
// [end:colors]

// [start:types]
export interface CircuitBreakerConfig {
  failureThreshold: number; // Open circuit after N failures
  resetTimeoutMs: number; // Try again after N ms
}

export interface ProviderConfig {
  name: string;
  provider: 'openai' | 'anthropic' | 'ollama';
  model: string;
  apiKey?: string;
  baseUrl?: string;
  retryConfig: {
    maxAttempts: number;
    initialBackoffMs: number;
    maxBackoffMs: number;
    backoffMultiplier: number;
  };
  circuitBreaker?: CircuitBreakerConfig;
}

export interface CircuitState {
  failures: number;
  lastFailure: number;
  isOpen: boolean;
  config: CircuitBreakerConfig;
}

export interface HealthCheckResult {
  name: string;
  healthy: boolean;
  latencyMs?: number;
  error?: string;
}

export interface ChatResult {
  content: string;
  provider: string;
}

export interface StreamChunk {
  content: string;
  provider: string;
  done: boolean;
}

export interface CircuitStatus {
  name: string;
  isOpen: boolean;
  failures: number;
}
// [end:types]

// [start:default-config]
export const DEFAULT_CIRCUIT_BREAKER: CircuitBreakerConfig = {
  failureThreshold: 3,
  resetTimeoutMs: 30000
};
// [end:default-config]

// [start:logging]
export function formatError(error: unknown): string {
  if (error instanceof ChatError) {
    return `${error.name}: ${error.message}`;
  }
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

export function printBanner(): void {
  console.log(`
${colors.cyan}╭─────────────────────────────────────────────╮
│  ${colors.reset}${colors.bold}Multi-Provider Fallback${colors.reset}${colors.cyan}                    │
│  ${colors.dim}High Availability LLM Client${colors.reset}${colors.cyan}                │
╰─────────────────────────────────────────────╯${colors.reset}
`);
}

export function log(level: 'info' | 'warn' | 'error' | 'success', message: string): void {
  const prefix = {
    info: `${colors.cyan}ℹ${colors.reset}`,
    warn: `${colors.yellow}⚠${colors.reset}`,
    error: `${colors.red}✗${colors.reset}`,
    success: `${colors.green}✓${colors.reset}`
  };
  console.log(`${prefix[level]} ${message}`);
}
// [end:logging]

// [start:fallback-client]
/**
 * Multi-provider client with automatic fallback and circuit breaker
 */
export class FallbackClient {
  private providers: ProviderConfig[];
  private clients: Map<string, ChatClient> = new Map();
  private circuits: Map<string, CircuitState> = new Map();

  constructor(providers: ProviderConfig[]) {
    this.providers = providers;

    // Pre-create clients for each provider
    for (const config of providers) {
      try {
        const client = new ChatClient({
          provider: config.provider,
          model: config.model,
          apiKey: config.apiKey,
          baseUrl: config.baseUrl,
          retryConfig: config.retryConfig
        });
        this.clients.set(config.name, client);
        this.circuits.set(config.name, {
          failures: 0,
          lastFailure: 0,
          isOpen: false,
          config: config.circuitBreaker ?? DEFAULT_CIRCUIT_BREAKER
        });
        log('info', `Configured ${config.name} (${config.provider}/${config.model})`);
      } catch (error) {
        log('warn', `Failed to configure ${config.name}: ${formatError(error)}`);
      }
    }

    if (this.clients.size === 0) {
      throw new Error('No providers could be configured');
    }
  }

  /**
   * Check if circuit breaker allows requests to this provider
   */
  private isCircuitClosed(name: string): boolean {
    const circuit = this.circuits.get(name);
    if (!circuit) return false;

    if (!circuit.isOpen) return true;

    // Check if enough time has passed to try again
    const elapsed = Date.now() - circuit.lastFailure;
    if (elapsed >= circuit.config.resetTimeoutMs) {
      circuit.isOpen = false;
      circuit.failures = 0;
      log('info', `Circuit closed for ${name} (reset after ${circuit.config.resetTimeoutMs}ms)`);
      return true;
    }

    return false;
  }

  /**
   * Record a failure for circuit breaker
   */
  private recordFailure(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures++;
    circuit.lastFailure = Date.now();

    if (circuit.failures >= circuit.config.failureThreshold) {
      circuit.isOpen = true;
      log('warn', `Circuit opened for ${name} (${circuit.failures} failures)`);
    }
  }

  /**
   * Record a success - reset circuit breaker
   */
  private recordSuccess(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures = 0;
    circuit.isOpen = false;
  }

  /**
   * Health check - ping all providers
   * @param message Optional message to send (default: "ping")
   */
  async healthCheck(message = 'ping'): Promise<HealthCheckResult[]> {
    const results: HealthCheckResult[] = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) {
        results.push({ name: config.name, healthy: false, error: 'Not configured' });
        continue;
      }

      const start = Date.now();
      try {
        await client.chat(message);
        results.push({
          name: config.name,
          healthy: true,
          latencyMs: Date.now() - start
        });
      } catch (error) {
        results.push({
          name: config.name,
          healthy: false,
          latencyMs: Date.now() - start,
          error: formatError(error)
        });
      }
    }

    return results;
  }

  /**
   * Send a chat message with automatic fallback
   * Falls back on ANY error for maximum availability
   */
  async chat(message: string): Promise<ChatResult> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name}...`);

      try {
        const response = await client.chat(message);
        this.recordSuccess(config.name);
        log('success', `${config.name} succeeded`);
        return {
          content: response.content,
          provider: config.name
        };
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }

  // [start:streaming]
  /**
   * Stream a chat message with automatic fallback
   *
   * Note: If streaming fails mid-stream, partial response is lost.
   * Fallback starts fresh from the beginning with next provider.
   */
  async *stream(message: string): AsyncGenerator<StreamChunk> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name} (streaming)...`);

      try {
        let hasYielded = false;
        for await (const chunk of client.stream(message)) {
          if (!hasYielded) {
            log('success', `${config.name} started streaming`);
            hasYielded = true;
          }
          yield {
            content: chunk.content,
            provider: config.name,
            done: false
          };
        }
        this.recordSuccess(config.name);
        yield {
          content: '',
          provider: config.name,
          done: true
        };
        return; // Success, exit generator
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }
  // [end:streaming]

  /**
   * Get list of configured providers
   */
  getProviders(): string[] {
    return Array.from(this.clients.keys());
  }

  /**
   * Get circuit breaker status for all providers
   */
  getCircuitStatus(): CircuitStatus[] {
    return this.providers
      .filter((p) => this.circuits.has(p.name))
      .map((p) => {
        const circuit = this.circuits.get(p.name)!;
        return {
          name: p.name,
          isOpen: circuit.isOpen,
          failures: circuit.failures
        };
      });
  }
}
// [end:fallback-client]

// [start:create-providers]
export function createDefaultProviders(): ProviderConfig[] {
  const providers: ProviderConfig[] = [];

  if (process.env.OPENAI_API_KEY) {
    providers.push({
      name: 'OpenAI',
      provider: 'openai',
      model: 'gpt-4o-mini',
      apiKey: process.env.OPENAI_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  if (process.env.ANTHROPIC_API_KEY) {
    providers.push({
      name: 'Anthropic',
      provider: 'anthropic',
      model: 'claude-3-haiku-20240307',
      apiKey: process.env.ANTHROPIC_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  // Always add Ollama as local fallback (may not be running)
  providers.push({
    name: 'Ollama',
    provider: 'ollama',
    model: 'llama3.2',
    baseUrl: 'http://localhost:11434',
    retryConfig: {
      maxAttempts: 1,
      initialBackoffMs: 500,
      maxBackoffMs: 2000,
      backoffMultiplier: 2
    }
  });

  return providers;
}
// [end:create-providers]

Streaming with Fallback

typescript
/**
 * Multi-Provider Fallback Library
 *
 * Exported functions for the Multi-Provider Fallback recipe.
 * Snippet markers allow VitePress to extract code for documentation.
 */

import { ChatClient, ChatError } from '../../../src';

// [start:colors]
export const colors = {
  reset: '\x1b[0m',
  dim: '\x1b[2m',
  bold: '\x1b[1m',
  red: '\x1b[31m',
  yellow: '\x1b[33m',
  green: '\x1b[32m',
  cyan: '\x1b[36m',
  magenta: '\x1b[35m'
};
// [end:colors]

// [start:types]
export interface CircuitBreakerConfig {
  failureThreshold: number; // Open circuit after N failures
  resetTimeoutMs: number; // Try again after N ms
}

export interface ProviderConfig {
  name: string;
  provider: 'openai' | 'anthropic' | 'ollama';
  model: string;
  apiKey?: string;
  baseUrl?: string;
  retryConfig: {
    maxAttempts: number;
    initialBackoffMs: number;
    maxBackoffMs: number;
    backoffMultiplier: number;
  };
  circuitBreaker?: CircuitBreakerConfig;
}

export interface CircuitState {
  failures: number;
  lastFailure: number;
  isOpen: boolean;
  config: CircuitBreakerConfig;
}

export interface HealthCheckResult {
  name: string;
  healthy: boolean;
  latencyMs?: number;
  error?: string;
}

export interface ChatResult {
  content: string;
  provider: string;
}

export interface StreamChunk {
  content: string;
  provider: string;
  done: boolean;
}

export interface CircuitStatus {
  name: string;
  isOpen: boolean;
  failures: number;
}
// [end:types]

// [start:default-config]
export const DEFAULT_CIRCUIT_BREAKER: CircuitBreakerConfig = {
  failureThreshold: 3,
  resetTimeoutMs: 30000
};
// [end:default-config]

// [start:logging]
export function formatError(error: unknown): string {
  if (error instanceof ChatError) {
    return `${error.name}: ${error.message}`;
  }
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

export function printBanner(): void {
  console.log(`
${colors.cyan}╭─────────────────────────────────────────────╮
│  ${colors.reset}${colors.bold}Multi-Provider Fallback${colors.reset}${colors.cyan}                    │
│  ${colors.dim}High Availability LLM Client${colors.reset}${colors.cyan}                │
╰─────────────────────────────────────────────╯${colors.reset}
`);
}

export function log(level: 'info' | 'warn' | 'error' | 'success', message: string): void {
  const prefix = {
    info: `${colors.cyan}ℹ${colors.reset}`,
    warn: `${colors.yellow}⚠${colors.reset}`,
    error: `${colors.red}✗${colors.reset}`,
    success: `${colors.green}✓${colors.reset}`
  };
  console.log(`${prefix[level]} ${message}`);
}
// [end:logging]

// [start:fallback-client]
/**
 * Multi-provider client with automatic fallback and circuit breaker
 */
export class FallbackClient {
  private providers: ProviderConfig[];
  private clients: Map<string, ChatClient> = new Map();
  private circuits: Map<string, CircuitState> = new Map();

  constructor(providers: ProviderConfig[]) {
    this.providers = providers;

    // Pre-create clients for each provider
    for (const config of providers) {
      try {
        const client = new ChatClient({
          provider: config.provider,
          model: config.model,
          apiKey: config.apiKey,
          baseUrl: config.baseUrl,
          retryConfig: config.retryConfig
        });
        this.clients.set(config.name, client);
        this.circuits.set(config.name, {
          failures: 0,
          lastFailure: 0,
          isOpen: false,
          config: config.circuitBreaker ?? DEFAULT_CIRCUIT_BREAKER
        });
        log('info', `Configured ${config.name} (${config.provider}/${config.model})`);
      } catch (error) {
        log('warn', `Failed to configure ${config.name}: ${formatError(error)}`);
      }
    }

    if (this.clients.size === 0) {
      throw new Error('No providers could be configured');
    }
  }

  /**
   * Check if circuit breaker allows requests to this provider
   */
  private isCircuitClosed(name: string): boolean {
    const circuit = this.circuits.get(name);
    if (!circuit) return false;

    if (!circuit.isOpen) return true;

    // Check if enough time has passed to try again
    const elapsed = Date.now() - circuit.lastFailure;
    if (elapsed >= circuit.config.resetTimeoutMs) {
      circuit.isOpen = false;
      circuit.failures = 0;
      log('info', `Circuit closed for ${name} (reset after ${circuit.config.resetTimeoutMs}ms)`);
      return true;
    }

    return false;
  }

  /**
   * Record a failure for circuit breaker
   */
  private recordFailure(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures++;
    circuit.lastFailure = Date.now();

    if (circuit.failures >= circuit.config.failureThreshold) {
      circuit.isOpen = true;
      log('warn', `Circuit opened for ${name} (${circuit.failures} failures)`);
    }
  }

  /**
   * Record a success - reset circuit breaker
   */
  private recordSuccess(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures = 0;
    circuit.isOpen = false;
  }

  /**
   * Health check - ping all providers
   * @param message Optional message to send (default: "ping")
   */
  async healthCheck(message = 'ping'): Promise<HealthCheckResult[]> {
    const results: HealthCheckResult[] = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) {
        results.push({ name: config.name, healthy: false, error: 'Not configured' });
        continue;
      }

      const start = Date.now();
      try {
        await client.chat(message);
        results.push({
          name: config.name,
          healthy: true,
          latencyMs: Date.now() - start
        });
      } catch (error) {
        results.push({
          name: config.name,
          healthy: false,
          latencyMs: Date.now() - start,
          error: formatError(error)
        });
      }
    }

    return results;
  }

  /**
   * Send a chat message with automatic fallback
   * Falls back on ANY error for maximum availability
   */
  async chat(message: string): Promise<ChatResult> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name}...`);

      try {
        const response = await client.chat(message);
        this.recordSuccess(config.name);
        log('success', `${config.name} succeeded`);
        return {
          content: response.content,
          provider: config.name
        };
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }

  // [start:streaming]
  /**
   * Stream a chat message with automatic fallback
   *
   * Note: If streaming fails mid-stream, partial response is lost.
   * Fallback starts fresh from the beginning with next provider.
   */
  async *stream(message: string): AsyncGenerator<StreamChunk> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name} (streaming)...`);

      try {
        let hasYielded = false;
        for await (const chunk of client.stream(message)) {
          if (!hasYielded) {
            log('success', `${config.name} started streaming`);
            hasYielded = true;
          }
          yield {
            content: chunk.content,
            provider: config.name,
            done: false
          };
        }
        this.recordSuccess(config.name);
        yield {
          content: '',
          provider: config.name,
          done: true
        };
        return; // Success, exit generator
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }
  // [end:streaming]

  /**
   * Get list of configured providers
   */
  getProviders(): string[] {
    return Array.from(this.clients.keys());
  }

  /**
   * Get circuit breaker status for all providers
   */
  getCircuitStatus(): CircuitStatus[] {
    return this.providers
      .filter((p) => this.circuits.has(p.name))
      .map((p) => {
        const circuit = this.circuits.get(p.name)!;
        return {
          name: p.name,
          isOpen: circuit.isOpen,
          failures: circuit.failures
        };
      });
  }
}
// [end:fallback-client]

// [start:create-providers]
export function createDefaultProviders(): ProviderConfig[] {
  const providers: ProviderConfig[] = [];

  if (process.env.OPENAI_API_KEY) {
    providers.push({
      name: 'OpenAI',
      provider: 'openai',
      model: 'gpt-4o-mini',
      apiKey: process.env.OPENAI_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  if (process.env.ANTHROPIC_API_KEY) {
    providers.push({
      name: 'Anthropic',
      provider: 'anthropic',
      model: 'claude-3-haiku-20240307',
      apiKey: process.env.ANTHROPIC_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  // Always add Ollama as local fallback (may not be running)
  providers.push({
    name: 'Ollama',
    provider: 'ollama',
    model: 'llama3.2',
    baseUrl: 'http://localhost:11434',
    retryConfig: {
      maxAttempts: 1,
      initialBackoffMs: 500,
      maxBackoffMs: 2000,
      backoffMultiplier: 2
    }
  });

  return providers;
}
// [end:create-providers]

Create Default Providers

typescript
/**
 * Multi-Provider Fallback Library
 *
 * Exported functions for the Multi-Provider Fallback recipe.
 * Snippet markers allow VitePress to extract code for documentation.
 */

import { ChatClient, ChatError } from '../../../src';

// [start:colors]
export const colors = {
  reset: '\x1b[0m',
  dim: '\x1b[2m',
  bold: '\x1b[1m',
  red: '\x1b[31m',
  yellow: '\x1b[33m',
  green: '\x1b[32m',
  cyan: '\x1b[36m',
  magenta: '\x1b[35m'
};
// [end:colors]

// [start:types]
export interface CircuitBreakerConfig {
  failureThreshold: number; // Open circuit after N failures
  resetTimeoutMs: number; // Try again after N ms
}

export interface ProviderConfig {
  name: string;
  provider: 'openai' | 'anthropic' | 'ollama';
  model: string;
  apiKey?: string;
  baseUrl?: string;
  retryConfig: {
    maxAttempts: number;
    initialBackoffMs: number;
    maxBackoffMs: number;
    backoffMultiplier: number;
  };
  circuitBreaker?: CircuitBreakerConfig;
}

export interface CircuitState {
  failures: number;
  lastFailure: number;
  isOpen: boolean;
  config: CircuitBreakerConfig;
}

export interface HealthCheckResult {
  name: string;
  healthy: boolean;
  latencyMs?: number;
  error?: string;
}

export interface ChatResult {
  content: string;
  provider: string;
}

export interface StreamChunk {
  content: string;
  provider: string;
  done: boolean;
}

export interface CircuitStatus {
  name: string;
  isOpen: boolean;
  failures: number;
}
// [end:types]

// [start:default-config]
export const DEFAULT_CIRCUIT_BREAKER: CircuitBreakerConfig = {
  failureThreshold: 3,
  resetTimeoutMs: 30000
};
// [end:default-config]

// [start:logging]
export function formatError(error: unknown): string {
  if (error instanceof ChatError) {
    return `${error.name}: ${error.message}`;
  }
  if (error instanceof Error) {
    return error.message;
  }
  return String(error);
}

export function printBanner(): void {
  console.log(`
${colors.cyan}╭─────────────────────────────────────────────╮
│  ${colors.reset}${colors.bold}Multi-Provider Fallback${colors.reset}${colors.cyan}                    │
│  ${colors.dim}High Availability LLM Client${colors.reset}${colors.cyan}                │
╰─────────────────────────────────────────────╯${colors.reset}
`);
}

export function log(level: 'info' | 'warn' | 'error' | 'success', message: string): void {
  const prefix = {
    info: `${colors.cyan}ℹ${colors.reset}`,
    warn: `${colors.yellow}⚠${colors.reset}`,
    error: `${colors.red}✗${colors.reset}`,
    success: `${colors.green}✓${colors.reset}`
  };
  console.log(`${prefix[level]} ${message}`);
}
// [end:logging]

// [start:fallback-client]
/**
 * Multi-provider client with automatic fallback and circuit breaker
 */
export class FallbackClient {
  private providers: ProviderConfig[];
  private clients: Map<string, ChatClient> = new Map();
  private circuits: Map<string, CircuitState> = new Map();

  constructor(providers: ProviderConfig[]) {
    this.providers = providers;

    // Pre-create clients for each provider
    for (const config of providers) {
      try {
        const client = new ChatClient({
          provider: config.provider,
          model: config.model,
          apiKey: config.apiKey,
          baseUrl: config.baseUrl,
          retryConfig: config.retryConfig
        });
        this.clients.set(config.name, client);
        this.circuits.set(config.name, {
          failures: 0,
          lastFailure: 0,
          isOpen: false,
          config: config.circuitBreaker ?? DEFAULT_CIRCUIT_BREAKER
        });
        log('info', `Configured ${config.name} (${config.provider}/${config.model})`);
      } catch (error) {
        log('warn', `Failed to configure ${config.name}: ${formatError(error)}`);
      }
    }

    if (this.clients.size === 0) {
      throw new Error('No providers could be configured');
    }
  }

  /**
   * Check if circuit breaker allows requests to this provider
   */
  private isCircuitClosed(name: string): boolean {
    const circuit = this.circuits.get(name);
    if (!circuit) return false;

    if (!circuit.isOpen) return true;

    // Check if enough time has passed to try again
    const elapsed = Date.now() - circuit.lastFailure;
    if (elapsed >= circuit.config.resetTimeoutMs) {
      circuit.isOpen = false;
      circuit.failures = 0;
      log('info', `Circuit closed for ${name} (reset after ${circuit.config.resetTimeoutMs}ms)`);
      return true;
    }

    return false;
  }

  /**
   * Record a failure for circuit breaker
   */
  private recordFailure(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures++;
    circuit.lastFailure = Date.now();

    if (circuit.failures >= circuit.config.failureThreshold) {
      circuit.isOpen = true;
      log('warn', `Circuit opened for ${name} (${circuit.failures} failures)`);
    }
  }

  /**
   * Record a success - reset circuit breaker
   */
  private recordSuccess(name: string): void {
    const circuit = this.circuits.get(name);
    if (!circuit) return;

    circuit.failures = 0;
    circuit.isOpen = false;
  }

  /**
   * Health check - ping all providers
   * @param message Optional message to send (default: "ping")
   */
  async healthCheck(message = 'ping'): Promise<HealthCheckResult[]> {
    const results: HealthCheckResult[] = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) {
        results.push({ name: config.name, healthy: false, error: 'Not configured' });
        continue;
      }

      const start = Date.now();
      try {
        await client.chat(message);
        results.push({
          name: config.name,
          healthy: true,
          latencyMs: Date.now() - start
        });
      } catch (error) {
        results.push({
          name: config.name,
          healthy: false,
          latencyMs: Date.now() - start,
          error: formatError(error)
        });
      }
    }

    return results;
  }

  /**
   * Send a chat message with automatic fallback
   * Falls back on ANY error for maximum availability
   */
  async chat(message: string): Promise<ChatResult> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name}...`);

      try {
        const response = await client.chat(message);
        this.recordSuccess(config.name);
        log('success', `${config.name} succeeded`);
        return {
          content: response.content,
          provider: config.name
        };
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }

  // [start:streaming]
  /**
   * Stream a chat message with automatic fallback
   *
   * Note: If streaming fails mid-stream, partial response is lost.
   * Fallback starts fresh from the beginning with next provider.
   */
  async *stream(message: string): AsyncGenerator<StreamChunk> {
    const errors: Array<{ provider: string; error: unknown }> = [];

    for (const config of this.providers) {
      const client = this.clients.get(config.name);
      if (!client) continue;

      // Check circuit breaker
      if (!this.isCircuitClosed(config.name)) {
        log('warn', `${config.name} circuit open, skipping`);
        continue;
      }

      log('info', `Trying ${config.name} (streaming)...`);

      try {
        let hasYielded = false;
        for await (const chunk of client.stream(message)) {
          if (!hasYielded) {
            log('success', `${config.name} started streaming`);
            hasYielded = true;
          }
          yield {
            content: chunk.content,
            provider: config.name,
            done: false
          };
        }
        this.recordSuccess(config.name);
        yield {
          content: '',
          provider: config.name,
          done: true
        };
        return; // Success, exit generator
      } catch (error) {
        this.recordFailure(config.name);
        errors.push({ provider: config.name, error });
        log('error', `${config.name} failed: ${formatError(error)}`);
        log('warn', `Falling back to next provider...`);
      }
    }

    // All providers failed
    const errorSummary = errors.map((e) => `  - ${e.provider}: ${formatError(e.error)}`).join('\n');
    throw new Error(`All providers failed:\n${errorSummary}`);
  }
  // [end:streaming]

  /**
   * Get list of configured providers
   */
  getProviders(): string[] {
    return Array.from(this.clients.keys());
  }

  /**
   * Get circuit breaker status for all providers
   */
  getCircuitStatus(): CircuitStatus[] {
    return this.providers
      .filter((p) => this.circuits.has(p.name))
      .map((p) => {
        const circuit = this.circuits.get(p.name)!;
        return {
          name: p.name,
          isOpen: circuit.isOpen,
          failures: circuit.failures
        };
      });
  }
}
// [end:fallback-client]

// [start:create-providers]
export function createDefaultProviders(): ProviderConfig[] {
  const providers: ProviderConfig[] = [];

  if (process.env.OPENAI_API_KEY) {
    providers.push({
      name: 'OpenAI',
      provider: 'openai',
      model: 'gpt-4o-mini',
      apiKey: process.env.OPENAI_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  if (process.env.ANTHROPIC_API_KEY) {
    providers.push({
      name: 'Anthropic',
      provider: 'anthropic',
      model: 'claude-3-haiku-20240307',
      apiKey: process.env.ANTHROPIC_API_KEY,
      retryConfig: {
        maxAttempts: 2,
        initialBackoffMs: 1000,
        maxBackoffMs: 5000,
        backoffMultiplier: 2
      }
    });
  }

  // Always add Ollama as local fallback (may not be running)
  providers.push({
    name: 'Ollama',
    provider: 'ollama',
    model: 'llama3.2',
    baseUrl: 'http://localhost:11434',
    retryConfig: {
      maxAttempts: 1,
      initialBackoffMs: 500,
      maxBackoffMs: 2000,
      backoffMultiplier: 2
    }
  });

  return providers;
}
// [end:create-providers]

Configuration Options

Per-Provider Retry

Each provider can have its own retry settings:

typescript
retryConfig: {
  maxAttempts: 2,        // Retry twice before fallback
  initialBackoffMs: 1000, // Start with 1s delay
  maxBackoffMs: 5000      // Max 5s between retries
}

Provider Priority

Providers are tried in array order. Put your preferred provider first:

typescript
const providers = [
  { name: 'Primary', provider: 'openai', ... },
  { name: 'Secondary', provider: 'anthropic', ... },
  { name: 'Fallback', provider: 'ollama', ... }
];

Local Fallback with Ollama

Ollama provides a local fallback that works without internet:

bash
# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh

# Pull a model
ollama pull llama3.2

# Ollama runs on http://localhost:11434

Circuit Breaker

The circuit breaker prevents hammering unhealthy providers. Each provider can have its own settings:

typescript
// Per-provider configuration
const providers = [
  {
    name: 'OpenAI',
    provider: 'openai',
    model: 'gpt-4o-mini',
    circuitBreaker: {
      failureThreshold: 3,    // Open after 3 failures
      resetTimeoutMs: 30000   // Try again after 30 seconds
    }
  },
  {
    name: 'Ollama',
    provider: 'ollama',
    model: 'llama3.2',
    circuitBreaker: {
      failureThreshold: 1,    // Open immediately on failure
      resetTimeoutMs: 5000    // Try again after 5 seconds
    }
  }
];

// Check circuit status
const status = client.getCircuitStatus();
// [{ name: 'OpenAI', isOpen: false, failures: 0 }, ...]

How it works:

  1. Each provider has an independent circuit with its own config
  2. After N failures, circuit opens (skips provider)
  3. After timeout, circuit closes (tries again)
  4. Success resets failure count

Health Check

Verify provider availability before critical operations:

typescript
// Default ping
const health = await client.healthCheck();

// Custom health check message
const health = await client.healthCheck('Hello, are you there?');

for (const result of health) {
  if (result.healthy) {
    console.log(`${result.name}: ✓ ${result.latencyMs}ms`);
  } else {
    console.log(`${result.name}: ✗ ${result.error}`);
  }
}

Use health checks for:

  • Pre-flight checks before batch operations
  • Monitoring dashboards
  • Choosing optimal provider based on latency

Environment Variables

VariableRequiredDescription
OPENAI_API_KEYOne of theseOpenAI API key
ANTHROPIC_API_KEYrequiredAnthropic API key

At least one cloud API key is required. Ollama works locally without an API key.

Use Cases

High Availability API

typescript
// Production service with guaranteed uptime
const client = new FallbackClient([
  { provider: 'openai', ... },    // Fast, primary
  { provider: 'anthropic', ... }, // Reliable backup
  { provider: 'ollama', ... }     // Local emergency fallback
]);

Cost Optimization

typescript
// Try cheaper models first
const client = new FallbackClient([
  { provider: 'ollama', model: 'llama3.2', ... },  // Free, local
  { provider: 'openai', model: 'gpt-4o-mini', ... }, // Cheap cloud
  { provider: 'anthropic', model: 'claude-3-opus', ... } // Premium fallback
]);

Development/Production Split

typescript
const providers = process.env.NODE_ENV === 'production'
  ? [openaiConfig, anthropicConfig, ollamaConfig]
  : [ollamaConfig]; // Local only in dev

Full Source

View on GitHub

Released under the MIT License.