Switching AI providers is usually a small change to your HTTP calls and a large change to your codebase, because provider details leak everywhere. You can stop that with one boundary: a client that takes a base URL, a key and a model name from configuration, and speaks one request shape.
Where the leak starts
The leak is rarely the request itself. It is the dozens of places that assume a vendor: a hardcoded URL, a response field read by name, a retry helper that only knows one error format, a test that reaches the real network. By the time you want a second provider, the migration is not a URL change, it is a refactor. The fix is to decide the boundary before you need it.
One interface, three inputs
Give the rest of your application a single function it can call:
class ChatClient:
def __init__(self, base_url: str, api_key: str, model: str):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.model = model
def complete(self, messages: list[dict], **opts) -> str:
raise NotImplementedError Everything provider-specific lives behind that method. The application
imports ChatClient, not a vendor SDK.
An OpenAI-compatible implementation
Many providers expose an OpenAI-compatible endpoint, which means one implementation can cover several of them. Point it at a base URL:
import httpx
class OpenAICompatibleClient(ChatClient):
def complete(self, messages, temperature=0.2):
resp = httpx.post(
f"{self.base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.api_key}"},
json={
"model": self.model,
"messages": messages,
"temperature": temperature,
},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"] WebWright serves the Wren R1 model through an OpenAI-compatible base
URL at https://webwright.ai/v1, so the same class works
against it with a different base_url, api_key and model.
Configuration, not code
Keep the three inputs in the environment, so the switch is a deploy-time decision:
AI_BASE_URL=https://webwright.ai/v1
AI_API_KEY=...
AI_MODEL=wren-r1 import os
client = OpenAICompatibleClient(
base_url=os.environ["AI_BASE_URL"],
api_key=os.environ["AI_API_KEY"],
model=os.environ["AI_MODEL"],
) Now the provider is a value, and the application code never names it.
Add a startup health check
Before you trust a provider in production, prove it answers. One small call at boot catches a wrong key, a wrong URL or a model that is not served:
def health_check(client: ChatClient) -> bool:
try:
client.complete([{"role": "user", "content": "ping"}])
return True
except Exception:
return False Log the result and fail fast. A health check turns a mystery 3 a.m. incident into a startup error.
Keep the boundary honest
Two habits keep this from eroding:
- Do not read vendor-specific response fields outside the client.
Normalize the response inside
complete, and return one shape to the application. - Do not let tests reach the real network. Test the client against a fake transport so a provider switch cannot break your test suite.
The payoff is simple: the next time cost or latency changes your mind about a provider, you edit three environment values and re-run the health check. The application does not move.
The WebWright Team