Hands-on workshop
Local LLMs
From ollama run to a gateway that fails over to the cloud - with a spending cap.
Step 1 · Basics
Step 2 · Abstractions
opencode the client
:4000/v1
LiteLLM group: fast
Ollama local · tried first
Claude or OpenAI failover · capped $2/day
Welcome. Two steps today, both hands-on. By the end you'll have run a model on your own laptop, then wrapped it in the same kind of gateway we run in production. Housekeeping: there's a clinic at the end for whatever broke. If you fall behind, keep going from the web page, don't interrupt mid-exercise. Keys: arrows / space to move, N for notes, O for overview, F for fullscreen.
Why
Why run a model on your laptop?
Private The prompt never leaves the machine.
Free at the margin No per-token bill for the boring tasks.
Offline Planes, cafés, bad hotel Wi-Fi.
In your control You pick the model, the version, the context.
Not a Claude replacement. It's for the boring 90%: commit messages, renames, scaffolding, log triage. A different point on the cost-quality curve.
Why workshop.caxaria.com 2 / 29
Don't oversell. Local models aren't frontier models and won't be for hard problems. The argument isn't "replace Claude" - it's stop paying cloud prices and latency for tasks a 9B model does fine: commit messages, renames, test scaffolding, regex, docstrings, log triage. The last line is the hook for the end of the day: the thing you build in Step 2 is the shape of a real production setup.
Today
Two steps. One extra layer.
Step 1
opencode
Ollama local
Step 2
opencode
LiteLLM fast
Ollama local
Cloud failover
start Intro
1 Step 1 · Basics
2 Step 2 · Abstractions
end Wrap-up
after Clinic
Today workshop.caxaria.com 3 / 29
Show the target once. Left to right: Step 1 is the toy that works. Step 2 adds one layer - the gateway - and the same client now survives an outage. Say explicitly: we finish self-contained. We never touch the team's shared gateway today. Stragglers go to the clinic at the end so the room never stalls on one machine.
How today works
Three ground rules
1
Follow on the page Every command has a copy button and an expect line. Pick your model at the top.
workshop.caxaria.com
2
Two terminals Terminal A runs Ollama. Leave it open.
Terminal B everything else.
3
Checkpoints Self-checking commands. Nobody walks past a ✓ alone. Stuck? Troubleshooting first, then a raised hand - or the clinic.
How today works workshop.caxaria.com 4 / 29
Three ground rules, said out loud. 1) Everything is on the page - they copy from it, not from the slides. Have them open it now and pick their model. 2) Two terminals: A runs Ollama and stays open; B is for everything else. This matters later, when we kill Ollama on purpose. 3) Checkpoints: nobody moves past one alone. If you're stuck, check the troubleshooting section, then raise a hand.
1
Step 1
Basics
You end with a model answering on your own machine, driven by a coding agent.
install → serve → pull → talk to it → opencode
Reset attention: this is the easy half. If Step 1 fights someone, it's almost always RAM, a port already in use, or a stale version - all in the troubleshooting section.
Step 1 1.3 · choose
Pick one. You can switch later.
qwen3.5:4b
8 GB RAM ~3.5 GB download Plenty for boilerplate
qwen3.5:9b
16 GB RAM or more ~6 to 7 GB download Best quality / speed balance
⚠ Context isn't free: Ollama reserves memory for the whole window up front.
Step 1 · Pick a model workshop.caxaria.com 6 / 29
Let them commit: 4B or 9B. "You can change later, it's one command" removes the anxiety. The examples on the slides use the 4B; the page has a 4B and a 9B tab on every command, both ready to copy. One thing to remember: context is reserved, not allocated lazily. That is why a 9B that "should fit" falls over when someone sets a huge context window. The next slide does the maths. While they pull the model (next slides), don't lecture. Let it download.
Step 1 1.3 · maths
Will it fit? Count the bits.
weights ≈ params × bits ÷ 8
precision GB per 1B 9B 4B
16-bit ~2.0 ~18 ~8
8-bit ~1.0 ~9 ~4
4-bit ~0.55 ~5 ~2.5
…then add KV cache : grows with context, reserved up frontOverhead : runtime + your OS
Budget: weights + KV cache + overhead ≤ ¾ of RAM . The ¾ is headroom for your Mac, not a shortcut around the bits.
ollama ps shows what it really takes
Step 1 · Will it fit? workshop.caxaria.com 7 / 29
Someone will ask "is three quarters of RAM enough?". Answer: it is a budget for the TOTAL, not a way to size the weights. The weights are parameters times bits divided by eight. Same 9B model: about 18 GB at 16 bits, 9 at 8 bits, about 5 at 4 bits. Ollama's default tags are 4-bit, which is why a 9B runs on a 16 GB laptop at all. Then add the KV cache (grows with context, reserved up front) and runtime/OS overhead. Budget: weights + KV cache + overhead <= about 3/4 of RAM; the last quarter is for the rest of your Mac. Don't guess: ollama ps prints what the loaded model really takes. The download sizes on the previous slide are larger than the table because they include extra files.
Step 1 1.1 to 1.3
Install, serve, pull
1 · install brew install ollamaollama --version
2 · Terminal A OLLAMA_CONTEXT_LENGTH=32768 ollama serve # keep this tab open
3 · Terminal B ollama pull qwen3.5:4b # or :9b ollama list
It's a daemon ollama serve ≈ dockerd. It keeps running - so it gets its own tab.
a version, then your model in ollama list
Step 1 · Install workshop.caxaria.com 8 / 29
Walk this live, together. Anyone with the menu-bar app or a brew service already running will hit "port in use" - the serve command's comment tells them to stop it first. Analogy to plant: ollama serve ≈ dockerd. It's a daemon, not a one-shot command, which is why it gets a terminal of its own. If the pull is slow, they start it and read the side-quest slides while it runs.
Exercise
Say hello - over HTTP
terminal B curl localhost:11434/v1/chat/completions \ -d '{"model": "qwen3.5:4b", "messages": [{"role": "user", "content": "Say hi in 5 words"}]}'
Also try ollama run qwen3.5:4b for the chat version. (9B? swap the tag.)
response { "choices": [{ "message": { "role": "assistant", "content": "Hello there, how are you? " } }], …
This shape is the whole reason Step 2 is possible. opencode speaks it. So will the gateway.
Step 1 · Exercise workshop.caxaria.com 9 / 29
The important line is the last one. Ollama speaks the OpenAI chat-completions dialect, so every tool that talks to OpenAI can talk to your laptop with one URL change. Plant it now; it pays off in Step 2. Have them run the curl, not just the interactive prompt - the JSON shape is what they'll reason about all day. Point at choices[0].message.content. Also: first answer is slow because the model is loading into RAM.
Side-quest
Ollama is Docker for models
Docker Ollama
docker pull nginx ollama pull qwen3.5:4b
docker run nginx ollama run qwen3.5:4b
docker ps ollama ps
Dockerfile Modelfile
Docker Hub ollama.com/library
Same muscle memory. Different payload. A model isn't a process you kill - it's weights in RAM , evicted when idle.
Step 1 · Side-quest workshop.caxaria.com 10 / 29
Do it live: docker pull nginx in one pane, ollama pull qwen3.5:4b in the other. The room recognises the output shape immediately - the analogy sticks because they saw it. The one real difference: a model isn't a process you run and kill. It's weights loaded into RAM and evicted when idle. That's why "why is my Mac using 12 GB" happens.
Side-quest Mac only
Faster on a Mac: oMLX
Ollama llama.cpp underneath. General-purpose. Works in 5 minutes.
oMLX Native Apple-Silicon server, custom Metal kernels . Faster - but you build the kernel for your Mac.
Same OpenAI-compatible API → it drops in exactly where Ollama sits. Homework.
The catch Mac only, and a fussier setup. Ollama is the right default today.
Step 1 · Side-quest workshop.caxaria.com 11 / 29
This is the "there's more than one way" segment. Be honest about the trade: oMLX is faster and fussier. On Apple Silicon it uses native Metal kernels; on some versions you have to compile the kernel for your Mac (the presenter did this once). The key point for later: it serves the same OpenAI-compatible API, so it slots in where Ollama sits and the gateway wouldn't notice. Keep it qualitative: no benchmark numbers on this slide. If someone asks how much faster, say you'll show them after, on your own machine.
Step 1 1.5 to 1.6
The whole integration is one baseURL
curl -fsSL https://opencode.ai/install | bash
~/.config/opencode/opencode.json { "provider": { "ollama": { "npm": "@ai-sdk/openai-compatible", "options": { "baseURL": "http://localhost:11434/v1 ", "apiKey": "ollama" }, "models": { "qwen3.5:4b": { … } } }}, "model": "ollama/qwen3.5:4b " }
baseURL - the URL you curledapiKey - dummy; Ollama doesn't checkmodel - ollama/ + a key under models
⚠ A name mismatch is the #1 setup mistake.
Step 1 · opencode workshop.caxaria.com 12 / 29
Point at baseURL and say: "remember the curl? This is that URL." The config isn't magic - it's the endpoint they already called. Common failure: they picked the 4B but left 9B in one of the two places. The model line must match a key under models, with the provider prefix. The page generates this file with a heredoc using their chosen model, and backs up any existing config.
Exercise
Ask for something beautiful. Then unplug.
Open opencode in a scratch project
Ask: “show me a beautiful piece of code in Elixir”
Turn off Wi-Fi. Ask: “now show me the same thing in TypeScript”
It still works. That's the point. A model on your laptop, answering with the network off.
Now push it Ask for a multi-file refactor. Watch it go subtly wrong: the honest limit, and the reason for Step 2.
Step 1 · Exercise workshop.caxaria.com 13 / 29
The Wi-Fi trick is the emotional payoff of Step 1: it turns "I have a local model" from a fact into a feeling. Do it yourself on the projector. Prompt one: "show me a beautiful piece of code in Elixir". Let the room read it. Then Wi-Fi off, prompt two: "now show me the same thing in TypeScript". Same laptop, no network, still answering. Then let them feel the limits: a multi-file refactor goes subtly wrong. That's the honest end of the spectrum, and it sets up "so we need a cloud failover" for Step 2.
Checkpoint · Step 1
Everyone together
ollama list shows your modelThe curl returns choices[0]… You got Elixir, then TypeScript with Wi-Fi off
If it doesn't
port in use stop the app / brew service
model not found fix both name spots
config ignored restart opencode
Before Step 2: Docker Desktop running (whale icon), and an API key from Anthropic or OpenAI . We don't hand out keys: no key, pair up.
Step 1 · Checkpoint workshop.caxaria.com 14 / 29
Actually stop and wait. Walk the room. This is the highest-value few minutes of the morning: every later failure is easier to debug from a green checkpoint. If someone's ollama list is empty their pull is still running or failed midway - re-run it, it resumes. Leave the gotchas table up while they sort Docker.
2
Step 2
Abstractions
You end with a gateway: one endpoint, a fast group that is local first with a capped cloud failover, and a client that never notices.
docker → config → gateway → failover → cap → opencode, repointed
Fresh start. Reframe: Step 1 was a toy that works. Step 2 makes it something you could put in front of a team. Same model, one new layer.
Step 2 why
What's wrong with what we just built?
Every client hardcodes localhost:11434 and a model name
Ollama down → every tool breaks, silently
Want a bigger model? Reconfigure N clients
No idea what it costs - or who spends it
Hardcoding an endpoint in every client is a habit , not an architecture.
Step 2 · The problem workshop.caxaria.com 16 / 29
Ask the room: "who has a .env with a model name in it?" Hands go up. This is the classic hardcoded-dependency story - same reason you don't call a database socket from your UI layer. The answer is a gateway: one stable endpoint. The closing line, in plain words: today every tool carries its own copy of "where the model lives". That works for one tool and rots at ten. A gateway turns it into one place to change.
Step 2 the idea
LiteLLM: one API in front of many models
opencode the client
:4000/v1
LiteLLM group: fast
Ollama local · tried first
Claude or OpenAI failover · capped $2/day
One API - OpenAI dialect, whatever answers.
A routing table - model_list.
A safety net : failover, budgets, keys, guardrails, UI.
Step 2 · LiteLLM workshop.caxaria.com 17 / 29
Three ideas, three minutes: one endpoint, a routing table, a safety net. Everything else is config detail. Frame it as a layer they already know: an ingress controller / reverse proxy for models. Same reasons - stable name, health checks, failover, observability. Build the diagram one click at a time: gateway, then local, then the dashed cloud failover.
Step 2 2.1 to 2.2
Three files, one gateway
docker-compose.yml LiteLLM + Postgres
config.yaml the routing table
.env master key · your cloud key
config.yaml model_list: - model_name: qwen3.5:4b # clients ask for this litellm_params: model: ollama_chat/qwen3.5:4b # provider’s name api_base: http://host.docker.internal :11434
host.docker.internal = “my laptop”, seen from inside the container.
Step 2 · The files workshop.caxaria.com 18 / 29
Left side of model_name is a name YOU own, right side is the provider's name - that indirection is the entire abstraction. Renaming a model later is a one-line change nobody downstream notices. host.docker.internal gets its own beat: it's the #1 container-vs-laptop confusion. Postgres is there because keys, logs and budgets need a database. ollama_chat/ means Ollama's native API, which also keeps a cloud budget from ever counting your local model. The page creates each file with a heredoc, so nobody has to open an editor or get indentation wrong.
Exercise
Bring the gateway up
Create .env, compose file, config.yaml (paste from the page)
docker compose up -d
Ask :4000 what models it has
Chat with qwen3.5:4b through it
terminal B docker compose up -d curl localhost:4000 /v1/models \ -H "Authorization: Bearer $LITELLM_MASTER_KEY"
first start ≈ 1 min · then qwen3.5:4b in the list
Step 2 · Exercise workshop.caxaria.com 19 / 29
Watch the logs while the first request lands. Seeing the model name resolve to an Ollama call is the mental click. Failure modes on the spot: container up but Ollama unreachable (host.docker.internal, or on Linux Ollama bound to localhost only), missing master key, model-name typo. All in the troubleshooting table. First start takes about a minute - Postgres migrations. The page's start command polls until ready.
Checkpoint · Gateway
The gateway answers
Both containers up /v1/models lists qwen3.5:4bA chat through :4000 returns a real answer
Pass = you can name the URL your client will use. http://localhost:4000/v1
Step 2 · Checkpoint workshop.caxaria.com 20 / 29
Walk the room. If /v1/models is empty, config.yaml didn't load - check docker compose logs litellm for a YAML error before touching anything else.
Step 2 2.4
One name, two deployments, one ceiling
config.yaml - model_name: fast litellm_params: model: ollama_chat/qwen3.5:4b order: 1 # local first - model_name: fast litellm_params: model: your cloud model order: 2 # only if local fails provider_budget_config: your provider : budget_limit: 2 # USD per day
bring one of these Anthropic model: anthropic/claude-sonnet-5-5 api_key: os.environ/ANTHROPIC_API_KEY
OpenAI model: openai/gpt-5-mini api_key: os.environ/OPENAI_API_KEY
⚠ A failover is a silent cost switch . Never ship one without a cap. Calling qwen3.5:4b directly never fails over: only fast does.
Step 2 · The fast group workshop.caxaria.com 21 / 29
This is the slide that matters most for a production audience. Say the warning slowly: a failover without a budget turns an outage into an invoice. Two deployments share one name, "fast". Lowest order goes first: local. If it fails, LiteLLM retries on order 2: the cloud model. Calling qwen3.5:4b directly never fails over, on purpose; only "fast" does. We tested both, and that it returns to local as soon as Ollama is back. We don't provide keys. They bring one from Anthropic or OpenAI: the only difference is the cloud entry and the provider name in the budget. Show both. Be precise about when it fires: connection refused, timeout, provider 5xx. Not 4xx: a bad request stays a bad request, and retrying it on a paid model just bills you for the same error. In production you'd scope caps per virtual key per team, not just per provider: homework on the page.
Step 2 going further
Guardrails: clean the prompt first
you type
Reply to Jane Doe (jane@acme.com ) about her refund
LiteLLM guardrail
detect · mask · or block
the model sees
Reply to <PERSON> (<EMAIL_ADDRESS> ) about her refund
…and with output_parse_pii the reply comes back with the real name and email restored.
config.yaml guardrails: - guardrail_name: "pii" litellm_params: guardrail: presidio mode: "pre_call" pii_entities_config: PERSON: "MASK " EMAIL_ADDRESS: "MASK " CREDIT_CARD: "BLOCK " output_parse_pii: true
Also in the box secret hiding · banned keywords · third-party checkers
Best effort, not a compliance guarantee. Presidio = two extra containers.
Step 2 · Guardrails workshop.caxaria.com 22 / 29
The hook: failover means prompts can now leave the laptop for a cloud provider. A gateway is the one place where you can inspect every request before that happens. LiteLLM guardrails run on each request. The PII one uses Presidio, which runs as two small containers (an analyzer and an anonymizer) next to the gateway. In config you list entity types and an action per type: MASK replaces the value with a placeholder like <EMAIL_ADDRESS>; BLOCK rejects the request. With output_parse_pii the gateway swaps the real values back into the reply, so the user sees a normal answer while the model never saw the email. Mode pre_call runs before the model; post_call checks replies; logging_only masks what is written to logs. Clients can ask for a guardrail by name per request, and it can be attached per key or on by default (check the docs for the current option names). Be honest about limits: detection is statistical, so it misses things. It's a safety net, not a compliance guarantee, and it adds a little latency. For us the interesting entities are the ones in our data: names, emails, IBANs. We did NOT run this in the workshop build. The site has the config as a reference, marked untested. Other guardrails exist (secret hiding, banned keywords, third-party checkers): point at the LiteLLM docs.
Step 2 going further
Auto-router: right-size every request
medium
“what is a KV cache?”
local
complex
“refactor this module…”
cloud
reasoning
“prove it, step by step…”
cloud
config.yaml - model_name: auto litellm_params: model: auto_router/complexity_router complexity_router_config: tiers: SIMPLE: qwen3.5:4b MEDIUM: qwen3.5:4b COMPLEX: cloud-smart REASONING: cloud-smart
How it decides Rules score each prompt: size, code, reasoning words. No extra model call. Add keyword_tier_rules to force the words you care about.
A heuristic, not magic. Check Logs to see where requests went.
Step 2 · Auto-router workshop.caxaria.com 23 / 29
The hook: so far the client chooses the model. But the client rarely knows how hard the question is, and "hi" does not need a cloud model. LiteLLM's complexity router is just another model name, "auto". Each prompt gets a score from rules, not from another model call: token count, whether there is code, reasoning markers, technical terms, multi-step phrasing. Two or more reasoning markers go straight to the top tier. The score picks one of four tiers, and each tier points at a model_name from our list: simple and medium stay on the local model, complex and reasoning go to the cloud one. The cloud tiers count against the same provider cap. We ran it: "hi" and "what is a KV cache" stayed local; a refactor request went to the cloud because of a keyword rule. Be honest: it's a heuristic. A long, hard question phrased casually can score low. That is what keyword_tier_rules are for, and there is an LLM-classifier option if you want something smarter (check the docs). Check Logs to see where things actually went. Gotcha we hit: with a local model LiteLLM has no price entry for, the router crashed until we added context_compaction: false. It's in the config on the page. Tiers can also point at a group such as fast, if you want local-first with failover inside a tier.
Exercise
Break it on purpose
1 · B
Loop calls fast every 2 s
local | pong local | pong
2 · A
Stop OllamaCtrl-C
✕ local is gone
3 · B
Loop keeps answering
cloud | pong cloud | pong
4 · A
Restart Ollama
local | pongtraffic is home
⚠ Those requests ran on your provider's bill. Next: see exactly where.
Step 2 · Exercise workshop.caxaria.com 24 / 29
The most convincing four minutes of the day: nobody's client changed, nothing redeployed, the work just kept flowing. That's the value of the abstraction, demonstrated rather than asserted. Then name the cost: those requests are now on your provider's bill (the loop prints local or cloud from a response header). If failover doesn't fire, check that both entries share model_name: fast, have an order, and that enable_pre_call_checks is true. To bring Ollama back, re-run the serve command in Terminal A.
Step 2 2.6
Your debugging surface
localhost:4000/ui - user admin, password = your master key
Playground Chat in the browser. Pick fast or qwen3.5:4b, compare.
Logs Every request: which model answered, latency, cost . Find your failover.
Keys Per-person keys, each with its own budget.
Step 2 · Playground workshop.caxaria.com 25 / 29
Five-minute guided tour, not a feature dump. Open Logs, point at one real routing decision - model, provider, latency, cost. This is the observability answer to "no idea what it costs". Then the Keys page: per-person virtual keys with budgets live here. If this ever went in front of a team, that's the first thing to set up. Have them copy the master key from the page's command (it goes to the clipboard) - never paste it on a slide or in chat. Login is username admin, password = master key.
Step 2 2.7
Repoint opencode: four values
Step 1 Step 2
provider ollama workshop
baseURL localhost:11434/v1 localhost:4000/v1
apiKey "ollama" {env:OPENCODE_API_KEY}
model ollama/qwen3.5:4b workshop/fast
The client binary didn't change. The model's identity moved into the gateway - exactly where you want it.
Step 2 · Repoint workshop.caxaria.com 26 / 29
Land the point: the diff is tiny because the endpoint shape never changed - that's the reward for choosing an OpenAI-compatible runtime in Step 1. Note the name shift: opencode now asks for "fast", not a specific model. Everything downstream is model-agnostic. The env indirection keeps the key out of a dotfile people screenshot.
Exercise
Prove it end to end
Ask opencode for a change: it goes via the gateway
Stop Ollama. Ask again.
It answers, from the cloud. Restart Ollama.
Find the spend in Logs
Local answers via fast The cloud is a capped failover Killing Ollama doesn't break the client You know where the spend shows up
Step 2 · Final workshop.caxaria.com 27 / 29
The full circle: same Elixir task from the offline exercise, same client, now surviving an outage. Let them feel the difference between "I have a local model" and "I have a resilient setup". Call out latency honestly when it flips to Claude - it's faster, and that's a useful data point, not a defeat. Then show the spend in the UI Logs. The bill is the other half of the lesson. Say the closing line and pause on it - it's the thesis of the day.
Recap
Three layers. Each one replaceable.
Runtime
Ollama → oMLX → anything OpenAI-shaped
Gateway
LiteLLM model names · failover · budgets · keys · guardrails
Client
opencode …or any OpenAI-compatible tool
You built the home edition of the pattern we run: runtime → gateway → clients , plus monitoring, per-team keys and pinned versions.
Recap workshop.caxaria.com 28 / 29
Recap by contrast. Same hardware, same model, one layer of indirection. The three-layer table is the takeaway: every box is replaceable - that's what "abstraction" bought them. Our production setup is the same shape - runtime, gateway, clients - with the things a shared setup adds: monitoring, per-team keys, pinned model versions. Be concrete about what the real one adds and that the architecture is identical. We deliberately did NOT point at the shared gateway today: self-contained means nobody's failures touched shared infrastructure.
Take it home
Homework
oMLX : serve your model, point the gateway at itA second model in model_list, used from opencodeGuardrails : mask an email with PresidioA budgeted key : watch it in the UI
workshop.caxaria.com
every command · troubleshooting
Clinic starts now · ollama list · docker compose logs -f litellm · localhost:4000/ui
Homework workshop.caxaria.com 29 / 29
One URL, on screen and in Slack. The page has every command plus troubleshooting, so nobody needs to screenshot a slide. Homework is deliberately small: one install, one config edit, one guardrail, one budget. Each teaches something the workshop only touched. Seed the clinic with two questions you know are coming - "can I use this for coding agents?" and "what about GPU servers?" - and have a one-line answer for each. Open the clinic.