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