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.



