chapter 07
Multi-Provider Architecture
One contract, many adapters - swap engines without touching the loop.
1 / 9
Your agent currently only knows how to talk to LM Studio. What happens when you want to try
a bigger cloud model for a hard task, or your local machine is asleep, or you just want to
compare answers? You do not want to rewrite run_agent every time. This chapter builds
the abstraction that fixes that - and it's the same idea, applied to your own code, that made
pyproject.toml (Chapter 2) and JSON (Chapter 3) useful in the first place: agree on a
shared contract, then let implementations differ behind it.
This chapter is less about LLMs specifically and more about a general engineering skill: when you notice that several things are "basically the same but slightly different," that's usually a sign you should design an interface, not copy-paste code with small edits. LM Studio, OpenAI, and Anthropic are a perfect case study because they solve the exact same problem (send a conversation, get a reply) with three genuinely different function signatures.
7.1 The problem, concretely
LM Studio (OpenAI-style) Anthropic (its own style)
client.chat.completions.create( client.messages.create(
model=..., model=...,
messages=[ max_tokens=1024,
{"role":"system","content":...}, system="...", ← system is SEPARATE, not in the list
{"role":"user","content":...}, messages=[
] {"role":"user","content":...},
) ]
)Same intent ("send a conversation, get a reply"), different shape. If run_agent calls
these directly, every provider you add means editing your core agent logic. That's fragile -
exactly the problem Chapter 1.5's inheritance example was foreshadowing.
Imagine you didn't build the abstraction, and instead wrote your agent loop directly against the OpenAI-shaped call. The first time you wanted to add Anthropic support, you'd probably reach for something like this:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
This works for two providers. Now imagine adding a third, a fourth, a fifth - every one of
them requires editing this same function, and the function grows a new elif branch every
time, each one mixing "how do I run the loop" with "how does provider X's API happen to be
shaped." That's the exact anti-pattern Chapter 1.5 warned about with deeply nested if/elif
chains standing in for what should be separate objects. The fix, as always, is to separate "the
thing that varies" (how each provider talks) from "the thing that doesn't" (the loop's logic).
BEFORE (one giant function, growing forever): AFTER (one small function per provider):
def run_agent(provider_name, messages): class LMStudioProvider: .chat(...)
if provider_name == "lmstudio": ... class OpenAIProvider: .chat(...)
elif provider_name == "openai": ... class AnthropicProvider: .chat(...)
elif provider_name == "anthropic": ... class MistralProvider: .chat(...) <- just add one more
elif provider_name == "mistral": ... run_agent(provider, messages) never changes
...7.2 The fix: one shared contract, many adapters
┌─────────────────────────────┐
│ BaseProvider (the contract) │
│ .chat(messages) -> str │
└─────────────────────────────┘
▲ ▲ ▲
│ │ │
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ LMStudio │ │ OpenAI │ │ Anthropic │
│ Provider │ │ Provider │ │ Provider │
│ (talks OpenAI-│ │ (talks OpenAI │ │ (translates to│
│ style API to │ │ cloud API) │ │ Anthropic's │
│ localhost) │ │ │ │ own shape) │
└──────────────┘ └──────────────┘ └──────────────┘Your run_agent function only ever talks to BaseProvider. It has no idea, and doesn't need
to care, which one is actually plugged in underneath - precisely the inheritance pattern from
Chapter 1.5, now doing real work.
This pattern has a name in software design: it's called the Adapter pattern. An adapter's entire job is to sit between "what my code wants to call" and "what the real thing actually looks like," translating one into the other. A physical power adapter is the same idea: your laptop charger wants a certain shape of plug; the wall socket offers a different shape; the adapter in between doesn't change electricity at all, it just changes the shape of the connection so the two sides can talk.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
ABC and @abstractmethod make this a formal contract: any subclass that forgets to
implement chat will error immediately, rather than failing mysteriously later.
To see this concretely, try defining a broken subclass that forgets to implement chat:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Python refuses to even construct BrokenProvider() - it raises a TypeError at the moment you
try to instantiate it, listing exactly which abstract method is missing. Compare that to what
would happen without ABC: you'd only discover the missing chat method the first time
run_agent tried to call it, possibly deep into a live session, possibly in production. ABC
turns a "fails at the worst possible time" bug into a "fails immediately and obviously" bug -
this is generally a trade worth making whenever you're designing a contract multiple classes
must satisfy.
Common mistake: thinking
ABCprevents incorrect implementations. It doesn't - it only guarantees the method exists. Python has no way to check that yourAnthropicProvider.chatactually returns something with the same shape asLMStudioProvider.chat. That discipline is on you; it's why the docstring onBaseProvider.chatdescribing exactly what it should return matters as much as the method signature itself.
7.3 Adapter 1 - LM Studio (you already wrote this in Chapter 5/6, now wrapped)
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Nothing here is new - this is the exact client.chat.completions.create(...) call from
Chapter 6.4, just moved inside a method with a name (chat) that matches the shared contract.
The whole trick of the Adapter pattern is often this anticlimactic: for the "easy" cases, the
adapter is barely more than a thin wrapper renaming things to match the contract. The value
shows up when a provider's API genuinely differs, which is exactly what Section 7.5 covers.
7.4 Adapter 2 - OpenAI cloud (near-identical shape, different base_url and a real key)
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Notice LMStudioProvider and OpenAIProvider are almost identical - because LM Studio was
designed to mimic OpenAI's shape (Chapter 5.3). The real translation work happens next.
It's worth noticing why nearly identical code still deserves two separate classes instead of
one class with an if is_local: ... branch inside it. Each class represents one clear
responsibility ("talk to this specific kind of server, with this specific kind of
authentication"), and if OpenAI's cloud API ever adds a feature LM Studio's local server doesn't
support (say, image inputs), you only touch OpenAIProvider, leaving LMStudioProvider
untouched and guaranteed not to break. Duplication that is this small is often a fine price to
pay for that isolation - a lesson worth remembering before reflexively "DRY-ing up" every few
lines of similar code.
7.5 Adapter 3 - Anthropic (here the shapes genuinely differ, so the adapter earns its keep)
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
INPUT (your uniform list) ADAPTER TRANSLATES OUTPUT (provider-native call)
[{"role":"system","content":"be terse"}, split system out, system="be terse"
{"role":"user","content":"hi"}] ──▶ keep rest as messages ──▶ messages=[{"role":"user","content":"hi"}]This is precisely why the abstraction exists: your agent's core logic (Chapter 6) never has to know that Anthropic's contract is shaped differently - the adapter absorbs that difference.
Let's trace through the translation loop by hand so the "why" is completely concrete. Suppose
messages coming from your Memory object looks like this:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Run this and you'll see the single system entry gets pulled out into its own variable, while
everything else stays in order inside chat_messages - exactly matching what
anthropic.Anthropic().messages.create(...) expects. This is the entire "why" of an adapter in
one runnable example: your code upstream never needs to know this reshaping happened.
Common mistake: assuming every provider's reply shape matches too. Notice
LMStudioProvider.chatreturnsresponse.choices[0].message(an object with a.contentattribute and possibly.tool_calls), whileAnthropicProvider.chatreturnsresponse.content[0].text(a plain string). If yourrun_agentloop assumes every provider returns the exact same object shape, it will break the moment you swap providers - Section 7.7 shows the smallisinstancecheck needed to handle this safely, and the note at the end of this chapter explains why tool-calling makes this gap even bigger.
7.6 Config-driven provider selection - the pyproject.toml idea, applied to YOUR project
Just like pyproject.toml declares what your project needs without hardcoding it into every
file, your agent should declare which provider to use in one config place, not scattered
through code.
pip install python-dotenv
# .env (never commit this file - see Chapter 2.5's .gitignore) PROVIDER=lmstudio LMSTUDIO_MODEL=your-model-id-here OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-...
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
change ONE line in .env: PROVIDER=lmstudio ─▶ PROVIDER=openai
│
▼
entire agent now runs against a different backend, zero code changesThis is the exact same "why" as package.json's dependencies or pyproject.toml's
dependencies list from Chapter 2: declare your choice in one config place, let the tooling
resolve it, instead of hardcoding it deep inside logic where it's easy to forget and hard to
change safely.
Notice build_provider still has an if/elif chain - and that's fine here, because this
function's entire job is exactly "given a name, produce the matching object." This is called
a factory function: a small, centralized place where the mapping from "identifier" to
"concrete object" lives, so that mapping only ever needs to be written once. The difference
between this and the anti-pattern from Section 7.1 is where the branching lives: here it's
isolated to one tiny function whose sole purpose is selection, not smeared throughout your
agent's actual logic.
FACTORY FUNCTION PATTERN: "lmstudio" ──▶ build_provider() ──▶ LMStudioProvider(...) "openai" ──▶ build_provider() ──▶ OpenAIProvider(...) "anthropic" ──▶ build_provider() ──▶ AnthropicProvider(...) the CALLER of build_provider() never needs to know these class names exist
You can test the factory in isolation, without any real API keys, by monkeypatching the environment:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Common mistake: committing a
.envfile with real API keys to version control. Chapter 2.5 already covered.gitignorefor exactly this reason - an API key leaked in a public GitHub history can rack up thousands of dollars of usage on your account within hours of a bot scraping it. Always double-check.envis listed in.gitignorebefore your first commit, not after.
7.7 Rewiring the Chapter 6 agent loop to use the abstraction
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
Swap .env's PROVIDER value, run again, and the exact same run_agent and Memory code
now talks to a completely different company's servers. That's the payoff of the whole chapter.
Look closely at getattr(message, "tool_calls", None) and
content = message if isinstance(message, str) else message.content. These two lines are doing
quiet, important work: they're the small seams where the loop tolerates the fact that
LMStudioProvider.chat returns an object with attributes, while AnthropicProvider.chat (as
written so far) returns a plain string. getattr(obj, "name", default) is a defensive way to
read an attribute that might not exist - instead of crashing with AttributeError when a plain
string has no .tool_calls, it just falls back to None, which the if correctly treats as
"no tool calls requested."
getattr(message, "tool_calls", None) applied to two different objects: message = <ChatCompletionMessage with .tool_calls=[...]> ──▶ returns [...] message = "4." (a plain Python string) ──▶ returns None (strings have no .tool_calls)
The Production Standard: A Unified ProviderResponse
Rather than relying on getattr or isinstance checks inside your agent loop, production systems define a standardized response dataclass. This ensures that every provider returns the exact same data contract:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
With this dataclass, every provider adapter takes responsibility for normalizing its raw API output before returning:
AnthropicProvidermapsblock.type == "tool_use"toToolCall(id=block.id, name=block.name, arguments=block.input).OpenAIProvider/GroqProvider/LMStudioProvidermapmessage.tool_callstoToolCall.- Both compute
latency_msand extractprompt_tokens/completion_tokens.
Handling Streaming Tool Call Deltas
When streaming tokens (stream=True), providers return tool arguments in fragments across multiple chunks (e.g., {"exp, ression":, "2+2"}). The adapter accumulates these string fragments by tool index:
Browser interpreter: a limited Python subset. Package, file and API examples may use simulated responses. Run the project locally for real integrations.
This ensures your agent loop stays 100% clean, never dealing with partial JSON parsing or vendor-specific response quirks.
Chapter 8 assembles everything from Chapters 1–7 into one real, runnable project folder.
Check yourself
- Explain, without looking back at the code, why a single
run_agentfunction that branches onif provider_name == "openai": ... elif ...is harder to maintain than theBaseProviderapproach, as the number of supported providers grows. - What specific error does Python raise if you try to instantiate a subclass of
BaseProviderthat never defineschat? When does that error happen - at class definition time, or at instantiation time? - Why does
AnthropicProvider.chatneed to separate thesystemmessage from the rest of the list before callingself.client.messages.create(...)? What would happen if you just passed the whole list, system message included, asmessages=? build_provider()still contains anif/elifchain. Why is that acceptable here when it was called an anti-pattern in Section 7.1's "before" example? What's structurally different?- Trace through
getattr(message, "tool_calls", None)for both a plain string and an object with a.tool_callsattribute. What does each call return, and why doesn't the string case raise an exception? - Sketch (in words or pseudocode) what a corrected
AnthropicProvider.chatwould need to do to return an object compatible with thetool_callshandling inrun_agent.