Swap LLM providers
from config, not call sites.
A minimal interface and adapter pattern for LLM applications — one shared contract, one adapter per provider, and a factory that picks the active provider from config. Switching from OpenAI to Anthropic (or back) touches one file.
Available in TypeScript and Python. Copying the code is unrestricted — nothing is sent to Dwayo unless you choose to download, save, or request a review.
// provider.ts — the contract every adapter implements
export interface CompletionRequest {
messages: { role: 'system' | 'user' | 'assistant'; content: string }[];
maxTokens?: number;
temperature?: number;
}
export interface CompletionResponse {
text: string;
provider: string;
model: string;
usage: { inputTokens: number; outputTokens: number };
}
export interface ModelProvider {
readonly name: string;
complete(req: CompletionRequest): Promise<CompletionResponse>;
}
// adapters/anthropic.ts
import Anthropic from '@anthropic-ai/sdk';
import type { ModelProvider, CompletionRequest, CompletionResponse } from '../provider';
export class AnthropicProvider implements ModelProvider {
readonly name = 'anthropic';
private client: Anthropic;
private model: string;
constructor(apiKey: string, model = 'claude-sonnet-5') {
this.client = new Anthropic({ apiKey });
this.model = model;
}
async complete(req: CompletionRequest): Promise<CompletionResponse> {
const system = req.messages.find(m => m.role === 'system')?.content;
const turns = req.messages
.filter(m => m.role !== 'system')
.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }));
const res = await this.client.messages.create({
model: this.model,
system,
messages: turns,
max_tokens: req.maxTokens ?? 1024,
temperature: req.temperature,
});
return {
text: res.content.map(b => (b.type === 'text' ? b.text : '')).join(''),
provider: this.name,
model: this.model,
usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens },
};
}
}
// adapters/openai.ts
import OpenAI from 'openai';
import type { ModelProvider, CompletionRequest, CompletionResponse } from '../provider';
export class OpenAIProvider implements ModelProvider {
readonly name = 'openai';
private client: OpenAI;
private model: string;
constructor(apiKey: string, model = 'gpt-4o') {
this.client = new OpenAI({ apiKey });
this.model = model;
}
async complete(req: CompletionRequest): Promise<CompletionResponse> {
const res = await this.client.chat.completions.create({
model: this.model,
messages: req.messages,
max_tokens: req.maxTokens ?? 1024,
temperature: req.temperature,
});
const choice = res.choices[0];
return {
text: choice.message.content ?? '',
provider: this.name,
model: this.model,
usage: {
inputTokens: res.usage?.prompt_tokens ?? 0,
outputTokens: res.usage?.completion_tokens ?? 0,
},
};
}
}
// factory.ts — this is the only file that changes when you switch providers
import { AnthropicProvider } from './adapters/anthropic';
import { OpenAIProvider } from './adapters/openai';
import type { ModelProvider } from './provider';
export function getProvider(): ModelProvider {
const name = process.env.MODEL_PROVIDER ?? 'anthropic';
switch (name) {
case 'anthropic':
return new AnthropicProvider(process.env.ANTHROPIC_API_KEY!);
case 'openai':
return new OpenAIProvider(process.env.OPENAI_API_KEY!);
default:
throw new Error(`Unknown MODEL_PROVIDER: ${name}`);
}
}
// usage.ts — call sites never import a provider SDK directly
import { getProvider } from './factory';
const provider = getProvider();
const result = await provider.complete({
messages: [{ role: 'user', content: 'Summarize this support ticket.' }],
});
console.log(result.provider, result.text);
# provider.py — the contract every adapter implements
from typing import Protocol, Literal, TypedDict
class Message(TypedDict):
role: Literal["system", "user", "assistant"]
content: str
class Usage(TypedDict):
input_tokens: int
output_tokens: int
class CompletionResponse(TypedDict):
text: str
provider: str
model: str
usage: Usage
class ModelProvider(Protocol):
name: str
def complete(
self,
messages: list[Message],
max_tokens: int = 1024,
temperature: float | None = None,
) -> CompletionResponse: ...
# adapters/anthropic_provider.py
import anthropic
from provider import ModelProvider, Message, CompletionResponse
class AnthropicProvider:
name = "anthropic"
def __init__(self, api_key: str, model: str = "claude-sonnet-5"):
self.client = anthropic.Anthropic(api_key=api_key)
self.model = model
def complete(self, messages, max_tokens=1024, temperature=None) -> CompletionResponse:
system = next((m["content"] for m in messages if m["role"] == "system"), None)
turns = [m for m in messages if m["role"] != "system"]
res = self.client.messages.create(
model=self.model,
system=system,
messages=turns,
max_tokens=max_tokens,
temperature=temperature,
)
return {
"text": "".join(block.text for block in res.content if block.type == "text"),
"provider": self.name,
"model": self.model,
"usage": {
"input_tokens": res.usage.input_tokens,
"output_tokens": res.usage.output_tokens,
},
}
# adapters/openai_provider.py
from openai import OpenAI
from provider import ModelProvider, Message, CompletionResponse
class OpenAIProvider:
name = "openai"
def __init__(self, api_key: str, model: str = "gpt-4o"):
self.client = OpenAI(api_key=api_key)
self.model = model
def complete(self, messages, max_tokens=1024, temperature=None) -> CompletionResponse:
res = self.client.chat.completions.create(
model=self.model,
messages=messages,
max_tokens=max_tokens,
temperature=temperature,
)
choice = res.choices[0]
return {
"text": choice.message.content or "",
"provider": self.name,
"model": self.model,
"usage": {
"input_tokens": res.usage.prompt_tokens if res.usage else 0,
"output_tokens": res.usage.completion_tokens if res.usage else 0,
},
}
# factory.py — this is the only file that changes when you switch providers
import os
from adapters.anthropic_provider import AnthropicProvider
from adapters.openai_provider import OpenAIProvider
def get_provider():
name = os.environ.get("MODEL_PROVIDER", "anthropic")
if name == "anthropic":
return AnthropicProvider(os.environ["ANTHROPIC_API_KEY"])
if name == "openai":
return OpenAIProvider(os.environ["OPENAI_API_KEY"])
raise ValueError(f"Unknown MODEL_PROVIDER: {name}")
# usage.py — call sites never import a provider SDK directly
from factory import get_provider
provider = get_provider()
result = provider.complete(
messages=[{"role": "user", "content": "Summarize this support ticket."}]
)
print(result["provider"], result["text"])
Lock-in happens one
direct SDK call at a time.
No one sets out to build a single-provider application. It happens gradually: the provider's SDK gets imported directly into a dozen call sites, its response shape leaks into application code, and a few provider-specific parameters get hardcoded along the way. By the time a provider outage, pricing change, or a materially better model makes switching attractive, the actual cost of switching has quietly become very high.
A thin abstraction layer, adopted early, keeps that cost low — a new adapter and a config change, not a rewrite.
What the starter
pattern covers.
One contract — messages in, a normalized response out — that every adapter implements, so application code never depends on a specific provider's SDK shape.
OpenAI and Anthropic adapters included; adding Gemini or a self-hosted model server means writing one more adapter against the same contract.
A factory function reads which provider to use from configuration, so switching providers is a deploy-time decision, not a code change at every call site.
Token usage is returned in a consistent shape across providers, so cost tracking and the LLM Cost Calculator on this site don't need provider-specific logic.
Keeping the
abstraction honest.
- Keep domain logic (what the application is trying to do) separate from provider logic (how a specific SDK is called).
- Normalize the common path — messages, system prompt, basic parameters, usage — not every provider-specific feature.
- Let call sites reach past the abstraction deliberately for a feature only one provider supports, rather than hiding it.
- Version the interface, and treat a breaking change to it as a breaking change for every adapter.
- Test each adapter against the same contract test suite so "works for OpenAI" and "works for Anthropic" mean the same thing.
- Normalize error types (rate limit, auth, timeout, content filter) so error handling doesn't depend on which provider is active.
Adopting the
pattern.
- Drop the interface and one adapter into a new codebase before writing any call sites — retrofitting later costs more.
- In an existing codebase, start with the highest-traffic call site and migrate outward.
- Add a second adapter early, even if you don't plan to switch yet — it's the fastest way to find where the abstraction leaked.
- Wire usage data into your cost tracking so provider switches are visible in the numbers, not just the code.
- Add retry, timeout, and error-normalization logic appropriate to your production requirements.
- Document which provider-specific features call sites intentionally bypass the abstraction for.
What this starter code
doesn't include.
- Streaming response support
- Retry, backoff, and rate-limit handling
- Tool/function-calling normalization across providers
- Automatic failover between providers
- A published, versioned package — this is a pattern to adapt, not install
- Embedding, image, or audio model interfaces
When to adopt
this pattern.
- Starting a new LLM application, before provider-specific calls spread through the codebase.
- Evaluating whether to switch model providers.
- Adding a fallback provider for reliability.
- Standardizing cost and usage tracking across multiple providers.
- Responding to a provider outage or deprecation with less rework.
- Preparing a vendor-neutral architecture for an enterprise security review.
Before you wire
this in.
Why abstract LLM providers instead of calling the SDK directly?
Model pricing, capability, and availability change faster than most application code should. An abstraction layer means a provider outage, price change, or a materially better model only requires changing one adapter and a config value — not every call site in the codebase.
Doesn't an abstraction layer hide provider-specific features I need?
A thin abstraction should cover the common path — messages, system prompt, basic parameters, token usage — and let call sites reach past it deliberately for a provider-specific feature, rather than hiding that capability entirely. Don't force every feature into the lowest common denominator.
Is this starter code production-ready?
It's a minimal, readable starting point, not a published package. Add retry logic, streaming support, error normalization, rate-limit handling, and tests appropriate to your usage before relying on it in production.
Does this help with automatic failover between providers?
The abstraction makes failover straightforward to add — because every provider implements the same interface, a wrapper can try one provider and fall back to another on failure. The starter code shows the interface; failover logic is a small addition on top of it.
Which providers does the starter code include?
OpenAI and Anthropic adapters, in both TypeScript and Python, following the same interface. Adding Gemini, an open-source model server, or another provider means writing one more adapter against the same contract.
Does Dwayo see anything when I copy or download this code?
Copying the code is unrestricted. Downloading the starter file is only shared with Dwayo if you choose to go through the lead-capture flow. See our privacy policy.
A second adapter is the fastest way
to find where the abstraction leaked.
Add a second provider early, even before you need it. If your architecture already has deep provider coupling, a Dwayo engineer can help scope a migration path.