Topic 2.8
One Interface, Many Providers
In one line
Give your code one tiny ChatModel interface and put every real client, fake and recorder behind it. Features then never learn which provider they use, tests need no key, and switching providers becomes a configuration change.
Think of it like this
A wall socket. Your lamp, laptop and kettle all have the same plug shape, and none of them knows or cares whether the power comes from coal, solar or a generator. If the power company changes, nothing in your house is rewired. The socket is the interface; the power stations are the providers.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Interface
- The agreed list of methods a component offers. Anything that provides them can be swapped in.
- Adapter
- A class that makes one provider's API fit your interface.
- Fake
- A stand-in implementation with scripted behaviour, used in tests and demos.
- Decorator (wrapper)
- A class with the same interface as the one it wraps, adding behaviour such as logging or retries.
- Factory
- A function that builds the right implementation from configuration.
- Dependency injection
- Passing the model into a function instead of creating it inside, so callers choose which one.
- Test double
- Any stand-in used instead of a real dependency in a test: fake, stub, recorder.
Step by step
01Step 1: write the feature against the interface
make_title(model, note) takes any object with complete. It builds the messages and returns the text, with "Untitled" when the reply is blank. It contains no URLs, keys or provider names.
02Step 2: write the fake
The fake pops a scripted reply. With it the feature is tested without a network. The second config returns " " (blank), which exercises the Untitled branch; a real model rarely does that on demand.
03Step 3: write the real-shaped client
HttpModel builds the JSON body from Topic 2.1, sends it through a transport function and reads choices[0]. In the example the transport is a fake server, so it runs offline; in production it is a real HTTP call (Topic 2.9 shows one).
04Step 4: wrap with a recorder
Recorder forwards the call and remembers the number of messages and the reply text. Tests use it to assert what the feature sent. The same idea, extended, becomes logging and metrics.
05Step 5: choose by configuration
make_model turns a config dictionary into the right object, optionally wrapped in a recorder. The loop at the bottom tries four configs: two fakes, the HTTP-shaped client, and an invalid provider that fails with a clear message.
06Step 6: keep the interface small
Resist adding every provider option to complete. Add what the features need, put provider-specific settings in the adapter's constructor or config, and add new methods only when a second feature needs them.
Try it yourself
- 1
Predict the output
Before running, predict the four result lines and whether each Recorder line appears. Which configs have
recordturned on? - 2
Add a provider
Add a provider
"echo"whose model returns the user's note unchanged. Where do you add it, and do you need to touchmake_title? - 3
Test the cut-off case
Add a fake reply
""(an empty string) as a new config. What does the feature return, and why is that better than returning an empty title?
Code & diagrams
The HTTP-shaped client talks to a fake transport, so the example is offline. The pattern is the same one the real client in Topic 2.9 uses.
Expected output
fake -> Rain Expected In Pune
recorded 1 call(s), 2 messages in the first
fake -> Untitled
http -> Title: Rain is expected in Pune this week
recorded 1 call(s), 2 messages in the first
satellite -> config error: unknown provider: satelliteBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Default silently on a bad config
In the Python make_model, replace the final else: raise ValueError(...) branch with else: model = FakeModel(["?"]), so an unknown provider quietly gets a fake, then run the example again.
Myth vs fact
Myth
An interface is over-engineering for a small project.
Fact
It is ten lines, and it makes tests free and provider changes cheap. Add it from the first call.
Myth
With the same interface, any provider gives the same results.
Fact
The code is swappable, the model is not: answers, formats and failure modes differ, so re-run your evaluations after a switch (Phase 9).
Myth
A framework's abstraction replaces my own interface.
Fact
Frameworks change quickly. A thin interface of your own keeps framework types out of your features, whichever library you use underneath.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Define capabilities explicitly (
supports_tools,max_context_tokens) on the adapter, so features can ask before using them and routers (Topic 12.6) can pick a model by need. - ▸
Contract-test every adapter with the same test suite: same inputs, same expected reply shape, same error classes. A new provider then passes or fails the suite instead of surprising you in production.
- ▸
Record real replies once (a cassette or snapshot file) and replay them in tests. This keeps tests realistic without cost, and the recording exposes shape changes when you refresh it.
Remember this
- 1
Why this exists. Without an interface,
requests.post("https://api...")and provider field names are scattered through your code. Switching provider means editing dozens of places; testing means hitting the network and spending money; adding a retry or a log means touching every call. With one narrow interface, all of that changes in one place. - 2
The interface. Keep it as small as the feature needs:
complete(messages, max_tokens) -> reply, where the reply hastextandfinish_reason(add token counts when you need cost, Topic 2.9). Your own types, not the provider's: the SDK's response classes stay inside the adapter. Streaming and tools can be added later as separate methods or richer replies (Phases 5 and 7). - 3
Many implementations. A fake returns scripted replies, so tests are free, instant and can simulate cut-off, empty and failing replies (Topic 0.7). A real client (
HttpModel) builds the request, sends it and parses the reply. A recorder wraps any model and remembers each request, so a test can check the exact messages that were sent. The example runs all three behind the samecomplete. - 4
Decorators compose. Because every wrapper has the same interface as what it wraps, you can stack them:
Logged(Retrying(Cached(Real))). Each does one job (logging, retries, caching) and can be tested alone. The recorder in the example is the simplest decorator; Topic 2.9 adds a logger and cost meter, and later phases add retries, caches and fallbacks the same way. - 5
Switching by configuration. A small factory (
make_model) reads a config value (an environment variable or a file) and returns the right implementation. Running the app withprovider=fakegives a demo with no key;provider=httpcalls the real service. An unknown provider fails at start-up with a clear message, not at the first user request. Note the example'ssatelliteconfig. - 6
What the interface cannot hide. Providers differ in features (structured output, tool format, caching, context size) and in quality, so a swap still needs evaluation (Phase 9). Keep the interface to what is common and add optional capabilities explicitly. Do not leak provider names into the feature code, and do not make the interface so wide that every adapter must fake what it cannot do.
Explain it without notes
What problem does a ChatModel interface solve?
How do fakes, recorders and real clients fit together?
What can't an interface hide when switching providers?
Practice
Write a class Counter that wraps any model and counts how many times complete was called (calls attribute), returning the wrapped model's reply.
Write make_model(config) that returns a FakeModel for {"provider": "fake"} and raises ValueError("unknown provider: ...") otherwise.
Trade-offs
- ↔
A narrow interface is easy to implement and test, but hides provider features until you extend it.
- ↔
A wide interface exposes more, but every adapter must support or fake each part.
- ↔
Wrappers keep each concern small, at the cost of a deeper call stack to read when debugging.
Done when you can
Done when your feature code contains no provider names, URLs or keys.
Done when you can run the feature against a fake in a test.
Done when you can add logging or a recorder without changing the feature.
Done when you can switch providers with a configuration change, and failing config stops start-up.