Skip to content

Lesson 4 — switch providers without touching the application

What you will build

Proof that the previous three lessons were not ceremony. One route, two providers, and a handler that does not know which one answered — the reason to put a facade in front of a model at all.

What you need

Nothing new.

examples/learn_ai/ai04_switch.py
"""Lesson 4 — switch providers without touching the application.

Run it with:

    python -m learn_ai.ai04_switch
"""

from __future__ import annotations

from typing import Annotated

from starlette.requests import Request

from xyberos import App, Inject, Settings
from xyberos.ai import AIFacade, ChatRequest, FakeModelProvider, Message, ModelReply
from xyberos.http import HttpAdapter, read_json_object
from xyberos.http.testing import create_test_client

SYSTEM_PROMPT = "You are a concise support assistant. Answer in one sentence."


def create_app(*, provider: object) -> App:
    """Compose the route once. The provider is the only thing that changes between runs."""
    app = App(settings=Settings.load(environ={}, overrides={"app": {"name": "ai_assistant"}}))
    app.provide(AIFacade, instance=AIFacade(provider), eager=True)

    @app.post("/ask")
    async def ask(request: Request, assistant: Annotated[AIFacade, Inject()]) -> dict[str, object]:
        payload = await read_json_object(request)
        question = str(payload.get("question", ""))
        response = await assistant.chat(
            ChatRequest(messages=(Message.system(SYSTEM_PROMPT), Message.user(question)))
        )
        return {
            "provider": assistant.name,
            "model": assistant.model,
            "answer": response.message.content,
        }

    return app


def answer(provider: object, question: str) -> dict[str, object]:
    """Send one question through the application's own route."""
    with create_test_client(HttpAdapter(create_app(provider=provider))) as client:
        return dict(client.post("/ask", json={"question": question}).json())


def main() -> None:
    """The same route, two providers, no change to the handler."""
    first = FakeModelProvider([ModelReply.ok("Short answer.")], name="provider-a", model="model-a")
    second = FakeModelProvider(
        [ModelReply.ok("A longer answer, from a different provider.")],
        name="provider-b",
        model="model-b",
    )
    for provider in (first, second):
        print(answer(provider, "Will it rain?"))


if __name__ == "__main__":
    main()

The handler asks for AIFacade and nothing else. The provider is decided where the application is composed, so switching from a local model to a hosted one is a change to one function, not to the code that builds prompts.

How to run it

$env:PYTHONPATH = "src;examples"
python -m learn_ai.ai04_switch
PYTHONPATH=src:examples python -m learn_ai.ai04_switch

What it prints

{'provider': 'provider-a', 'model': 'model-a', 'answer': 'Short answer.'}
{'provider': 'provider-b', 'model': 'model-b', 'answer': 'A longer answer, from a different provider.'}

Two lines with the same shape and different providers: provider-a/model-a and provider-b/model-b. The answer text differs because the providers were scripted to differ, which is the point of scripting them — a test asserts the route, not a model's prose.

What the seam buys, and what it costs

It buys It costs
one place to change when a vendor changes one abstraction to learn
a deterministic provider for tests and CI capabilities that differ per provider must be declared (lesson 2)
a single place to attach logging, redaction, and usage accounting a facade that is one layer between you and a vendor's newest feature
provider selection as configuration rather than as code the temptation to hide model-specific prompts inside the facade

The last row of that table is the honest cost. A facade earns its place when an application has more than one reason to call a model; it stops earning it if it grows a prompt for every vendor quirk.

Common mistakes

  • Naming a vendor in the application layer. If a service imports OllamaSettings, the seam is decoration: the swap still requires editing the service.
  • Putting provider selection in the handler. Reading an environment variable inside a route makes the choice untestable; compose the provider, then inject the facade.
  • Assuming behaviour is identical across providers. The contract is identical; tokenisation, refusals, and tool support are not. That is what the capability declaration is for.
  • Hiding prompts in the facade. A prompt is application policy. The facade sends messages it is given; it does not invent them.

Next

Lesson 5 asks a model for a shape instead of a sentence, and shows what the runtime refuses to accept.