Multi-Provider Load Balancer for AI APIs — Complete Code (2026) | AI Error Hub
AI Error Hub/Blog/Multi-Provider Load Balancer
Tutorial Deep-dive

Multi-Provider Load Balancer for AI APIs — Complete Code

ZK
Zain Khan · Editor · Published 2026-09-11 · 12-minute read

Last Thursday at 2:47 PM UTC, Anthropic had a 40-minute partial outage. Every app I know that ran on Claude alone was down for the duration. Every app running a multi-provider load balancer failed over to GPT-5.6 within 3 seconds and served requests normally. The difference isn't the model — it's the 200 lines of TypeScript that pick which model to use when. This post is that code.

What we're building

A drop-in load balancer that sits between your app and multiple LLM providers. Given a request, it picks a provider based on health, cost, or capacity; sends the request; and falls over to the next provider if the first fails. Providers are pluggable: Anthropic, OpenAI, Gemini, or any custom endpoint.

What the finished load balancer gives you
  • Automatic failover across 2-5 providers with configurable priorities
  • Health tracking — providers with high error rates get deprioritized
  • Three routing strategies: priority-first, weighted, cost-aware
  • Per-provider circuit breaker to short-circuit sustained failures
  • Metrics hooks for observability
  • Zero dependencies beyond provider SDKs

What we're NOT building: a general-purpose service mesh, a distributed load balancer with cluster coordination, or a hosted routing service like OpenRouter or Portkey. This is a single-process helper, meant to be embedded in your app or backend service. It's the middle ground between "one provider hardcoded" and "operate infrastructure to route requests."

The provider adapter interface

Every provider has a different SDK, different request shape, different error types. The load balancer needs a uniform interface. This adapter is the seam.

// providers/base.ts
export interface Message {
  role: "user" | "assistant" | "system";
  content: string;
}

export interface CompletionRequest {
  model?: string;  // provider-specific, optional
  messages: Message[];
  maxTokens: number;
  temperature?: number;
}

export interface CompletionResponse {
  text: string;
  provider: string;
  model: string;
  inputTokens: number;
  outputTokens: number;
  latencyMs: number;
}

export interface ProviderAdapter {
  name: string;
  call(request: CompletionRequest): Promise<CompletionResponse>;
  // Cost per million tokens (input, output)
  pricing: { input: number; output: number };
}


export class ProviderError extends Error {
  constructor(
    public provider: string,
    public statusCode: number | null,
    public retryable: boolean,
    message: string,
  ) {
    super(message);
    this.name = "ProviderError";
  }
}

The Anthropic adapter

// providers/anthropic.ts
import Anthropic from "@anthropic-ai/sdk";
import { ProviderAdapter, CompletionRequest, CompletionResponse, ProviderError } from "./base";

export class AnthropicAdapter implements ProviderAdapter {
  name = "anthropic";
  pricing = { input: 3, output: 15 };  // Sonnet 4.6 per M tokens
  private client: Anthropic;
  private model: string;
  
  constructor(model = "claude-sonnet-4-6") {
    this.client = new Anthropic({ maxRetries: 0 });  // control at balancer
    this.model = model;
  }
  
  async call(req: CompletionRequest): Promise<CompletionResponse> {
    const start = Date.now();
    try {
      const response = await this.client.messages.create({
        model: req.model ?? this.model,
        max_tokens: req.maxTokens,
        temperature: req.temperature,
        messages: req.messages
          .filter((m) => m.role !== "system")
          .map((m) => ({ role: m.role as "user" | "assistant", content: m.content })),
        system: req.messages.find((m) => m.role === "system")?.content,
      });
      
      const textBlock = response.content.find((b) => b.type === "text");
      const text = textBlock ? (textBlock as any).text : "";
      
      return {
        text, provider: this.name, model: this.model,
        inputTokens: response.usage.input_tokens,
        outputTokens: response.usage.output_tokens,
        latencyMs: Date.now() - start,
      };
    } catch (e: any) {
      throw new ProviderError(
        this.name,
        e.status ?? null,
        isRetryableStatus(e.status),
        e.message ?? "Anthropic call failed",
      );
    }
  }
}


function isRetryableStatus(status: number | undefined): boolean {
  if (!status) return true;  // network error, retry
  return status === 429 || status === 529 || (status >= 500 && status < 600);
}

The OpenAI adapter

// providers/openai.ts
import OpenAI from "openai";
import { ProviderAdapter, CompletionRequest, CompletionResponse, ProviderError } from "./base";

