DEV Community

zhongqiyue
zhongqiyue

Posted on

Why I built a simple AI provider wrapper (and you might too)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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: ...")
Enter fullscreen mode Exit fullscreen mode

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_format for 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)