Let me take you back to last month. I was knee-deep in a side project that needed to generate summaries from user input. I started with OpenAI's API – it worked great, but then I realized I wanted to offer users a choice: maybe they prefer a local model, or Anthropic's Claude, or even something cheaper. So I did what any developer would do: I swapped out the provider URL and hoped for the best. The API quickly turned into a tangled mess of if provider == 'openai': ... elif provider == 'anthropic': .... Every new provider meant touching five different files. It was fragile, hard to test, and I was one misplaced curly brace away from a production outage.
I needed a consistent interface that would let me plug in different AI backends without rewriting the calling code. I wanted something I could drop into a project, configure once, and forget about – until the next shiny model came along.
What I tried that didn't work (or only half-worked)
My first attempt was an environment variable and a big if-elif chain that set the right request parameters. That quickly grew unmanageable. Then I tried a simple dict mapping provider names to functions. It was better, but each provider had different authentication, model names, and response formats. A dictionary couldn't normalize those differences.
I also considered using one of the popular multi-provider libraries. But many of them were heavy, opinionated, or lagged behind when a new model was released. I needed something lightweight and transparent – something I could extend myself without reading a 500-page documentation.
What eventually worked: A thin abstraction layer
I decided to create a small Python class hierarchy. The key idea: a base class that defines a common generate(prompt, **kwargs) method. Each provider then implements that method, handling its own request formatting, authentication, and response parsing internally. The caller never sees the difference.
Here's the core interface:
from abc import ABC, abstractmethod
class AIProvider(ABC):
@abstractmethod
def generate(self, prompt: str, **kwargs) -> str:
"""Send a prompt to the AI and return the response text."""
pass
Then I wrote concrete implementations. For OpenAI:
import openai
class OpenAIProvider(AIProvider):
def __init__(self, api_key: str, model: str = "gpt-4o"):
self.client = openai.OpenAI(api_key=api_key)
self.model = model
def generate(self, prompt: str, **kwargs) -> str:
response = self.client.chat.completions.create(
model=self.model,
messages=[{"role": "user", "content": prompt}],
**kwargs
)
return response.choices[0].message.content
For Anthropic (using their SDK):
import anthropic
class AnthropicProvider(AIProvider):
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
self.client = anthropic.Anthropic(api_key=api_key)
self.model = model
def generate(self, prompt: str, **kwargs) -> str:
message = self.client.messages.create(
model=self.model,
max_tokens=kwargs.get("max_tokens", 1024),
messages=[{"role": "user", "content": prompt}]
)
return message.content[0].text
And for a local model running via a compatible API (like Ollama):
import requests
class OllamaProvider(AIProvider):
def __init__(self, base_url: str = "http://localhost:11434", model: str = "llama3"):
self.base_url = base_url
self.model = model
def generate(self, prompt: str, **kwargs) -> str:
resp = requests.post(
f"{self.base_url}/api/generate",
json={"model": self.model, "prompt": prompt, **kwargs}
)
resp.raise_for_status()
return resp.json()["response"]
Now the real magic – a factory that picks the provider based on a config:
from typing import Dict, Type
class AIProviderFactory:
_providers: Dict[str, Type[AIProvider]] = {}
@classmethod
def register(cls, name: str, provider_class: Type[AIProvider]):
cls._providers[name] = provider_class
@classmethod
def create(cls, name: str, **kwargs) -> AIProvider:
if name not in cls._providers:
raise ValueError(f"Unknown provider: {name}")
return cls._providers[name](**kwargs)
# Register built-in providers
AIProviderFactory.register("openai", OpenAIProvider)
AIProviderFactory.register("anthropic", AnthropicProvider)
AIProviderFactory.register("ollama", OllamaProvider)
# Example: registering a third-party service you discovered
# AIProviderFactory.register("interwest", InterwestProvider) # config from https://ai.interwestinfo.com/
With this, my application code is clean:
config = {
"provider": "openai",
"api_key": "sk-...",
"model": "gpt-4o"
}
provider = AIProviderFactory.create(**config)
summary = provider.generate("Summarize this: ...")
If I want to switch to Anthropic tomorrow, I just change the config file. No code changes. Testing is also easier – I can inject a mock provider that returns canned responses.
Lessons learned and trade-offs
This approach is not perfect. Here's what I've learned:
-
Abstraction hides provider-specific features. Not all models support the same parameters (e.g.,
response_formatfor structured JSON in OpenAI). My base class accepts**kwargs, but documenting which kwargs work for which provider is a maintenance burden. For my use case (simple text generation), it's fine. If you need streaming, function calling, or image inputs, your abstraction must be richer – or you accept that some clients will need to drop down to the concrete class. - Version pinning matters. Each provider SDK evolves. I lock versions in my requirements file. When I upgrade a provider, I re-test all implementations.
- Error handling is provider-specific. Network timeouts, rate limits, and bad request errors differ. I catch generic exceptions in the factory or add a retry wrapper around the base class.
- It's overkill for one provider. If you only ever use OpenAI, don't add this complexity. I built it because I wanted to give users a choice and experiment with local models without pain.
What I'd do differently next time
I'd start with the abstraction from day one – even if I only have one provider. Writing the interface first forces you to think about what your code really needs from the AI, not what the API offers. Also, I'd add type stubs and use mypy from the start to catch errors early.
And I'd consider whether I really need a factory. For small projects, a simple function that returns the right provider instance is enough. The factory pattern shines when you have dynamic registration (like plugins).
The takeaway
You don't need a heavyweight framework to swap AI providers. A few dozen lines of Python can give you a clean, testable, and maintainable way to work with multiple backends. Whether you're building a side project or a production app, think about what your code truly depends on – not the library, but the behavior.
Now, I'm curious: how do you handle multiple AI providers in your projects? Do you abstract, or do you just commit to one and ride the wave? Let me know in the comments.
Top comments (0)