export class OpenAIAdapter implements ProviderAdapter {
  name = "openai";
  pricing = { input: 5, output: 20 };  // GPT-5.6 approx
  private client: OpenAI;
  private model: string;
  
  constructor(model = "gpt-5.6") {
    this.client = new OpenAI({ maxRetries: 0 });
    this.model = model;
  }
  
  async call(req: CompletionRequest): Promise<CompletionResponse> {
    const start = Date.now();
    try {
      const response = await this.client.chat.completions.create({
        model: req.model ?? this.model,
        max_tokens: req.maxTokens,
        temperature: req.temperature,
        messages: req.messages.map((m) => ({ role: m.role, content: m.content })),
      });
      
      return {
        text: response.choices[0].message.content ?? "",
        provider: this.name, model: this.model,
        inputTokens: response.usage?.prompt_tokens ?? 0,
        outputTokens: response.usage?.completion_tokens ?? 0,
        latencyMs: Date.now() - start,
      };
    } catch (e: any) {
      throw new ProviderError(
        this.name,
        e.status ?? null,
        isRetryableStatus(e.status),
        e.message ?? "OpenAI call failed",
      );
    }
  }
}


function isRetryableStatus(status: number | undefined): boolean {
  if (!status) return true;
  return status === 429 || (status >= 500 && status < 600);
}

The Gemini adapter

// providers/gemini.ts
import { GoogleGenAI } from "@google/genai";
import { ProviderAdapter, CompletionRequest, CompletionResponse, ProviderError } from "./base";

export class GeminiAdapter implements ProviderAdapter {
  name = "gemini";
  pricing = { input: 3.5, output: 10.5 };  // Gemini 3.5 Pro
  private client: GoogleGenAI;
  private model: string;
  
  constructor(model = "gemini-3.5-pro") {
    this.client = new GoogleGenAI({ apiKey: process.env.GOOGLE_API_KEY! });
    this.model = model;
  }
  
  async call(req: CompletionRequest): Promise<CompletionResponse> {
    const start = Date.now();
    try {
      const contents = req.messages
        .filter((m) => m.role !== "system")
        .map((m) => ({
          role: m.role === "assistant" ? "model" : "user",
          parts: [{ text: m.content }],
        }));
      
      const systemInstruction = req.messages.find((m) => m.role === "system")?.content;
      
      const response = await this.client.models.generateContent({
        model: this.model,
        contents,
        config: {
          maxOutputTokens: req.maxTokens,
          temperature: req.temperature,
          systemInstruction,
        },
      });
      
      return {
        text: response.text ?? "",
        provider: this.name, model: this.model,
        inputTokens: response.usageMetadata?.promptTokenCount ?? 0,
        outputTokens: response.usageMetadata?.candidatesTokenCount ?? 0,
        latencyMs: Date.now() - start,
      };
    } catch (e: any) {
      const status = e.response?.status ?? null;
      throw new ProviderError(
        this.name,
        status,
        !status || status === 429 || (status >= 500 && status < 600),
        e.message ?? "Gemini call failed",
      );
    }
  }
}

Health tracking

The load balancer needs to know which providers are healthy. A simple sliding-window error rate tracker suffices for most cases.

// health.ts
export class HealthTracker {
  private windows: Map<string, { successes: number; failures: number; lastReset: number }> = new Map();
  private readonly windowMs: number;
  private readonly unhealthyThreshold: number;
  
  constructor(windowMs = 60_000, unhealthyThreshold = 0.5) {
    this.windowMs = windowMs;
    this.unhealthyThreshold = unhealthyThreshold;
  }
  
  private getWindow(provider: string) {
    let w = this.windows.get(provider);
    const now = Date.now();
    if (!w || now - w.lastReset > this.windowMs) {
      w = { successes: 0, failures: 0, lastReset: now };
      this.windows.set(provider, w);
    }
    return w;
  }
  
  recordSuccess(provider: string) {
    this.getWindow(provider).successes++;
  }
  
  recordFailure(provider: string) {
    this.getWindow(provider).failures++;
  }
  
  isHealthy(provider: string): boolean {
    const w = this.getWindow(provider);
    const total = w.successes + w.failures;
    if (total < 10) return true;  // not enough data
    return w.failures / total < this.unhealthyThreshold;
  }
  
  errorRate(provider: string): number {
    const w = this.getWindow(provider);
    const total = w.successes + w.failures;
    return total > 0 ? w.failures / total : 0;
  }
}

The load balancer

Now the core class. Takes a list of provider adapters, picks one per request, handles failures, tracks health, exposes metrics hooks.

