The AI Engineer's Toolkit: Python, uv, and a Sane Dev Setup
Set up a Python AI engineering environment with uv, proper API key hygiene, and the minimal stack to start calling frontier models without the usual friction.
The day I accidentally pushed an OpenAI API key to a public GitHub repo was educational. Eleven minutes later, an automated scanner had found it, spun up a batch job in a region I had never used, and started billing at $0.015 per 1K output tokens. By the time GitHub's secret scanning sent me an email and I rotated the key, the bill was non-trivial. Nothing in my code was wrong. The bug was in how I set up the environment.
This article is the setup step every other tutorial skips. It takes about 20 minutes to do correctly and will save you a recurring 2-3 hour tax every time you start a new project or onboard a teammate.
Why the environment step matters more than you think
AI engineering work involves credentials (API keys worth real money), fast-moving packages (the anthropic SDK released eight minor versions in 2025), and a tendency for "quick experiments" to turn into production code. Bad environment hygiene compounds: the notebook that started as a prototype becomes the thing generating embeddings for 50,000 users, still running on the version of openai you pip-installed globally six months ago, with the API key hardcoded in line 3.
Getting this right once means every project starts clean in ten minutes, your teammates can reproduce your environment exactly, and your keys are never at risk. Getting it wrong means you are debugging ImportError: cannot import name 'AsyncOpenAI' from 'openai' at 11pm before a demo.
Choosing your package manager: uv wins in 2026
The Python packaging ecosystem has too many options. Here is the honest state of each.
pip + venv is what you already know. It works. The problems: no lockfile by default (so pip install produces different results on different machines unless you pin manually with pip freeze > requirements.txt), no Python version management (you install the Python separately via homebrew or pyenv), and it is slow — installing a project with GPU dependencies and ML libraries can take 5-10 minutes.
poetry added lockfiles and pyproject.toml management in 2018 and was the right answer for a long time. In 2026 it has one major weakness: speed. Poetry's dependency resolver is still significantly slower than uv's, and the poetry install command on a cold cache can take 60-90 seconds for a moderately sized AI project.
conda is excellent for one specific use case: managing non-Python native binaries like CUDA toolkit, cuDNN, and NCCL alongside Python packages. If you are doing local GPU inference with PyTorch and need CUDA 12.4 pinned exactly, conda handles that dependency graph correctly. For pure API-calling AI engineering work — which is most of what this course covers — conda is overengineered and slower than the alternatives.
uv is a Rust-based package manager from Astral (the company behind ruff) that replaces pip, venv, pip-tools, and pyenv in a single binary. It installs packages 10-100x faster than pip by parallelizing downloads and using a global cache, manages Python versions natively, and produces a lockfile (uv.lock) by default. As of mid-2026 it is the fastest Python package manager available and has become the default recommendation from most of the AI engineering community.
Install it once, system-wide:
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"
Setting up a new AI engineering project
Ten minutes from scratch to a running API call:
# Create and initialise a new project (--package gives you the src/ layout
# and a build-system, so uv sync installs your code into the environment)
uv init --package my-ai-project
cd my-ai-project
# Pin Python version (pick 3.11 or 3.12)
uv python pin 3.12
# Add your baseline packages
uv add openai anthropic python-dotenv pydantic jupyterlab
# uv creates a virtual environment automatically and writes uv.lock
# Run anything inside the managed environment with uv run
uv run python -c "import openai; print(openai.__version__)"
The resulting pyproject.toml looks like this:
[project]
name = "my-ai-project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"openai>=1.35.0",
"anthropic>=0.30.0",
"python-dotenv>=1.0.0",
"pydantic>=2.7.0",
"jupyterlab>=4.2.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
The [build-system] table is what --package buys you: without it, uv sync treats the project as a loose collection of scripts and never installs my_ai_project into the environment — and import my_ai_project fails everywhere except the project root.
Commit pyproject.toml and uv.lock. Anyone who clones the repo runs uv sync and has the exact same environment in under 30 seconds.
API key hygiene: the non-negotiable part
The model providers charge by the token. If your key leaks, someone else runs your bill up. This is not hypothetical — automated scanners watch public GitHub repos in near-real-time.
Rule 1: keys in .env, never in code.
Create .env at the project root:
# .env
OPENAI_API_KEY=sk-proj-...
ANTHROPIC_API_KEY=sk-ant-...
Add .env to .gitignore immediately — before you add anything to it:
echo ".env" >> .gitignore
git add .gitignore
git commit -m "add .gitignore"
Load the .env file at the top of every script:
from dotenv import load_dotenv
import os
load_dotenv() # reads .env if it exists, does nothing if it doesn't
openai_key = os.environ["OPENAI_API_KEY"] # raises KeyError if missing — intentional
anthropic_key = os.environ["ANTHROPIC_API_KEY"]
Using os.environ["KEY"] (bracket syntax) rather than os.environ.get("KEY") is deliberate: it fails loudly if the key is missing, rather than passing None silently to an API constructor and producing a confusing auth error 10 lines later.
Rule 2: different keys for development and production.
Both OpenAI and Anthropic let you create multiple API keys. Use one key per environment (dev, staging, prod) and set usage limits on the dev key (OpenAI's dashboard lets you cap at $10/month for a personal dev key). If the dev key leaks, the blast radius is a $10 charge, not an uncapped production bill.
Rule 3: in production, no .env files.
In a production container or CI environment, inject keys as environment variables from your secret store — GitHub Actions secrets, AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault. The code that reads os.environ["OPENAI_API_KEY"] stays identical; only the source of the variable changes.
flowchart LR
DEV["Development\n.env file\n(git-ignored)"] -->|same code| APP["Application\nos.environ['OPENAI_API_KEY']"]
CI["CI / CD\nGitHub Actions secret"] -->|injected at runtime| APP
PROD["Production\nAWS Secrets Manager\nor GCP Secret Manager"] -->|injected at container start| APP
style APP fill:#0e7490,color:#fff
style DEV fill:#1e3a5f,color:#fff
style CI fill:#1e3a5f,color:#fff
style PROD fill:#1e3a5f,color:#fff
Rule 4: if a key is ever committed, rotate it immediately. Not after you finish your current work. Now. On public repos, GitHub's push protection will warn you before the push goes through; bypass it and scrapers find the key within minutes. Private repos only get push protection with paid Advanced Security — and while external scanners can't see them, treat a committed key as compromised anyway: contractors, CI logs, and a future flip to public all expose the git history. Rotate.
Your first API call
With the environment set up, the first call to each provider is straightforward:
# main.py
from dotenv import load_dotenv
from openai import OpenAI
from anthropic import Anthropic
load_dotenv()
# OpenAI
oai = OpenAI() # reads OPENAI_API_KEY from environment automatically
response = oai.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What is 2 + 2? Answer with just the number."}],
max_tokens=10,
)
print("OpenAI:", response.choices[0].message.content)
# Anthropic
ant = Anthropic() # reads ANTHROPIC_API_KEY from environment automatically
message = ant.messages.create(
model="claude-sonnet-4-5",
max_tokens=10,
messages=[{"role": "user", "content": "What is 2 + 2? Answer with just the number."}],
)
print("Anthropic:", message.content[0].text)
Run it:
uv run python main.py
# OpenAI: 4
# Anthropic: 4
Total cost of that script: about $0.000015. The max_tokens=10 cap prevents any accidental runaway generation during setup testing.
The minimal stack: five packages and why each one
openai — official Python SDK for GPT-4o, GPT-4o-mini, o3, and OpenAI embeddings
anthropic — official Python SDK for Claude Sonnet/Opus/Haiku 4.x
python-dotenv — loads .env into os.environ; one line at the top of every script
pydantic — data validation and structured output parsing; required by most AI frameworks
jupyterlab — notebooks for exploration and visualization
That's it for the first week. Some packages you will see in tutorials that you should not install yet:
langchain / langchain-openai: LangChain is a powerful orchestration framework, but it abstracts away the underlying API calls in ways that make debugging harder when you are still learning what those calls do. Once you have called the APIs directly twenty times and felt the friction of building your own retry logic, the abstractions will make sense. Before that, they obscure more than they help. See the context engineering article for when orchestration becomes genuinely useful.
tiktoken: Useful for counting tokens before sending a request. Add it when you start building applications where context length matters — not on day one.
torch / transformers: These are for running models locally. If you are calling the OpenAI or Anthropic APIs, you do not need PyTorch installed. Adding it pulls in gigabytes of dependencies and can complicate your environment significantly. Wait until you actually need local inference.
Notebooks vs scripts: the sharp line
Jupyter notebooks are the right tool for:
- Exploring a new API's response structure
- Visualising token distributions or embedding spaces
- Running a one-off evaluation against a small dataset
- Writing an interactive tutorial (like the ones on this site)
Python scripts are the right tool for:
- Anything that runs in CI
- Anything that runs on a schedule
- Anything another service calls
- Anything that produces outputs other systems depend on
The mistake I see constantly: a notebook that starts as "let me see what this embedding model returns" grows into "let me process all 10,000 documents" grows into "this runs nightly and feeds our search index." Notebooks have no good story for testing, logging, error handling, or deployment. The moment a notebook becomes something that needs to run reliably, rewrite it as a script.
flowchart TD
EXP["Exploration\n(notebook)"] -->|"Concept proven\nwants to run repeatedly"| SCRIPT["Refactor to script\n(proper module)"]
SCRIPT -->|"Needs to run in CI\nor production"| PIPELINE["Part of a pipeline\n(tested, logged, monitored)"]
EXP -->|"One-off analysis\nor visualization"| DONE["Done — stays a notebook\narchived with outputs"]
style EXP fill:#1e3a5f,color:#fff
style SCRIPT fill:#0e7490,color:#fff
style PIPELINE fill:#15803d,color:#fff
style DONE fill:#374151,color:#fff
Project structure that won't embarrass you in a month
Start simple:
my-ai-project/
├── pyproject.toml # dependencies, managed by uv
├── uv.lock # exact versions, committed to git
├── .env # API keys — git-ignored
├── .gitignore # includes .env
├── README.md
├── src/
│ └── my_ai_project/
│ ├── __init__.py
│ └── main.py # your application code
├── notebooks/
│ └── exploration.ipynb # experiments, never runs in CI
└── tests/
└── test_main.py
The src/ layout (putting your code under a subdirectory rather than at the project root) is a minor discipline that pays off immediately when you add tests. With a flat layout, import my_ai_project resolves to whatever folder sits in your working directory — even when the package was never installed — which silently masks packaging mistakes. The src/ layout forces imports to go through the installed package, so your tests exercise what anyone who runs uv sync actually gets.
The Makefile below expects pytest and ruff, which are development tools, not runtime dependencies — add them to uv's dev group so they install for developers but stay out of production:
uv add --dev pytest ruff
A Makefile at the root keeps the commands muscle-memory consistent:
.PHONY: install test lint run
install:
uv sync
test:
uv run pytest tests/
lint:
uv run ruff check src/ tests/
run:
uv run python -m my_ai_project.main
New teammate? They run make install and then make run. Done.
Worked cost estimate: what the minimal stack actually costs to run
Setup assumptions:
- OpenAI gpt-4o-mini: $0.15/M input tokens, $0.60/M output tokens (illustrative, mid-2026)
- Anthropic claude-haiku-4: $0.25/M input tokens, $1.25/M output tokens (illustrative, mid-2026)
- Typical beginner exploration: 100 API calls/day, avg 500 input + 200 output tokens each
Daily token usage:
Input: 100 calls × 500 tokens = 50,000 input tokens = 0.05M tokens
Output: 100 calls × 200 tokens = 20,000 output tokens = 0.02M tokens
Daily cost (gpt-4o-mini):
Input: 0.05M × $0.15 = $0.0075
Output: 0.02M × $0.60 = $0.0120
Total: ~$0.02/day
Monthly cost at this rate: ~$0.60/month
At 10× scale (1,000 calls/day, 1,000 avg tokens):
Monthly cost: ~$18-30/month depending on model
→ Set a $10 soft limit on your dev key to catch accidental loops.
A while True: client.chat.completions.create(...) with no break,
at ~1 small call/second, burns roughly $0.70/hour at gpt-4o-mini
pricing — left running overnight, it eats most of a $10 cap.
Swap in a frontier model with a large max_tokens and the same
loop hits the cap in under an hour.
These numbers illustrate why API key hygiene matters even for personal projects. A key leak to an attacker who uses it for a large batch job can run up hundreds of dollars in minutes — not because your key has high limits, but because they will run until they hit the limit you set (or, worse, if you forget to set one).
What breaks: the failure modes this setup prevents
Version drift. Without a lockfile, uv sync / pip install on a new machine may install different minor versions. The anthropic SDK changed the response structure between 0.20 and 0.30. If one developer is on 0.20 and another is on 0.30, message.content[0].text works on one machine and raises AttributeError on the other. uv.lock prevents this.
Global package pollution. Installing into the global Python environment (the one that runs your OS tooling on some Linux distros) causes conflicts that are extremely hard to debug. pip install openai globally on Ubuntu can break system tools that depend on a different requests version. uv's virtual environment is isolated by default.
Key commit. If you add your API key to main.py and commit it before adding .gitignore, GitHub will flag it — but the key is already in the git history. git log exposes it to anyone with repo access. Rotation is the only fix. Adding .gitignore and .env before writing any code prevents this.
Python version mismatch. Pydantic v2 requires Python 3.8+; some anthropic SDK features require 3.10+; the AI framework ecosystem largely targets 3.11+. Without a pinned version in pyproject.toml, a colleague on 3.9 will install the package but get subtle runtime errors on features that require newer syntax. uv python pin 3.12 pins it once, for everyone.
The "works in notebook, fails in production" trap. A notebook that calls load_dotenv() in cell 3 but accesses os.environ["OPENAI_API_KEY"] in cell 1 works fine if you run cells in order in your session — but fails the first time someone runs it on a fresh kernel. Scripts with a single load_dotenv() at the top, tested with uv run, don't have this problem.
Moving from setup to actual work
Once this environment is working, the next things to understand are what you are actually sending to the model and why it responds the way it does. The tokenization article explains how your text becomes the integer sequences the model sees, including why token counts matter for both cost and behavior. The context window article covers what happens to everything you send — and what happens when you send too much.
For the big picture of where this toolkit fits in the AI engineer's actual job, the AI Engineer role article is worth reading next. Understanding what you are building toward — RAG pipelines, evaluation harnesses, agent orchestration — makes the environment choices here less arbitrary and more obviously right.
The model landscape article covers which models to reach for once you have this baseline working. The short version: start with gpt-4o-mini or claude-haiku-4 for exploration (cheap, fast, good enough to validate ideas), then move to a frontier model (gpt-4o, claude-sonnet-4-5) when you need quality. The environment you just set up calls both identically.
The decision in practice
uv, .env, and five packages is the right starting point for 95% of AI engineering projects in 2026. The exceptions are narrow: if your project needs GPU inference locally, add conda or a separate CUDA management step. If your team already has a mature Poetry setup, the migration friction may not be worth it yet. If you are working in a corporate environment where package sources are restricted, you may need to point uv at an internal mirror with UV_INDEX_URL.
Everything else — the choice between LangChain and direct SDK calls, notebooks vs scripts, which frontier model to use — those are decisions you will make better after you have a working baseline. The setup is not the point. But it is the gate.
Pricing figures are illustrative and based on publicly available rates as of mid-2026. Model provider pricing changes frequently — verify current rates in the provider's official documentation before estimating costs for a real project.
Frequently asked questions
▸Should I use uv, Poetry, or conda for AI engineering in 2026?
uv is the right default for almost every AI engineering project in 2026. It installs packages 10-100x faster than pip, handles virtual environments and lockfiles in a single tool, and has no dependency on Rust toolchains or conda channels. Use conda only if you need non-Python native binaries (CUDA toolkit, cuDNN) that are not yet available via wheels; use Poetry if your team has already standardised on it and migration friction outweighs the speed gains.
▸How should I store API keys for OpenAI and Anthropic in development?
Store API keys in a .env file at the project root, load them with python-dotenv or uv run --env-file .env, and add .env to your .gitignore immediately. Never hardcode keys in source files or pass them as CLI arguments (they appear in shell history). For CI/CD, inject keys as encrypted environment variables in your pipeline tool (GitHub Actions secrets, for example). Rotate keys immediately if one is ever committed to a repo.
▸What is the minimal Python package set to start AI engineering?
For calling closed APIs: openai and anthropic. For loading environment variables: python-dotenv. For structured outputs and data validation: pydantic. For Jupyter notebooks: jupyterlab. That is four to five packages — resist the urge to install LangChain or LlamaIndex until you actually need orchestration; the abstractions add complexity before you understand what they are abstracting.
▸When should I use Jupyter notebooks vs Python scripts for AI engineering work?
Use notebooks for exploration, one-off evaluations, and anything where you want to see intermediate outputs inline — token distributions, embedding visualizations, sample model responses. Use scripts (with a proper module structure) for anything that runs in CI, production, or as part of a pipeline. The anti-pattern is using a single sprawling notebook as both the exploration environment and the production code — that notebook will become unmaintainable within weeks.
▸Does Python version matter for AI engineering?
Yes. Use Python 3.11 or 3.12. The openai and anthropic SDKs and pydantic v2 mostly support 3.9+, but the AI framework ecosystem (LangGraph, LlamaIndex) effectively targets 3.11+. Python 3.12 gives measurable interpreter speed gains (roughly 25% faster than 3.10 on compute-bound code) and is the version most CI images default to in 2026. Avoid 3.13+ for now — several GPU-adjacent packages (PyTorch, some CUDA wrappers) have not fully caught up.
▸How do I structure a Python project for AI engineering work?
Start with a flat layout: src/ for source code, tests/ for tests, a pyproject.toml at the root managed by uv, and a .env file for secrets. Resist the temptation to create deep package hierarchies early — most AI projects pivot significantly in the first month. Add a Makefile with targets for install, lint, test, and run to keep the onboarding story simple for teammates.
You may also like
Sandboxing AI Agents: Containers, gVisor, and MicroVMs
Sandboxing AI agents with containers, gVisor, Firecracker microVMs, and Wasm: honest boot and overhead numbers, and why egress filtering matters most.
Voice AI latency budget: hitting sub-500ms in production
A systematic breakdown of every millisecond in the voice AI pipeline and the specific techniques that compound to sub-500ms time-to-first-audio in production.
AI Compliance in Practice: EU AI Act, SOC 2, and HIPAA
Risk tiers, documentation duties, audit trails, and health-data rules for teams shipping LLMs under the EU AI Act, SOC 2, and HIPAA in 2026.