Module 0 · Setup
Setup and the Course Repo
This module doesn't ship a feature — it builds the infrastructure every later module stands on. The folder layout, the .env convention, and above all shared/llm_client.py (one function that calls any provider and logs every call) aren't throwaway setup steps; they're the shared architecture that Module 1's cost meter, Module 4's budget alerts, and every project after that will import and extend instead of rebuilding. Get this module right and the rest of the course gets easier, not harder.
Everything below assumes Python 3.11+ and a terminal. Unless a step says otherwise, run every command from the ai-beyond-fundamentals/ repo root you create in Lab 1, with its venv activated — that's what makes relative paths like chatbot/three_providers.py resolve correctly. Work through the concepts once, then do the labs in order — each one builds on the last.
| Step | Approx. time |
|---|---|
| Lab 1 · Scaffold the repo (includes creating three API keys) | 1 to 2 hours |
| Lab 2 · Three providers, one prompt | 30 to 60 min |
| Lab 3 · Run a model locally | about 18 to 36 min |
| Lab 4 · Build the logger | about 13 to 26 min |
| Project · One client, four providers (with the README) | about 17 to 34 min |
| Module total | about 2 to 4 hours |
The low end is a timed first run through the module with Python and Ollama already installed. The high end doubles it for a first-time learner who has to install tools, create accounts and read the code as they go.
Concepts
Keep API keys in environment files, never in code
A provider API key is a bearer credential: whoever holds the string can spend money on your account. If it's typed directly into a .py file, it ships the moment that file is committed to git — including to a public GitHub repo, a screen share, or a zip you email someone. Leaked keys get scraped by bots within minutes and run up real charges.
The fix is a .env file at the repo root holding NAME=value pairs, loaded at runtime by a library like python-dotenv. The file never gets committed — it's listed in .gitignore before it ever holds a real key. Collaborators instead get a .env.example with the same variable names and blank or placeholder values, committed normally, so they know what to fill in without ever seeing yours.
Python environments with venv and pip, in a monorepo layout
venv is Python's built-in tool for creating an isolated environment so this project's packages don't collide with anything else installed on your machine. pip installs into it.
If you write anthropic in a requirements file with no version attached, pip install grabs whatever the newest release happens to be on the day it runs — today that might work, three months from now a new major version could change the SDK's method names and break every lab that imports it, with no code change on your end to explain why. "Pinning" means locking each package to the exact version number you tested with, so installing never silently changes what you're running.
Neither venv nor pip tracks those exact versions for you automatically (this is what uv or poetry would do instead). You do it by hand with pip freeze, which inspects everything currently installed in the active environment and prints each package with an exact == version:
$ pip freeze
anthropic==2.3.0
openai==3.37.0
google-genai==2.33.0
python-dotenv==1.2.4
...Redirecting that output into requirements.txt and committing the file turns it into the pin list. From then on, pip install -r requirements.txt installs exactly those versions, so your setup, your collaborator's, and the one this checklist describes all run identical code.
Prefer uv or poetry? This course uses venv and pip because they ship with Python and need nothing extra installed. uv and poetry are popular alternatives that create the environment, install packages and record exact versions in a lockfile for you. If you already use one, it works for every lab: translate the install commands and skip the manual pip freeze step.
A monorepo here just means one repository, one pyproject.toml, and one lockfile for every project this course builds — the chatbot, the extraction tool, the RAG app, the eval suite, the agent, and the RAG-vs-fine-tune comparison — each in its own folder, plus a shared/ folder for code every project reuses. One environment to install instead of seven, and one place to fix a dependency problem instead of seven.
A shared client wrapper that logs every call
Every lab from here on calls a model. If each lab calls the provider SDK directly, cost and latency tracking has to be rebuilt seven times — or, more likely, never gets built at all. Instead, this module produces one function, call(prompt, model), that every later lab imports from shared/. It hides which provider you're talking to and silently appends a row — model, tokens in, tokens out, milliseconds, dollars — to a shared log file on every call.
That log file is what makes Module 1's cost meter, Module 4's budget alerts, and Module 4's regression tests possible without extra plumbing. You're building the instrument panel before you need to read it.
Hands-on Labs
- Create the repo and one virtual environment at the root — every project folder below shares it, so you activate it once per terminal session, not once per folder.
mkdir ai-beyond-fundamentals cd ai-beyond-fundamentalsmacOS / Linuxpython3 -m venv .venv source .venv/bin/activateWindows (PowerShell)python -m venv .venv .venv\Scripts\Activate.ps1What does
python3 -m venv .venvdo?It creates an isolated Python environment inside a folder named
.venv. Breaking the command down:python3runs your system's Python interpreter.-m venvtells Python to run a standard-library module as a program. The module isvenv, which ships with Python, so there is nothing to install..venvis the folder where the new environment goes. The leading dot hides it in Finder and in a plainls; that is just convention.
What ends up inside
.venv/:bin/python, a private link to the interpreter. This is what runs once you activate.bin/pip, an installer that puts packages in.venv/lib/.../site-packagesinstead of your system Python.bin/activate, the scriptsource .venv/bin/activateruns. It puts.venv/binfirst on yourPATH, so typingpythonorpipnow means the private ones.
Why bother: packages installed here never touch your system Python or other projects, other projects can't break this one by upgrading a shared package, and the pinned versions in
requirements.txtstay exactly what you tested with.Creating the venv does not activate it. Run
source .venv/bin/activatein every new terminal. You'll see(.venv)at the start of your prompt when it's on. Check withwhich python: the path should end in.venv/bin/python, not/usr/bin/python3..venvis listed in.gitignorebecause it is large and machine-specific; anyone else rebuilds it fromrequirements.txt. - Build the project skeleton. Deliberately no
shared/__init__.pyhere — every file insideshared/imports its siblings with bare names likefrom llm_log import log_call, which only resolves ifshared/itself sits directly onsys.path. Adding__init__.pyturnssharedinto a real package, which changes howpytestresolves its imports in Lab 4 and breaks exactly that.macOS / Linuxmkdir -p shared chatbot extraction rag evals agent ragvsfinetuneWindows (PowerShell)mkdir shared, chatbot, extraction, rag, evals, agent, ragvsfinetuneWhy does macOS use
mkdir -pbut PowerShell doesn't?They are two different programs that happen to share a name.
macOS / Linux:
mkdir -pmkdiris a small program from the Unix toolset, and it takes flags.- By default it makes one folder at a time, and it errors if the parent is missing or the folder already exists.
-p(“parents”) changes that in two ways: it creates any missing parent folders, and it does not error if the folder already exists.-pis also what makes listing several folders in one command work, as this step does.
Windows PowerShell:
mkdir shared, chatbot, ...- In PowerShell,
mkdiris a built-in function that wrapsNew-Item -ItemType Directory. - That command already creates missing parents and already tolerates existing folders, so there is nothing for a
-pflag to turn on. - PowerShell takes multiple names as a comma-separated list, because it passes them as a list of values rather than as separate words.
One more difference: PowerShell commands usually take full-word parameters like
-Pathor-Force, not single-letter Unix flags. Typingmkdir -pin PowerShell would likely fail or be read as an abbreviated parameter name. - Install the packages every later module needs, then freeze the exact versions so the repo pins what you tested with.
pip install anthropic openai google-genai python-dotenv pyyaml pytest pip freeze > requirements.txt - Now three separate files, all at the repo root (
ai-beyond-fundamentals/, next to.venv). Start with.gitignore, so the next two are already covered by it the moment they exist.macOS / Linuxcat > .gitignore <<'EOF' .env .venv __pycache__/ *.log *.jsonl EOFWindows (PowerShell)@' .env .venv __pycache__/ *.log *.jsonl '@ | Set-Content .gitignore .env.example— the committed template, values left blank, so a collaborator knows which keys to set without ever seeing yours.macOS / Linuxcat > .env.example <<'EOF' ANTHROPIC_API_KEY= OPENAI_API_KEY= GEMINI_API_KEY= EOFWindows (PowerShell)@' ANTHROPIC_API_KEY= OPENAI_API_KEY= GEMINI_API_KEY= '@ | Set-Content .env.example- Get a key from each provider's dashboard before you can fill in
.env. Each needs its own account; Anthropic and OpenAI also require billing to be set up before a new key will actually work.- Anthropic — console.anthropic.com › Settings › API Keys, then "Create Key".
- OpenAI — platform.openai.com/api-keys, then "Create new secret key".
- Gemini — aistudio.google.com/apikey (Google AI Studio), then "Create API key". No billing needed to start on the free tier.
Each key is shown to you once. Copy it somewhere safe immediately — if you lose it, the dashboard can only let you revoke it and create a new one, not show it to you again.
.env— your real keys. Copy the template, then open the copy directly from the terminal — a filename starting with a dot is hidden by default in Finder and File Explorer, so double-clicking to find it in a file browser often won't work.macOS / Linuxcp .env.example .env code .env # VS Code # or: nano .env (terminal editor, Ctrl+O to save, Ctrl+X to exit) # or: open .env (macOS — opens in whatever app handles plain text)Windows (PowerShell)Paste each key in after itsCopy-Item .env.example .env code .env # VS Code # or: notepad .env=, one per line, no quotes and no spaces around the=, so it reads:Save the file. It's never committed —ANTHROPIC_API_KEY=sk-ant-api03-... OPENAI_API_KEY=sk-proj-... GEMINI_API_KEY=AIza....gitignorealready excludes it.- Commit the skeleton.
git init git add . git commit -m "Scaffold course repo"
git status shows .env as ignored, not untracked. If it shows up as untracked, fix .gitignore before continuing — don't just avoid committing it this once.Model names below can go stale within weeks — providers rename, deprecate and retire models on their own schedule, not this lesson's. Writing this exact lab, we hit both a deprecated Claude model and a Gemini model that 404'd for new accounts. Check each provider's current model list first (Anthropic, OpenAI, Gemini) — or skip the docs and ask the SDK directly, the trick that found this lesson's working Gemini model after gemini-2.5-flash 404'd on us:
from google import genai
client = genai.Client(api_key="...")
for m in client.models.list():
print(m.name)If a model name anywhere in this module throws a 404 or a deprecation warning, swap in whatever's current and keep going — nothing in the lesson's logic depends on the exact name.
- Create a new file at
chatbot/three_providers.py(from the repo root:code chatbot/three_providers.pyfor VS Code,nano chatbot/three_providers.pyfor a terminal editor, ortouch chatbot/three_providers.pyon macOS/Linux /New-Item chatbot\three_providers.pyon Windows then open it in your editor —codeandnanoboth create the file themselves, notouchneeded first). Paste in one function per provider. Each prints the model's actual reply — not just its usage — so a wrong or empty answer is visible immediately instead of hiding behind a token count that looks fine on its own.import json import os from datetime import datetime from dotenv import load_dotenv load_dotenv() PROMPT = "Explain what a token is, in three sentences." def call_anthropic(): from anthropic import Anthropic client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) resp = client.messages.create( model="claude-haiku-5-5", max_tokens=200, thinking={"type": "disabled"}, # otherwise Haiku can spend the whole token budget thinking and return no text messages=[{"role": "user", "content": PROMPT}], ) # a "thinking" response can put a ThinkingBlock before the text block, # so pick the block that actually is text rather than assuming index 0 text = next(b.text for b in resp.content if b.type == "text") return {"text": text, "usage": resp.usage.model_dump(mode="json", exclude_none=True)} def call_openai(): from openai import OpenAI client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) resp = client.chat.completions.create( model="gpt-5.4-mini", messages=[{"role": "user", "content": PROMPT}], ) return { "text": resp.choices[0].message.content, "usage": resp.usage.model_dump(mode="json", exclude_none=True), } def call_gemini(): from google import genai from google.genai import types client = genai.Client( api_key=os.environ["GEMINI_API_KEY"], # milliseconds; without a timeout the SDK keeps retrying an overloaded model http_options=types.HttpOptions(timeout=20000), ) resp = client.models.generate_content(model="gemini-flash-lite-latest", contents=PROMPT) return { "text": resp.text, "usage": resp.usage_metadata.model_dump(mode="json", exclude_none=True), } if __name__ == "__main__": results = {} for name, call in [("anthropic", call_anthropic), ("openai", call_openai), ("gemini", call_gemini)]: try: results[name] = call() except Exception as e: # one provider failing (a 503, say) shouldn't lose the other two answers results[name] = {"error": f"{type(e).__name__}: {e}"} output = json.dumps(results, indent=2) print(output) filename = f"lab2-output-{datetime.now():%Y%m%d-%H%M%S}.json" # a new file every run with open(filename, "w") as f: f.write(output + "\n") print("saved to", filename) - Run it. The script prints the results as JSON and also saves them to a new timestamped file such as
lab2-output-20261025-125400.json(every run keeps its own file, so earlier results are never overwritten), so you can open a file in your editor (VS Code folds and colors JSON) or compare runs. Read all three answers, then the threeusageobjects. If a provider fails, its entry shows anerrorinstead of the answer, and the other two still come through.If Gemini returns apython chatbot/three_providers.py503("high demand") or a504(deadline exceeded), or the run seems stuck, that's the model being overloaded, not your code. The timeout turns a stuck call into anerrorentry in the JSON; rerun after a few seconds. We usegemini-flash-lite-latestbecause the larger flash models were overloaded when we wrote this lab and the lite model answered in under a second. Swap the name if a model is busy. - Each SDK names its fields differently. At the bottom of
chatbot/three_providers.py, add a comment recording all three shapes exactly as they appear in the JSON — you'll be back in this file for Lab 4, writing the code that normalizes them into one shape.# usage field names, from the lab2-output-*.json file: # anthropic: input_tokens / output_tokens # openai: prompt_tokens / completion_tokens # gemini: prompt_token_count / candidates_token_count
401 means the key name in .env doesn't match what the code reads, or the key is stale: load_dotenv() does not override a key already exported in your shell, so a terminal window opened before you rotated a key keeps using the old one. Open a fresh terminal window (or run source ~/.zshrc) and retry; a 429 means you're rate-limited — wait a minute and retry.- Install Ollama.
macOS, via Homebrew
brew install ollama ollama serve # leave running in its own terminalLinuxcurl -fsSL https://ollama.com/install.sh | sh ollama serve # leave running in its own terminalWindows# Download the installer from ollama.com/download and run it — # it installs Ollama and starts it running in the background automatically. - Pull a small model and sanity-check it from the command line.
llama3.2downloads about 2 GB (it defaults to the 3B variant); check you have that much free disk space first, or pull the smallerllama3.2:1btag instead (about 1.3 GB) if you're tight on room. This can be run from any directory — Ollama stores pulled models once in your home directory (~/.ollamaon macOS/Linux,%USERPROFILE%\.ollamaon Windows), not inside the project repo, so it doesn't matter where your terminal'scwdis for this step.ollama pull llama3.2 ollama run llama3.2 "Explain what a token is, in three sentences." - Back in
chatbot/three_providers.py, add a fourth function,call_ollama(), alongside the three from Lab 2 — same SDK ascall_openai, pointed at a differentbase_urlso it talks to your local Ollama server instead of OpenAI's. Add("ollama", call_ollama)to the list in the__main__loop so it lands in the output file with the other three. It returnsusageasNoneif your local server sends none, instead of crashing.def call_ollama(): from openai import OpenAI local = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") # key is unused but required by the SDK resp = local.chat.completions.create( model="llama3.2", # or whichever model `ollama list` shows on your machine messages=[{"role": "user", "content": PROMPT}], ) usage = resp.usage.model_dump(mode="json", exclude_none=True) if resp.usage else None return {"text": resp.choices[0].message.content, "usage": usage}
resp.usage comes back populated; some local setups return None, which the logger in Lab 4 needs to handle without crashing.- Create a new file at
shared/llm_log.py(code shared/llm_log.py,nano shared/llm_log.py, ortouch shared/llm_log.py/New-Item shared\llm_log.pythen open it) and paste in:import json, time, pathlib LOG_PATH = pathlib.Path(__file__).parent / "calls.jsonl" def log_call(provider, model, input_tokens, output_tokens, elapsed_ms, cost_usd): row = { "ts": time.time(), "provider": provider, "model": model, "input_tokens": input_tokens or 0, "output_tokens": output_tokens or 0, "elapsed_ms": round(elapsed_ms, 1), "cost_usd": round(cost_usd, 6), } with LOG_PATH.open("a") as f: f.write(json.dumps(row) + "\n") - Open
chatbot/three_providers.pyagain. Becauselog_calllives inshared/, a sibling folder, add these two lines near the top of the file (below your other imports) so Python can find it:Then change each of the four functions the same way: start a timer, call the SDK, normalize that provider's usage object into plain ints using the field names from your Lab 2 comment, and call# top of chatbot/three_providers.py, added in Lab 4 import sys, pathlib, time sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "shared")) from llm_log import log_calllog_call(...)with a flatcost_usd=0for now (the project below replaces that with a real number). Keep the existingreturn— logging is in addition to saving the answer to the JSON file, not instead of it. Here'scall_anthropicbefore and after — apply the same change tocall_openai,call_geminiandcall_ollama, each with its own field names:# before (Lab 2) def call_anthropic(): from anthropic import Anthropic client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) resp = client.messages.create( model="claude-haiku-5-5", max_tokens=200, thinking={"type": "disabled"}, # otherwise Haiku can spend the whole token budget thinking and return no text messages=[{"role": "user", "content": PROMPT}], ) text = next(b.text for b in resp.content if b.type == "text") return {"text": text, "usage": resp.usage.model_dump(mode="json", exclude_none=True)} # after (Lab 4) def call_anthropic(): from anthropic import Anthropic client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) start = time.time() resp = client.messages.create( model="claude-haiku-5-5", max_tokens=200, thinking={"type": "disabled"}, # otherwise Haiku can spend the whole token budget thinking and return no text messages=[{"role": "user", "content": PROMPT}], ) elapsed_ms = (time.time() - start) * 1000 text = next(b.text for b in resp.content if b.type == "text") log_call("anthropic", "claude-haiku-5-5", resp.usage.input_tokens, resp.usage.output_tokens, elapsed_ms, cost_usd=0) return {"text": text, "usage": resp.usage.model_dump(mode="json", exclude_none=True)}call_ollamaneeds one extra guard. Ollama's OpenAI-compatible endpoint can returnresp.usageasNoneinstead of a real usage object (unlike OpenAI itself, which always fills it in) — and readingresp.usage.prompt_tokensstraight off aNonecrashes withAttributeError: 'NoneType' object has no attribute 'prompt_tokens'. Your Lab 3 code already guards themodel_dumpcall for this reason; the logging needs the same guard. Checkresp.usagebefore reading a field off it, and fall back to0when it's missing.X if condition else Yis Python's inline if/else — it does that check in one line instead of a separateifblock:# call_ollama, with the Lab 4 timer/logging added def call_ollama(): from openai import OpenAI local = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") start = time.time() resp = local.chat.completions.create( model="qwen3-vl:4b-instruct", # the model you pulled in Lab 3 messages=[{"role": "user", "content": PROMPT}], ) elapsed_ms = (time.time() - start) * 1000 input_tokens = resp.usage.prompt_tokens if resp.usage else 0 output_tokens = resp.usage.completion_tokens if resp.usage else 0 log_call("ollama", "qwen3-vl:4b-instruct", input_tokens, output_tokens, elapsed_ms, cost_usd=0) usage = resp.usage.model_dump(mode="json", exclude_none=True) if resp.usage else None return {"text": resp.choices[0].message.content, "usage": usage} - Create
shared/test_logger.pythe same way, and paste in a checkpoint test.# shared/test_logger.py import json from llm_log import LOG_PATH def test_all_providers_logged(): rows = [json.loads(line) for line in LOG_PATH.read_text().splitlines()] providers = {row["provider"] for row in rows} assert providers == {"anthropic", "openai", "gemini", "ollama"} - Run everything and then the test.
python chatbot/three_providers.py pytest shared/test_logger.py
cat shared/calls.jsonl shows four lines, one per provider, and pytest passes.Project
Combine the four labs into one file, shared/llm_client.py: a single call(prompt, model) function that looks up the provider for a given model name in a config file, dispatches to the right SDK, times the call, computes real cost, and logs it through log_call from Lab 4. The five steps below build it piece by piece.
- Create
shared/models.yaml(code shared/models.yaml,nano shared/models.yaml, ortouch shared/models.yaml/New-Item shared\models.yamlthen open it) and paste in one entry per model. These prices are what the providers' pricing pages listed in October 2026 — prices change, so check each page (Anthropic, OpenAI, Gemini) before trusting them.Use whatever model name you actually pulled in Lab 3 and have been calling since — if you pulled something other than# shared/models.yaml claude-haiku-5-5: provider: anthropic price_in: 0.10 # $ per 1M input tokens (prompts up to 100k tokens) price_out: 0.50 # $ per 1M output tokens gpt-5.4-mini: provider: openai price_in: 0.75 price_out: 4.50 gemini-flash-lite-latest: provider: gemini price_in: 0.30 # the page lists Gemini 3.5 Flash-Lite at these rates; the alias target is not stated price_out: 2.50 # Gemini's output price includes thinking tokens qwen3-vl:4b-instruct: provider: ollama price_in: 0 # runs on your machine: no per-token charge price_out: 0qwen3-vl:4b-instructthere, use that same name here and in every file below, not the name printed in this lesson. - Create
shared/llm_client.pythe same way, and paste in the client itself.# shared/llm_client.py import os, time, random, pathlib, yaml from llm_log import log_call CONFIG = yaml.safe_load((pathlib.Path(__file__).parent / "models.yaml").read_text()) RATE_LIMIT_ERRORS = () # fill in with each SDK's RateLimitError class def call(prompt, model, max_attempts=5): cfg = CONFIG[model] start = time.time() for attempt in range(max_attempts): try: text, usage = _dispatch(cfg["provider"], model, prompt) break except RATE_LIMIT_ERRORS: if attempt == max_attempts - 1: raise time.sleep(2 ** attempt + random.random()) elapsed_ms = (time.time() - start) * 1000 cost = (usage["input_tokens"] / 1_000_000 * cfg["price_in"] + usage["output_tokens"] / 1_000_000 * cfg["price_out"]) log_call(cfg["provider"], model, usage["input_tokens"], usage["output_tokens"], elapsed_ms, cost) return text def _dispatch(provider, model, prompt): # one branch per provider, reusing the call_* functions from Lab 2 & 3 — # each must return (text, {"input_tokens": int, "output_tokens": int}) ... - Fill in
_dispatch. Copy the logic from the fourcall_*functions inchatbot/three_providers.py(Labs 2 & 3) into oneif provider == "..."branch each — you can leave the originals inthree_providers.pyalone, they still work as a standalone reference. Two changes on the way in: drop the timer/log_call/print lines you added in Lab 4 (this file'scall()already times and logs centrally, so keeping both would log every call twice), and return the actual reply text alongside a plain usage dict instead of printing.# shared/llm_client.py — _dispatch, filled in def _dispatch(provider, model, prompt): if provider == "anthropic": from anthropic import Anthropic client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) resp = client.messages.create( model=model, max_tokens=200, thinking={"type": "disabled"}, messages=[{"role": "user", "content": prompt}], ) # same ThinkingBlock gotcha as Lab 2 — don't assume index 0 is the text block text = next(b.text for b in resp.content if b.type == "text") return text, { "input_tokens": resp.usage.input_tokens, "output_tokens": resp.usage.output_tokens, } if provider == "openai": from openai import OpenAI client = OpenAI(api_key=os.environ["OPENAI_API_KEY"]) resp = client.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], ) return resp.choices[0].message.content, { "input_tokens": resp.usage.prompt_tokens, "output_tokens": resp.usage.completion_tokens, } if provider == "gemini": from google import genai from google.genai import types client = genai.Client( api_key=os.environ["GEMINI_API_KEY"], http_options=types.HttpOptions(timeout=20000), # milliseconds, as in Lab 2 ) resp = client.models.generate_content(model=model, contents=prompt) return resp.text, { "input_tokens": resp.usage_metadata.prompt_token_count, "output_tokens": resp.usage_metadata.candidates_token_count, } if provider == "ollama": from openai import OpenAI local = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") resp = local.chat.completions.create( model=model, messages=[{"role": "user", "content": prompt}], ) usage = resp.usage return resp.choices[0].message.content, { "input_tokens": usage.prompt_tokens if usage else 0, "output_tokens": usage.completion_tokens if usage else 0, } raise ValueError(f"Unknown provider: {provider}") - Fill in
RATE_LIMIT_ERRORSat the top of the file, aboveCONFIG, with the real rate-limit exception class from each SDK.The agent prompt below can do this step for you if you'd rather not hunt through each SDK's docs for the exact class names.# shared/llm_client.py — add near the top, replacing the placeholder from anthropic import RateLimitError as AnthropicRateLimitError from openai import RateLimitError as OpenAIRateLimitError from google.genai.errors import ClientError as GeminiClientError RATE_LIMIT_ERRORS = (AnthropicRateLimitError, OpenAIRateLimitError, GeminiClientError) # GeminiClientError covers other 4xx errors too (bad request, auth), not # just 429 — check e.code == 429 inside the except block if you want to be # precise. Good enough to make the retry loop fire for this exercise. - Test it end to end. Write a small test script,
shared/try_client.py, that callscall(prompt, model)once for each of the four models.call()returns the reply instead of printing it — that's the point, so other code can use the answer instead of just displaying it — so this script's ownprint(...)is the only place any of these four answers actually reaches your terminal. If you want to see what a provider is really responding with, this is where you look; nothing upstream in_dispatchprints anything.# shared/try_client.py — a quick smoke test: one call to each model from dotenv import load_dotenv load_dotenv() from llm_client import call for model in ["claude-haiku-5-5", "gpt-5.4-mini", "gemini-flash-lite-latest", "qwen3-vl:4b-instruct"]: print(model, "->", call("Give me five different greetings, one per line.", model))Read the four printed replies first — five greetings back from each model, not an error string or an empty reply — then confirmpython shared/try_client.py cat shared/calls.jsonlcalls.jsonlhas four new rows with a real, non-zerocost_usdfor every model exceptllama3.2, which should show0.
You just hand-built a small version of what LiteLLM is as a library: one call() across 100+ providers, with cost tracking, retries and logging already built in. A real production codebase would likely reach for LiteLLM (or something like it) instead of this file. You built it by hand here so that choice is a shortcut once you understand what it's hiding, not a black box you're trusting blind.
Done when:
- Swapping providers means changing one line in
models.yaml, not code. pytest shared/test_logger.pypasses, showing a log row for all four providers.- Optional: a simulated 429 (raise the error manually in a test) triggers a retry with increasing delay, and a 6th consecutive failure still raises.
- A README in the repo root explains the folder layout well enough that Module 1 can start from it without asking you a question.