// load-balancer.ts
import { ProviderAdapter, CompletionRequest, CompletionResponse, ProviderError } from "./providers/base";
import { HealthTracker } from "./health";

export type Strategy = "priority" | "weighted" | "cost";

export interface LoadBalancerOptions {
  providers: ProviderAdapter[];
  strategy?: Strategy;
  weights?: Record<string, number>;  // for "weighted"
  healthTracker?: HealthTracker;
  onProviderCall?: (provider: string) => void;
  onProviderSuccess?: (provider: string, latencyMs: number) => void;
  onProviderFailure?: (provider: string, error: ProviderError) => void;
  onFailover?: (from: string, to: string) => void;
  onAllFailed?: () => void;
}


export class LoadBalancer {
  private providers: ProviderAdapter[];
  private strategy: Strategy;
  private weights: Record<string, number>;
  private health: HealthTracker;
  private hooks: Required<Pick<LoadBalancerOptions,
    "onProviderCall" | "onProviderSuccess" | "onProviderFailure" | "onFailover" | "onAllFailed"
  >>;
  
  constructor(opts: LoadBalancerOptions) {
    if (opts.providers.length === 0) {
      throw new Error("LoadBalancer requires at least one provider");
    }
    this.providers = opts.providers;
    this.strategy = opts.strategy ?? "priority";
    this.weights = opts.weights ?? {};
    this.health = opts.healthTracker ?? new HealthTracker();
    this.hooks = {
      onProviderCall: opts.onProviderCall ?? (() => {}),
      onProviderSuccess: opts.onProviderSuccess ?? (() => {}),
      onProviderFailure: opts.onProviderFailure ?? (() => {}),
      onFailover: opts.onFailover ?? (() => {}),
      onAllFailed: opts.onAllFailed ?? (() => {}),
    };
  }
  
  async call(request: CompletionRequest): Promise<CompletionResponse> {
    const ordered = this.orderProviders(request);
    const attempted: string[] = [];
    let lastError: ProviderError | null = null;
    
    for (const provider of ordered) {
      attempted.push(provider.name);
      this.hooks.onProviderCall(provider.name);
      
      try {
        const response = await provider.call(request);
        this.health.recordSuccess(provider.name);
        this.hooks.onProviderSuccess(provider.name, response.latencyMs);
        return response;
      } catch (e) {
        const err = e instanceof ProviderError 
          ? e 
          : new ProviderError(provider.name, null, true, String(e));
        this.health.recordFailure(provider.name);
        this.hooks.onProviderFailure(provider.name, err);
        lastError = err;
        
        // Non-retryable: don't fail over
        if (!err.retryable) throw err;
        
        // Log the failover
        const nextIdx = ordered.indexOf(provider) + 1;
        if (nextIdx < ordered.length) {
          this.hooks.onFailover(provider.name, ordered[nextIdx].name);
        }
      }
    }
    
    this.hooks.onAllFailed();
    throw new Error(
      `All ${attempted.length} providers failed. Last error: ${lastError?.message}`
    );
  }
  
  private orderProviders(request: CompletionRequest): ProviderAdapter[] {
    // Filter to healthy providers first
    const healthy = this.providers.filter((p) => this.health.isHealthy(p.name));
    const unhealthy = this.providers.filter((p) => !this.health.isHealthy(p.name));
    // Unhealthy ones go last, in case all healthy fail
    const pool = [...healthy, ...unhealthy];
    
    switch (this.strategy) {
      case "priority":
        return pool;  // as given
      case "weighted":
        return this.weightedShuffle(pool);
      case "cost":
        return this.costOrdered(pool, request);
      default:
        return pool;
    }
  }
  
  private weightedShuffle(pool: ProviderAdapter[]): ProviderAdapter[] {
    // Weighted random by configured weight (default 1)
    const weighted = pool.map((p) => ({
      provider: p,
      weight: this.weights[p.name] ?? 1,
    }));
    const result: ProviderAdapter[] = [];
    const remaining = [...weighted];
    while (remaining.length > 0) {
      const total = remaining.reduce((s, x) => s + x.weight, 0);
      let r = Math.random() * total;
      for (let i = 0; i < remaining.length; i++) {
        r -= remaining[i].weight;
        if (r <= 0) {
          result.push(remaining[i].provider);
          remaining.splice(i, 1);
          break;
        }
      }
    }
    return result;
  }
  
  private costOrdered(pool: ProviderAdapter[], request: CompletionRequest): ProviderAdapter[] {
    // Estimate cost per request; cheapest first
    const inputTokens = request.messages.reduce(
      (s, m) => s + Math.ceil(m.content.length / 4), 0
    );
    const outputTokens = request.maxTokens / 2;  // rough estimate
    
    return [...pool].sort((a, b) => {
      const costA = (inputTokens * a.pricing.input + outputTokens * a.pricing.output) / 1_000_000;
      const costB = (inputTokens * b.pricing.input + outputTokens * b.pricing.output) / 1_000_000;
      return costA - costB;
    });
  }
}

Using it

// app.ts
import { LoadBalancer } from "./load-balancer";
import { AnthropicAdapter } from "./providers/anthropic";
import { OpenAIAdapter } from "./providers/openai";
import { GeminiAdapter } from "./providers/gemini";

const lb = new LoadBalancer({
  providers: [
    new AnthropicAdapter("claude-sonnet-4-6"),
    new OpenAIAdapter("gpt-5.6"),
    new GeminiAdapter("gemini-3.5-pro"),
  ],
  strategy: "priority",  // try Anthropic first, then failover
  onProviderCall: (name) => console.log(`Calling ${name}`),
  onProviderFailure: (name, err) => 
    console.warn(`${name} failed: ${err.message}`),
  onFailover: (from, to) => 
    console.log(`Failing over from ${from} to ${to}`),
});


// Use just like a single-provider call
const response = await lb.call({
  messages: [
    { role: "system", content: "You are a helpful assistant." },
    { role: "user", content: "What's the capital of France?" },
  ],
  maxTokens: 100,
});

console.log(response.text);
console.log(`Served by ${response.provider} (${response.model})`);
console.log(`Latency: ${response.latencyMs}ms`);
console.log(`Tokens: ${response.inputTokens} in, ${response.outputTokens} out`);

Strategy comparison

Three strategies with different tradeoffs:

Priority (default)

Always try providers in configured order. Fail over only when the primary fails. Use when: one provider is clearly your preferred choice and others are backups. Failover happens transparently on outages.

Weighted

Randomly pick per-request based on weights. Higher weight = more often. Use when: you want to distribute load across providers proactively (spreading rate limits) or when you're A/B testing quality.

// 60% Anthropic, 30% OpenAI, 10% Gemini
const lb = new LoadBalancer({
  providers: [...],
  strategy: "weighted",
  weights: { anthropic: 60, openai: 30, gemini: 10 },
});

Cost

Always try the cheapest provider first, failover to more expensive ones. Use when: cost matters more than quality and your workload works on any of the providers. Not appropriate when quality varies significantly between providers.

What this doesn't cover

The 200 lines above cover 80% of production cases. What's missing:

Add these before scaling past ~10K req/day
  • Exponential backoff per provider — wrap the call in the pattern from G9. Currently, we fail over immediately on transient errors; retry+backoff first is often cheaper.
  • Circuit breakers — the current health tracker is a soft signal. A hard circuit breaker per provider prevents pathological cases. See B4 for the pattern.
  • Distributed state — HealthTracker is per-process. Multiple instances see different health signals. For consistency, back it with Redis.
  • Streaming — the current interface returns whole responses. For streaming, adapters need to yield chunks; failover after any bytes stream is impossible.
  • Provider-specific features — tool use, structured output, vision. These require extending the interface with capability flags.

Each of these is worth a post on its own. Together, they're the operational polish layer. The load balancer above is the foundation.

The observability you actually need

Wire the metrics hooks into whatever observability stack you use. The minimum useful set:

import { Counter, Histogram } from "prom-client";

const callCounter = new Counter({
  name: "llm_provider_calls_total",
  help: "Total provider calls",
  labelNames: ["provider"],
});
const successCounter = new Counter({
  name: "llm_provider_success_total",
  help: "Successful provider calls",
  labelNames: ["provider"],
});
const failureCounter = new Counter({
  name: "llm_provider_failure_total",
  help: "Failed provider calls",
  labelNames: ["provider", "status"],
});
const latencyHistogram = new Histogram({
  name: "llm_provider_latency_ms",
  help: "Provider latency",
  labelNames: ["provider"],
  buckets: [50, 100, 250, 500, 1000, 2500, 5000, 10000],
});
const failoverCounter = new Counter({
  name: "llm_failovers_total",
  help: "Failover events",
  labelNames: ["from", "to"],
});


const lb = new LoadBalancer({
  providers: [...],
  onProviderCall: (name) => callCounter.labels(name).inc(),
  onProviderSuccess: (name, latencyMs) => {
    successCounter.labels(name).inc();
    latencyHistogram.labels(name).observe(latencyMs);
  },
  onProviderFailure: (name, err) => 
    failureCounter.labels(name, String(err.statusCode ?? "unknown")).inc(),
  onFailover: (from, to) => failoverCounter.labels(from, to).inc(),
});

