Write Once, Talk to Any LLM: a Provider-Agnostic Abstraction
If you’ve wired an application to more than one language-model provider, you’ve felt the friction. OpenAI wants a list of {"role": ..., "content": ...} messages. Google’s Gemini wants {"role": ..., "parts": [...]}. Token counting is a tiktoken lookup for one and an API call for the other. The errors are named differently, the clients are constructed differently, and the role vocabularies don’t even agree on what to call the model’s own turns.
Leave that difference exposed and it metastasizes: every feature that calls a model grows an if provider == "openai" branch, and adding another provider means editing a dozen call sites. The fix is old and boring and works: put an adapter in front of the mess so application code talks to one interface and never branches on provider, the same bet that let me store recipes, bookmarks, and groceries behind one polymorphic REST endpoint instead of one per shape. This post is about a specific, slightly unusual way to build that adapter: with mixins instead of factories.
Three providers, three shapes
Here’s the matrix the abstraction has to absorb. These are the providers, models, and context windows the library actually ships with, straight from constants.py:
| Provider | Example model | Message shape | Roles | Context window |
|---|---|---|---|---|
| OpenAI | gpt-5.6 |
{role, content} |
system / user / assistant | 1,050,000 |
| Google Gemini | gemini-2.5-flash |
{role, parts: [...]} |
user / model | 1,048,576 |
| DeepSeek | deepseek-v4-flash |
{role, content} (OpenAI-compatible) |
system / user / assistant | 1,000,000 |
Two things jump out. First, the message shape genuinely differs: OpenAI and its compatible cousins use a flat content string (OpenAI text generation docs), while Gemini wraps content in a parts list and renames assistant to model (Gemini API reference). Second, some of this isn’t a real difference: DeepSeek is wire-compatible with OpenAI and just needs a different base URL and key.
That second observation is the whole reason to prefer composition over a tree of subclasses: the variation isn’t one axis, it’s several partly-overlapping ones.
One interface
The application should only ever see this:
chatter = OpenAIChatter(setup="You are a helpful assistant.", model="gpt-5.6")
reply = chatter.answer("Summarize this paragraph.")
Swap OpenAIChatter for GoogleChatter and nothing else changes. answer(prompt) returns a string. The conversation history, the token accounting, the provider-specific client object: all of it lives behind the interface. That contract is pinned down by an abstract base class (base.py), which declares what every chatter must provide and nothing about how:
class BaseChatter(ABC):
def __init__(self, *args, setup=None, api_key=None,
temperature=0.0, model=None, **kwargs):
self.setup = setup
self.model = model
self.messages: List[Dict] = []
# the client is whatever the provider mixin builds
self.client = self._configure(*args, api_key=api_key, **kwargs)
@abstractmethod
def _configure(self, *args, api_key, **kwargs): ...
@abstractmethod
def answer(self, prompt: str, **kwargs) -> str: ...
@abstractmethod
def _update(self, *args, role: str, content: str, **kwargs): ...
The base defines the vocabulary: _configure builds the client and sets the token budget, _update appends to history in the provider’s shape, answer runs the round-trip. It deliberately implements none of them. This is the Adapter pattern from the Gang of Four: convert each provider’s interface into the one the client expects. The twist is how the conversion gets supplied.
Mixins over factories
The textbook way to plug in providers is a factory plus a strategy object: a ChatterFactory.create("openai") that returns some OpenAIStrategy you then hold and delegate to. That works, but it spreads one provider’s logic across two classes and a registry, and you spend real lines wiring delegation methods that do nothing but forward calls.
Python’s multiple inheritance lets you skip all of it. Each provider is a mixin that supplies the missing methods, and a concrete chatter is just the mixin plus the base:
class OpenAIChatter(OpenAIChatterMixin, BaseChatter): ...
class GoogleChatter(GoogleChatterMixin, BaseChatter): ...
That single line is the configuration. There’s no factory function, no registry dict, no strategy field. The class declaration composes behavior directly. When BaseChatter.__init__ calls self._configure(...), Python’s method resolution order finds the mixin’s _configure first (mixin is listed before the base), so the right provider logic runs without any dispatch code you had to write. The MRO is the dispatch (Python docs: multiple inheritance).
This is the same shape as a JDBC driver or a SQLAlchemy dialect: a stable top-level interface, with the database- or provider-specific differences encapsulated below it so application code stays portable (JDBC overview; SQLAlchemy dialects). The difference is that here the “driver” is mixed straight into the class rather than loaded at runtime.
What each provider must supply
A mixin earns its place by answering three questions: how do I build the client, how do I shape a message, and how do I count tokens. Putting two mixins side by side makes the contract concrete: watch the message shape diverge.
OpenAI builds a flat message and counts tokens locally with tiktoken:
class OpenAIChatterMixin:
def _configure(self, *args, api_key=None, organization=None, **kwargs):
self._load_credentials(api_key=api_key, organization=organization)
self.max_tokens = dict(MAX_TOKENS)[self.model]
if self.setup:
self.messages.append({'role': ROLES[0], 'content': self.setup})
return self._set_client(**kwargs)
def _update(self, *args, role: str, content: str, **kwargs):
if role not in ROLES:
raise KeyError(f"`role` must be one of: {ROLES}")
message = {'role': role, 'content': content} # flat content
self.messages.append(message)
self._reduce_number_of_tokens_if_needed(**kwargs)
Google wraps content in parts, renames the model’s role, and counts tokens by asking the API:
class GoogleChatterMixin:
def _configure(self, *args, api_key=None, **kwargs):
if self.model is None:
self.model = MODELS[7]
self.max_tokens = dict(MAX_TOKENS)[self.model]
self.__load_credentials(api_key=api_key)
if self.setup:
self.messages.extend([
{'role': ROLES[1], 'parts': [self.setup]}, # 'user'
{'role': ROLES[-1], 'parts': ['Understood']}, # 'model'
])
return self._set_client(**kwargs)
def _update(self, role: str, content, use_agent=True, agent=None):
if role not in [ROLES[1], ROLES[-1]]:
raise KeyError(f"`role` must be {ROLES[1]} or {ROLES[-1]}")
message = {'parts': [content], 'role': role} # parts list
self.messages.append(message)
self._reduce_number_of_tokens_if_needed(use_agent, agent)
Same method names, completely different bodies. The application never sees either one. It sees answer, which calls _update and lets the MRO route to whichever body belongs to the class it’s running in. (ROLES and MAX_TOKENS live in constants.py, so the role vocabulary and the per-model budgets live as data.)
A nice payoff of inheritance-as-composition: DeepSeek is wire-compatible with OpenAI, so it’s one subclass that overrides only credential loading and the base URL:
class DeepSeekChatter(OpenAIChatter):
def __init__(self, api_key=None, model=MODELS[12], **kwargs):
super().__init__(api_key=api_key, model=model,
base_url="https://api.deepseek.com", **kwargs)
def _load_credentials(self, api_key, organization=None):
self.api_key = api_key or os.getenv("DEEPSEEK_API_KEY")
...
No new mixin, no duplicated message logic. The OpenAI mixin already does the heavy lifting.
Adding a provider
This is the test of an abstraction: what does it cost to add the next one? With provider branching scattered through the app, adding a provider means hunting down every if/elif and extending it. With the mixin approach, it’s one new mixin (three methods) and one class declaration. The caller-side cost is the part that matters:
| Approach | Lines the caller changes to add a provider |
|---|---|
Scattered if provider == ... branching |
grows at every call site (N branches × M call sites) |
One Chatter interface (mixin + base) |
0: application code already calls .answer() |
The implementation still costs a mixin either way: abstraction doesn’t make the provider’s quirks vanish, it just quarantines them. What drops to zero is the change application code absorbs, because it was never branching on provider to begin with. That’s the whole point of an adapter: pay the cost once, at the seam.
Where I used this
I built this for an agent library that needed to survive provider outages and pricing changes without rewrites. Because every chatter answers to the same answer(prompt) contract, the surrounding code (retry logic, conversation summarization when history overflows the context window, the agent loop itself) is written exactly once and runs against all of them. When one provider rate-limits or degrades, falling back to another is swapping the class behind the interface. (The fallback machinery itself is a separate concern; I wrote about graceful provider degradation and circuit breakers in an earlier post.)
References
- Gamma, Helm, Johnson, Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software (the Adapter pattern).
- Python tutorial: multiple inheritance and MRO: how method resolution order makes mixin composition work.
- OpenAI text generation guide: the
{role, content}message shape andsystem/user/assistant/developerroles. - Gemini API: generateContent: the
{role, parts}Contentobject anduser/modelroles. - Java Database Connectivity (JDBC). Driver abstraction: one API, many databases.
- SQLAlchemy dialects: a unified interface over PostgreSQL, MySQL, SQLite, and more.
Working on something in this space, or hiring for it?
Keep reading
- The cheaper model that cost 51% more: what my eval harness caughtGPT-5.6-terra lists 20% below GPT-5.4 on both input and output. I swapped my expense tracker's default to it on that basis, then measured it against real receipts: it cost 51% more per receipt, and it would have returned HTTP 400 on every single photo.August 17, 2026
- Don't let the LLM do the math: deterministic discount proration for receipt OCRA vision model reads the receipt fine, then quietly loses a cent splitting the discount. Here's why I moved the arithmetic out of the model into a small Python function whose shares always sum to the amount paid.August 7, 2026
- Model churn is a maintenance tax: what broke when GPT-5.6 and Gemini renamed everythingA provider-agnostic layer is the easy part. The recurring cost is keeping the model IDs and capabilities current as GPT-5.6 and Gemini reshuffle underneath you. Here's the churn my adapter absorbed this month, and the three call sites the refresh silently repointed.August 4, 2026
Get the next update by email
Build-in-public updates and new posts, delivered as a digest. Double opt-in · no spam · unsubscribe anytime · handled by Buttondown.