Try this first

Here is the same request, sent to Anthropic instead. Read it — you do not have to run it.

import anthropic

client = anthropic.Anthropic()

response = client.messages.create(
    model="claude-opus-5",
    max_tokens=1000,
    messages=[{"role": "user", "content": "Say hello in one short sentence."}],
)

print(response)

Same request. Same idea. Now compare what comes back with Lesson 1.2:

OpenAI and Ollama Anthropic
The reply lives in choices[0].message.content — a string content — a list of blocks
Why it stopped finish_reason stop_reason
It wants a tool when finish_reason == "tool_calls" stop_reason == "tool_use"
Tool arguments arrive as a JSON string you must parse a dict, already parsed
Results go back as one message per result, role: "tool" all results in one user message

Gemini differs again: replies are “parts”, the assistant is called model, and tool results
are function_response objects.

What you just did

You found the seam.

These are not different ideas. Every provider does the same three things — describes tools,
signals that it wants one, and accepts the result back. They just spell it differently.

So the differences are exactly three:

  1. How you describe a tool.
  2. How you spot a tool request in the reply.
  3. How you send the result back.

That list is not a summary. It is a specification. Anything that handles those three things
can hide every provider behind one interface, and everything else you write can stop caring.

The adapter

That is llm.py. The full version is in the course repository; here is its whole shape:

@dataclass
class ToolCall:
    id: str
    name: str
    arguments: dict          # always a dict, whoever sent it


@dataclass
class Reply:
    text: str
    tool_calls: list[ToolCall]
    stop: str                # the provider's own word, kept so you can see it
    usage: dict
    raw: object              # the untouched provider response

    @property
    def wants_tool(self):
        return bool(self.tool_calls)


def connect(spec):
    """connect("ollama:gemma4") / "openai:gpt-5" /
       "anthropic:claude-opus-5" / "gemini:gemini-2.5-flash" """

Each provider class does three small jobs — translate the tools going out, translate the
messages going out, translate the reply coming back. That is the entire file.

Use it like this:

from llm import connect

llm = connect("ollama:gemma4")          # the only line that names a vendor
reply = llm.send(messages, TOOLS)

Keep your own transcript

One decision inside the adapter matters enough to state plainly, because Lesson 1.6 depends
on it: the conversation stays yours, in one neutral shape:

{"role": "user",         "content": "..."}
{"role": "assistant",    "content": "...", "tool_calls": [ToolCall, ...]}
{"role": "tool_results", "results": [{"id": ..., "content": ..., "is_error": False}]}

The adapter translates that into the provider’s shape on every send. It never keeps a copy.
You still own the entire memory of your agent, which is the point of Lesson 1.6 and the reason
Module 6 is possible at all.

Do not let the adapter hide the mechanism

An abstraction is useful and it is also a place to stop thinking. Two habits keep that from
happening:

reply.stop keeps the provider’s own word. When your loop stops unexpectedly, print it.
"tool_calls", "tool_use", "length", "stop" — these are real values from real
providers and you should recognise them.

reply.raw is the untouched response. Any time you wonder what really came back, look at
it. The adapter is eighty lines you can read, not a wall.

Providers differ in exactly three places: describing a tool, spotting a tool request, and
sending the result back. Everything else about an agent is the same everywhere.

Try this before the next lesson

Get llm.py from the course repository and run the Lesson 1.2 request through it:

from llm import connect
llm = connect("ollama:gemma4")
reply = llm.send([{"role": "user", "content": "Say hello in one short sentence."}])
print(reply.text, "|", reply.stop, "|", reply.usage)

Then open llm.py and read the class for the provider you are using. It is about twenty-five
lines. You should be able to point at all three translations.