The run-anywhere week — ZML's free cross-chip server, OpenCode's model-agnostic design going mainstream — all points at one boring, durable practice: never call a model provider's SDK directly from your application code. Put one thin interface in between, and every future migration becomes a config change instead of a rewrite.

This is the cheapest insurance you can buy on your AI stack. It's about 130 lines of TypeScript, it doesn't force you to self-host anything, and it doesn't slow anything down. It just guarantees that the day a provider triples its price, has a bad outage, or you decide to serve an open model on cheaper silicon, the change is isolated to one file. Here's the whole thing, in five steps.

Step 1 — One narrow interface your app knows about#

Your application should know about exactly one shape. Define a normalized request and response, and a single complete() function. Nothing downstream imports a provider SDK.

// llm/types.ts — the only shape your app depends on
export interface LLMRequest {
  model: string;                 // logical name, e.g. "fast" | "smart"
  messages: { role: "system" | "user" | "assistant"; content: string }[];
  maxTokens?: number;
  temperature?: number;
}
export interface LLMResponse {
  text: string;
  finish: "stop" | "length" | "tool_call" | "error";
  usage: { promptTokens: number; completionTokens: number };
  backend: string;               // which adapter answered (for logs/metrics)
}
export interface Backend {
  name: string;
  complete(req: LLMRequest): Promise<LLMResponse>;
}

The key discipline: no provider SDK import is allowed outside an adapter file. If your route handler reads response.choices[0].finish_reason, you've already leaked.

Step 2 — Thin adapters behind the interface#

Because most providers — and every major self-hosting server — speak an OpenAI-compatible /chat/completions schema, each adapter is a near-identical translation. Here's one for any OpenAI-style hosted API, parameterized by base URL and key:

// llm/openai-compatible.ts
import type { Backend, LLMRequest, LLMResponse } from "./types";

export function openaiCompatible(cfg: {
  name: string; baseUrl: string; apiKey: string; modelMap: Record<string, string>;
}): Backend {
  return {
    name: cfg.name,
    async complete(req: LLMRequest): Promise<LLMResponse> {
      const res = await fetch(`${cfg.baseUrl}/chat/completions`, {
        method: "POST",
        headers: { "content-type": "application/json",
                   authorization: `Bearer ${cfg.apiKey}` },
        body: JSON.stringify({
          model: cfg.modelMap[req.model] ?? req.model,
          messages: req.messages,
          max_tokens: req.maxTokens, temperature: req.temperature,
        }),
      });
      if (!res.ok) throw new Error(`${cfg.name} ${res.status}`);
      const j = await res.json();
      const c = j.choices[0];
      return {                       // normalize here, once
        text: c.message.content ?? "",
        finish: c.finish_reason === "length" ? "length"
              : c.finish_reason === "tool_calls" ? "tool_call" : "stop",
        usage: { promptTokens: j.usage?.prompt_tokens ?? 0,
                 completionTokens: j.usage?.completion_tokens ?? 0 },
        backend: cfg.name,
      };
    },
  };
}

A self-hosted open model — served with vLLM, SGLang, or a cross-chip server like ZML's LLMD — uses the same adapter, pointed at your own base URL. That's the whole trick: a model on your hardware looks identical to a hosted API from the app's side. (For the trade-offs between serving engines, we compared vLLM vs. SGLang vs. Ollama.)

Step 3 — Pick the backend from config, not code#

Which provider and which model are deployment settings, not code. Wire them from env so you flip them without shipping logic:

// llm/index.ts
import { openaiCompatible } from "./openai-compatible";

const hosted = openaiCompatible({
  name: "hosted", baseUrl: process.env.HOSTED_URL!, apiKey: process.env.HOSTED_KEY!,
  modelMap: { fast: "gpt-fast", smart: "gpt-smart" },
});
const selfHosted = openaiCompatible({
  name: "self", baseUrl: process.env.SELF_URL!, apiKey: process.env.SELF_KEY ?? "-",
  modelMap: { fast: "llama-8b", smart: "llama-70b" },
});

const BY_NAME = { hosted, self: selfHosted } as const;
const PRIMARY = BY_NAME[(process.env.LLM_PRIMARY ?? "hosted") as keyof typeof BY_NAME];
const FALLBACK = BY_NAME[(process.env.LLM_FALLBACK ?? "self") as keyof typeof BY_NAME];

Now "move to the self-hosted model on cheaper silicon" is LLM_PRIMARY=self — no code change, no redeploy of logic.

Step 4 — A health-checked fallback chain#

Wrap the two backends so one vendor's outage becomes a latency bump, not an outage. Start simple: try primary with a timeout, catch, try secondary.

// llm/complete.ts
import type { LLMRequest, LLMResponse } from "./types";

async function withTimeout(p: Promise<LLMResponse>, ms: number) {
  const ac = new AbortController();
  const t = setTimeout(() => ac.abort(), ms);
  try { return await p; } finally { clearTimeout(t); }
}

export async function complete(req: LLMRequest): Promise<LLMResponse> {
  for (const backend of [PRIMARY, FALLBACK]) {
    try { return await withTimeout(backend.complete(req), 20_000); }
    catch (e) { console.warn(`[llm] ${backend.name} failed:`, e); }
  }
  throw new Error("all LLM backends failed");
}

That's the version to ship. Add retries with jittered backoff for transient 429/5xx, and a lightweight circuit breaker (skip a flapping backend for a cooldown) only once you measure needing them.

Step 5 — Normalize everything you branch on#

The adapter already normalized text, finish, and usage. The rule that makes it hold: downstream code never reads a provider-specific field. Finish reason is your enum, not a vendor string. Token usage has one shape (so cost tracking is provider-independent). Tool calls, if you use them, get normalized in the adapter too. The only thing allowed to differ between providers is the code inside an adapter.

The payoff#

You now own ~130 lines, once. A router like OpenRouter is just another excellent backend to slot in as your primary — many models through one API — with a direct provider or a self-hosted open model as the fallback beneath it. The models stay commodities; switching them for price, for uptime, or for a move to cheaper chips never touches your product code. That's the whole point of the run-anywhere week: portability is a config value, and you get to decide it's one before a vendor decides it for you.