Every provider ships its own client library, its own message shape, and its own names for the same ideas. Write against one directly and the provider's vocabulary spreads through your codebase, one call site at a time.
Fifteen lines fix that, and the fifteen lines buy more than tidiness.
The same request, twice
Here is one call to two providers. Read the differences rather than the code.
# Provider A
reply = anthropic.messages.create(
model="a-large", system=SYSTEM, max_tokens=1024,
messages=[{"role": "user", "content": q}],
)
text = reply.content[0].text
# Provider B
reply = openai.chat.completions.create(
model="b-large",
messages=[{"role": "system", "content": SYSTEM}, {"role": "user", "content": q}],
)
text = reply.choices[0].message.content
The same request, and four things differ. The system prompt is a top-level argument in one and a
message in the other. The token limit is required in one and optional in the other. The reply lives at
content[0].text in one and choices[0].message.content in the other. And the model names follow
different conventions entirely.
None of that is difficult. It is just scattered, and scattered is what makes it expensive later.
The adapter
Put every difference in one place, and give the rest of your code a single shape to call.
from dataclasses import dataclass
@dataclass
class Reply:
text: str
input_tokens: int
output_tokens: int
def ask(model: str, system: str, question: str) -> Reply:
"""One shape in, one shape out. Every provider quirk lives below this line."""
if model.startswith("a-"):
r = anthropic.messages.create(
model=model, system=system, max_tokens=1024,
messages=[{"role": "user", "content": question}],
)
return Reply(r.content[0].text, r.usage.input_tokens, r.usage.output_tokens)
r = openai.chat.completions.create(
model=model,
messages=[{"role": "system", "content": system},
{"role": "user", "content": question}],
)
return Reply(r.choices[0].message.content,
r.usage.prompt_tokens, r.usage.completion_tokens)
Your agent now calls ask(...) and knows nothing about either provider. Libraries exist that do this
for you across dozens of providers, and they are worth using. Writing it once yourself first is worth
an afternoon, because then you know exactly what the library is hiding.
Every provider quirk lives below one function. Your agent calls one shape.
What the fifteen lines actually buy
Comparison becomes a loop. With one interface, running your twenty questions across four models is
a for loop rather than four codebases.
for model in ("a-large", "a-small", "b-large", "b-small"):
correct, attempted = score(model, CASES)
print(f"{model:10s} {correct}/{len(CASES)} correct, {attempted} attempted")
That loop is the difference between choosing a model from a vendor page and choosing one from evidence about your own questions.
Upgrades stop being projects. New models land monthly. When the model is a string, trying the new one costs a config change and a test run. When it is woven through forty call sites, trying it costs a sprint, so you do not try it, and you quietly stay a year behind.
Cost control gets a dial. Once every call goes through one function, routing the cheap steps to a small model and the hard ones to a large one is a change in one place.
Where it leaks
An adapter hides the differences that are cosmetic. Some differences are real, and pretending otherwise causes the worst kind of bug, the one that appears only under load.
| Portable | Leaks | |
|---|---|---|
| Plain text in, plain text out | Yes | |
| Token counts | Mostly, names differ | Tokenisers differ, so counts differ |
| System prompt | Yes | |
| Tool calling | Broadly similar shape | Argument quality varies a lot |
| Structured output | Supported by most | Conformance rates differ |
| Long-context recall | Varies widely across the window |
So the adapter makes swapping a model possible, and it never makes two models equivalent. Swap the string, then run your twenty questions again. The point of the abstraction is that re-testing costs you five minutes rather than a rewrite.
The part that matters beyond engineering
Here is the reason I build this layer on day one rather than day sixty.
Where a model runs is a data question. Every call sends your prompt, and your prompt carries whatever you put in it: customer text, extracts from documents, internal figures. That data crosses a border to wherever the provider processes it.
When the provider is one string in one function, moving that workload is a configuration change you can make in an afternoon and verify with a test run. When the provider is spread across your codebase, the same move is a project you keep deferring, and deferring it is how a temporary choice becomes the permanent architecture.
Portability is worth building before you need it, because the day you need it you will need it quickly.
What to take from this
- Every provider difference belongs below one function, so the rest of your code sees one shape.
- One interface turns model comparison into a
forloop over your own question set, which is how you pick a model on evidence. - The adapter makes a swap cheap to attempt and never makes two models equivalent. Re-run your questions after every swap.
An adapter gets a reply back reliably. Getting a reply your code can use is the next problem, and it is where most demos stop being systems.