Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 62 of 899 min

Inheritance and composition: swapping one model provider for another

The problem it solves

Module 6 left you with a piece of advice: keep the model call in one function so that switching providers changes one place. Here is the shape that advice grows into when a program is large enough to need it.

Your program wants to say "ask the model this" and get text back. It does not want to know whether the model is Anthropic's, OpenAI's, or a free one running under Ollama. Those differ in URL, headers, request shape and response shape, and in nothing that the rest of the program cares about.

A base class that states the interface

python
class ChatProvider:
    def complete(self, messages, max_tokens=500):
        raise NotImplementedError

class AnthropicProvider(ChatProvider):
    def __init__(self, model):
        self.client = anthropic.Anthropic()
        self.model = model

    def complete(self, messages, max_tokens=500):
        system = next((m["content"] for m in messages if m["role"] == "system"), None)
        rest = [m for m in messages if m["role"] != "system"]
        r = self.client.messages.create(model=self.model, max_tokens=max_tokens,
                                        system=system, messages=rest)
        return r.content[0].text

class OpenAICompatibleProvider(ChatProvider):
    def __init__(self, model, base_url=None, api_key=None):
        self.client = openai.OpenAI(base_url=base_url, api_key=api_key)
        self.model = model

    def complete(self, messages, max_tokens=500):
        r = self.client.chat.completions.create(model=self.model, messages=messages,
                                                max_tokens=max_tokens)
        return r.choices[0].message.content

class AnthropicProvider(ChatProvider) says: this is a kind of ChatProvider. It inherits everything the base has and overrides complete with its own. The base's complete raises, so a subclass that forgets to override it fails loudly the first time it is called rather than silently returning None.

The differences — Anthropic wants the system prompt as a separate argument, and returns a list of content blocks — are absorbed inside each subclass. The caller sees one method:

python
provider = OpenAICompatibleProvider("qwen2.5:0.5b", base_url="http://localhost:11434/v1", api_key="x")
reply = provider.complete([{"role": "user", "content": "Hi"}])

Swap the first line and nothing else changes. That is the whole payoff.

super() and isinstance

When a subclass needs the parent's version too, super() reaches it:

python
class LoggingProvider(OpenAICompatibleProvider):
    def complete(self, messages, max_tokens=500):
        log.info("asking %s with %d messages", self.model, len(messages))
        return super().complete(messages, max_tokens)

isinstance(provider, ChatProvider) is true for every subclass, which is what lets a function accept "any provider" and check it got one.

Where inheritance lies

The textbook example is a square that inherits from a rectangle. A square is a rectangle, so it must be fine. Then someone calls set_width(5) on a square and its height silently changes too, and code written for rectangles is now wrong for one of them.

The rule that survives contact with real programs: inherit only when the subclass can be used everywhere the parent can, with no surprises. That is a much stricter condition than "shares some code". Two classes that share code but differ in what they promise should not be parent and child; they should share a helper function or a common base that states only the promise.

In the example above, OllamaProvider(OpenAICompatibleProvider) would be tempting — the request format is the same. But Ollama does not return the cached-token fields, does not enforce max_tokens the same way, and has no billing. The day the parent grows a method that assumes one of those, the child breaks. The safer structure is what the example already has: both are siblings under ChatProvider, and if two siblings want to share the request-building code, they call the same module-level function.

Composition: the object that has a provider

The rest of the program should not inherit from a provider either. It should hold one:

python
class Assistant:
    def __init__(self, provider: ChatProvider, system: str):
        self.provider = provider
        self.conversation = Conversation(system=system)

    def ask(self, text):
        self.conversation.add("user", text)
        reply = self.provider.complete(self.conversation.messages)
        self.conversation.add("assistant", reply)
        return reply

Assistant has a provider and has a conversation. It is not a kind of either. You can hand it a fake provider in a test that returns canned text, a logging wrapper in development, or a real one in production, and Assistant is unchanged. This is the same move as module 4's "hand the call in", now with an object instead of a function.

One interface, several providers, one object that holds oneYour programAsks one thing: assistant.say(question)AssistantHAS a provider and HAS a conversation — compositionChatProviderOne method: complete(messages, system) -> strAnthropicProvider · OpenAIProvider · OllamaProviderInherit the interface, absorb each service's shapeFakeProviderReturns a canned string, so tests need no networkand no moneyInheritance is used once, for the interface, because every provider genuinely can be used wherever aChatProvider can. Everything else is composition: Assistant has a provider and has a conversation,which is what lets you swap a fake one in a test or fall back to a cheaper model when the budget isnearly spent.
One interface, several providers, oneobject that holds oneYour programAsks one thing: assistant.say(question)AssistantHAS a provider and HAS a conversation —compositionChatProviderOne method: complete(messages, system) -> strAnthropicProvider · OpenAIProvider ·OllamaProviderInherit the interface, absorb each service'sshapeFakeProviderReturns a canned string, so tests need nonetwork and no moneyInheritance is used once, for the interface, becauseevery provider genuinely can be used wherever aChatProvider can. Everything else is composition:Assistant has a provider and has a conversation,which is what lets you swap a fake one in a test orfall back to a cheaper model when the budget isnearly spent.

Composition also lets you change the provider at runtime — fall back to a cheaper model when the budget is nearly spent — which a subclass fixed at definition time cannot do.

Duck typing, and when the base class is optional

Python does not require the base class. Any object with a complete method that takes those arguments will work in Assistant, whether or not it inherits from ChatProvider. That is duck typing, and it is why a test can pass in a two-line class. The base class earns its place by documenting the interface in one spot, by giving isinstance something to check, and by making a forgotten method raise NotImplementedError at the right line. typing.Protocol, covered later in this module, gives the same documentation without requiring inheritance at all.

Try this now

Write ChatProvider, one real subclass against Ollama, and a FakeProvider whose complete returns "ok: " + messages[-1]["content"]. Build Assistant around each and confirm the Assistant code does not change. Then try LoggingProvider with super().

The one thing to keep

Put the one thing that differs between providers behind a small base class with a single method, and give the rest of the program an object that has a provider rather than is one — inheritance for the interface, composition for everything else.

Before you move on

A developer writes `class OllamaChat(OpenAIChat)` because the two providers share a request format, overriding only `base_url`. Later the OpenAI class gains a method that reads `usage.prompt_tokens_details` for cached-token pricing, which Ollama's responses do not include, and every Ollama call now raises `AttributeError`. What went wrong structurally?

Pick the one you would defend. Nobody sees your answer.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly