Session 2. Call a model through the adapter — Tue 15 Sep

The provider seam

One method, and everything else is yours

class LLMClient(Protocol):
    def complete(self, system: str, user: str) -> str:
        """Return the model's text for one system+user exchange."""
        ...

That is the whole seam, in src/bootcamp_agent/llm.py. Two strings in, one string out. system says how to behave, user carries the task, the return value is text you have not validated yet.

Every agent framework wraps this one call. They give it different names and add retries, streaming, tool schemas and callbacks on top. None of them removes it, because it is the only step in your program that a vendor runs. Write your application against the seam and the framework becomes a choice you can defer. Write it against an SDK and the SDK is in every module by Friday.

Calling it

from bootcamp_agent.llm import FakeLLM

hello_llm = FakeLLM(
    responses={
        "hello": "Hello! I am a deterministic stand-in for a language model.",
        "agent": "An agent is a loop around a model: perceive, decide, act, observe.",
    },
    default="I have no canned answer for that — a real model would improvise here.",
)

print(hello_llm.complete(system="You are concise.", user="Say hello to the bootcamp"))
Hello! I am a deterministic stand-in for a language model.

FakeLLM matches the first key found in user, case-insensitively, in insertion order. No match returns default. It also appends every (system, user) pair to self.calls, so a test can assert what your code asked, not only what it did with the answer.

Question contains Answer
agent the agent canned line
hello the hello canned line
neither the default

Three paths, three calls: that is exercise 1.

A Protocol, not a base class

LLMClient is a typing.Protocol. Nothing inherits from it. A class is an LLMClient because it has a complete(system, user) -> str, and for nothing else.

Client Runs where Needs a key Deterministic
FakeLLM in your process, no model no yes
OllamaClient your machine, over HTTP no no
AnthropicClient the vendor's API yes no
OpenAICompatibleClient OpenAI, or any compatible endpoint yes no

Four classes, one method each. Application code never learns which one it got. That is the property the rest of the course leans on: session 5's agent takes an LLMClient argument, and its tests pass a fake.

The SDK import is lazy, on purpose

class AnthropicClient:
    def __init__(self, api_key: str, model: str) -> None:
        import anthropic          # inside __init__, not at module top

        self._client = anthropic.Anthropic(api_key=api_key)

The import happens when you construct that client and never before. So import bootcamp_agent.llm works on a machine with no provider package installed, which is every machine in this room and the CI runner. A missing SDK becomes an ImportError at the moment you asked for that lane, with uv sync --extra anthropic as the fix.

Configuration is data

@dataclass(frozen=True)
class Settings:
    provider: str
    model: str | None
    api_key: str | None
    base_url: str | None

load_settings() reads the environment (and .env) into those four fields. get_client(settings) turns them into a client. Everything provider-specific in this course lives in those two functions: the name of the key variable, the default model per provider, the base URL for an OpenAI-compatible endpoint.

Read get_client once. It is thirty lines and it is the whole adapter:

Your code asks for a client and gets one. It never reads an environment variable, never holds a key, and never names a vendor.