With these five metrics, you can answer: which providers are healthy right now, what's my failover rate, which provider is fastest, where are my error concentrations. Dashboards write themselves.

The insurance policy framing

A load balancer isn't free. Each adapter is code you maintain. Each provider is an account, keys to rotate, docs to keep up with. Cross-provider testing is complexity you didn't have when you ran on Claude alone.

The value proposition is that it's insurance. During Anthropic's 40-minute outage in October 2026, apps with load balancers stayed up; apps without them didn't. During OpenAI's tier-4 rate limit reduction earlier in the year, apps with load balancers absorbed the shift by shifting weight; apps without them had to scramble. The complexity cost is upfront; the payoff is when things break, which will happen. Every major provider has had multi-hour outages in the past two years.

The threshold for "worth building" is somewhere around "revenue depends on the app being up." For consumer chatbots serving 10K+ users, yes. For internal tools where a 30-minute outage means "team goes to lunch," probably not. Judge accordingly.

What's next

Two directions from here:

First, harden this: add exponential backoff (G9), circuit breakers (B4), Redis-backed state, streaming support. That gets you to a load balancer that survives real scale.

Second, extend it: add tool-use routing (some providers handle certain tool patterns better), structured output routing (route to whichever provider best supports your schema shape), vision routing (only some providers support images). That gets you to a load balancer that's a strategic asset, not just insurance.

The 200 lines above are the starting point. Everything else is polish on the foundation.

Frequently asked questions

How is this different from LiteLLM or OpenRouter?

LiteLLM and OpenRouter are hosted routing services or libraries with their own opinions about routing, retries, and observability. This is 200 lines you own and can modify. Use LiteLLM or OpenRouter if you want the routing outsourced. Use this if you want direct control, no external dependency, and simpler debugging.

Do I need this if I'm using the Vercel AI SDK?

Vercel AI SDK has provider abstraction but doesn't do load balancing across providers by default. You can wrap AI SDK providers in this load balancer if you want both. See the Vercel AI SDK errors guide (G6) for the SDK's built-in behavior.

Should I fail over on rate limits or just backoff?

Depends on how long the rate limit lasts. If Retry-After says 30 seconds and you have a 1-second SLA, fail over. If the rate limit is 500ms and you have a 5-second budget, backoff. The current implementation fails over immediately; add exponential backoff (G9) as a first-line defense before failover kicks in.

How do I handle cost differences during failover?

Two options: (1) use the cost strategy to prefer cheaper providers when available, (2) instrument the failover event and alert when expensive-provider traffic exceeds thresholds. Both are useful; the first is proactive, the second reactive. Chapter 8 of the AI Chatbot Cost Guide (G10) covers optimization in depth.

Can I use different models on different providers?

Yes — each adapter is instantiated with a specific model. You can have three Anthropic adapters (Opus 5, Sonnet 4.6, Haiku 4.5) and route based on strategy. Common pattern: primary=Sonnet, failover to Opus if Sonnet down, ultimate failover to GPT.

What happens if all providers fail?

The load balancer throws an error after trying every provider. Your app handles this like any other final failure — surface to the user, log, alert. The onAllFailed hook lets you distinguish this catastrophic case from individual provider failures in your metrics.

How do I test the failover logic?

Mock the provider adapters. In tests, create adapters that throw specific errors on specific calls, then verify the balancer fails over correctly. Cover: transient error causing failover, non-retryable error surfacing immediately, all providers failing, health degradation causing reordering.

Does this work with streaming?

Not as written. Streaming failover is fundamentally hard — once bytes stream to the client, you can't undo them if the stream fails mid-response. The pragmatic pattern: use load balancer for pre-stream request setup (choosing provider, handling initial 429s), then commit to the chosen provider for the stream. See G4 for streaming failover discussion.

How much does this cost to run vs single-provider?

In the healthy case: identical to single-provider (only the primary is called). During failover: you pay for one failed call plus the successful failover call. If your primary is 99.5% reliable, added cost is under 1%. The insurance value dwarfs the runtime cost.

Can I add my own provider (e.g., a fine-tuned model on Replicate)?

Yes — implement the ProviderAdapter interface. As long as call() takes a CompletionRequest and returns a CompletionResponse (or throws a ProviderError), the load balancer treats it like any other provider. Custom endpoints, self-hosted models, and specialized providers all plug in the same way.