All posts
Fundamentals

One interface, many models

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.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.

PortableLeaks
Plain text in, plain text outYes
Token countsMostly, names differTokenisers differ, so counts differ
System promptYes
Tool callingBroadly similar shapeArgument quality varies a lot
Structured outputSupported by mostConformance rates differ
Long-context recallVaries 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 for loop 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.