Free provider-abstraction starter code

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.

TypeScript
Python
// 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"])
Why it matters

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's included

What the starter
pattern covers.

A shared provider interface

One contract — messages in, a normalized response out — that every adapter implements, so application code never depends on a specific provider's SDK shape.

Adapters per provider

OpenAI and Anthropic adapters included; adding Gemini or a self-hosted model server means writing one more adapter against the same contract.

Config-driven provider selection

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.

Normalized usage data

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.

Design principles

Keeping the
abstraction honest.

How to use it

Adopting the
pattern.

  1. Drop the interface and one adapter into a new codebase before writing any call sites — retrofitting later costs more.
  2. In an existing codebase, start with the highest-traffic call site and migrate outward.
  3. 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.
  4. Wire usage data into your cost tracking so provider switches are visible in the numbers, not just the code.
  5. Add retry, timeout, and error-normalization logic appropriate to your production requirements.
  6. Document which provider-specific features call sites intentionally bypass the abstraction for.
Scope

What this starter code
doesn't include.

When to use it

When to adopt
this pattern.

Common questions

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.

Next step

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.

← Return to all engineering resources