OpenAI(base_url="http://...", api_key="ollama") — the same SDK you’ve used since M01. The only thing that changes is the hostname: localhost becomes your cloud VM’s IP address. One env var swap, infinite scale.
M21B: Cloud Deployment
Your Ollama agent runs brilliantly on your laptop. Now let’s move it to a cloud GPU VM so it runs 24 / 7, serves your whole team, and delivers 5–10× faster inference — with the exact same code.
Learning Objectives
- Explain why a cloud GPU VM is the right next step after local development
- Compare GPU cloud providers by cost, speed, and operational simplicity
- Provision a GCP Compute Engine VM with a T4 GPU and Ollama running as a systemdsystemd: the Linux process manager. A systemd service runs automatically at boot, restarts on crash, and exposes logs via journalctl. It is the standard way to run long-lived server processes on modern Ubuntu/Debian VMs. service
- Provision an AWS EC2 g4dn.xlarge with the Deep Learning AMI and secure SSH access
- Connect your existing agent code to a cloud Ollama instance via an SSH tunnelSSH tunnel: an encrypted network connection that forwards a local port (e.g. 11434 on your laptop) to a port on a remote server. Traffic travels through the encrypted SSH channel — no firewall rule or public port is needed on the server.
- Use managed OpenAI-compatible APIs (Groq, Together AI, Fireworks) as drop-in Ollama replacements
- Build a
LocalModelClientprovider-agnostic wrapper driven by an env var - Estimate monthly inference cost across providers from a tokens/day budget
Why Move From Local to Cloud?
BEFORE: You have a research agent running on your laptop. It uses Mistral 7B via Ollama, you have full control, and it works great during your workday. Eight tokens per second on the M2 Pro feels perfectly fast when you are the only user.
PAIN: Your laptop sleeps when you close the lid. Your teammate in a different time zone cannot access it. A scheduled job that runs at 3 am finds nothing listening on port 11434. And when three people try to run queries simultaneously, they all share the same four CPU threads — latency spikes to 30+ seconds per request.
MAPPING: A cloud GPU VM is that same analyst, but now living in an office that never closes. The VM runs 24 / 7, exposes Ollama on a port only reachable via SSH, and a T4’s dedicated 16 GB VRAMVRAM (Video RAM): the dedicated memory on a GPU. For LLM inference, VRAM determines which model sizes you can run and how much you can batch. A 16 GB T4 can run Mistral 7B with room to spare; a 24 GB RTX 3090 can run Mixtral 8x7B in 4-bit. delivers 40+ tokens per second — five times faster than your laptop. The code change is one line: swap localhost for the VM’s IP.
A production agent handling 10 concurrent users needs at minimum 200 tokens/sec sustained throughput. An M2 Pro MacBook delivers ~8 tok/s on Mistral 7B-Q4 (CPU-only) — that is 25 users serialised into a single queue. A GCP T4 VM delivers ~40 tok/s (5×), a RTX 3090 on Vast.ai delivers ~80 tok/s (10×), and an A100 on Lambda Labs delivers ~200 tok/s (25×). For the cost of one Starbucks latte per day (~$0.20/hr on a 3090), you can serve a small team in real time.
(CPU, Mistral 7B-Q4)
(GCP, $0.35/hr)
(Vast.ai, $0.20/hr)
Every code example you have written in this course uses OpenAI(base_url="http://localhost:11434/v1", api_key="ollama"). To point the same code at a cloud VM, change exactly one value: the hostname. Everything else — model name, request format, streaming API, tool-call schema — stays identical. This is not a migration; it is a config change.
The cleanest pattern: read base_url from an env var OLLAMA_BASE_URL that defaults to http://localhost:11434/v1 in development and is set to the VM IP in production. No code path changes, no if-branches, no adapter layers.
Provider Comparison
Not all GPU clouds are equal. The table below shows the realistic choices for running Ollama in production, ordered from the cheapest to the most enterprise-grade. Note: prices are approximate and change frequently — always check the provider’s current pricing page.
| Provider | Instance | GPU | VRAM | ~Cost/hr | Mistral 7B Speed | Best For |
|---|---|---|---|---|---|---|
| Vast.ai | RTX 3090 spot | 3090 | 24 GB | ~$0.20 | ~80 tok/s | Dev / experiments |
| RunPod | RTX 3090 pod | 3090 | 24 GB | ~$0.22 | ~80 tok/s | Side projects, startups |
| GCP | n1-standard-4 + T4 | T4 | 16 GB | ~$0.35 | ~40 tok/s | GCP-native teams |
| AWS | g4dn.xlarge | T4 | 16 GB | ~$0.53 | ~40 tok/s | AWS-native teams |
| Azure | NC4as_T4_v3 | T4 | 16 GB | ~$0.53 | ~40 tok/s | Enterprise / Azure AD |
| Lambda Labs | 1x A10 instance | A10 | 24 GB | ~$0.60 | ~100 tok/s | Production workloads |
| CoreWeave | A100 80GB | A100 | 80 GB | ~$2.21 | ~200 tok/s | High-throughput prod |
A T4 VM running 8 hours a day (typical dev workday) costs $2.80/day on GCP — less than a coffee. If you stop the VM when not in use, a month of heavy development runs ~$56. Lambda Labs A10 at 8 hr/day is $144/month. Compare that to OpenAI API costs at scale: 1M tokens/day at $3/MTok = $90/month, with no GPU to manage. The crossover point depends on your token volume and privacy requirements.
Lab 1: GCP Compute Engine with T4 GPU
Google Cloud Platform Compute Engine lets you attach a GPUGPU (Graphics Processing Unit): a processor with thousands of small parallel cores optimised for matrix math. Because transformer inference is essentially batched matrix multiplication, a GPU can run LLM inference 10–50x faster than a CPU for the same model size. accelerator to a standard VM. The T4 is the cheapest NVIDIA GPU available on GCP and comfortably runs Mistral 7B and Mixtral 8x7B in 4-bit quantisation.
You need a GCP project with billing enabled and GPU quota in us-central1-a. New GCP accounts have 0 GPU quota by default — request it at IAM & Admin → Quotas → search "NVIDIA T4 GPUs". Approval is usually instant for ≤1 GPU. Install the gcloud CLI and run gcloud auth login before the steps below.
Step 1 — Create the VM
# Create VM with T4 GPU, Ubuntu 22.04, 50 GB SSD
# --maintenance-policy=TERMINATE is required for GPU VMs (no live migration)
gcloud compute instances create ollama-server \
--zone=us-central1-a \
--machine-type=n1-standard-4 \
--accelerator=type=nvidia-tesla-t4,count=1 \
--image-family=ubuntu-2204-lts \
--image-project=ubuntu-os-cloud \
--boot-disk-size=50GB \
--boot-disk-type=pd-ssd \
--maintenance-policy=TERMINATE \
--metadata=startup-script='#!/bin/bash
# Install CUDA drivers (required for T4 GPU access)
apt-get install -y linux-headers-$(uname -r)
curl -fsSL https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-keyring_1.1-1_all.deb -o /tmp/cuda-keyring.deb
dpkg -i /tmp/cuda-keyring.deb
apt-get update -y && apt-get install -y cuda-drivers
# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh
# Pull Mistral (4-bit, fits in T4 16 GB VRAM)
sudo -u ubuntu ollama pull mistral
'
# Verify the instance is running
gcloud compute instances describe ollama-server --zone=us-central1-a \
--format="value(status)"
# Expected: RUNNING
WHY: The startup-script runs as root at first boot — this is idiomatic GCP and avoids a manual SSH session for one-time setup
GOTCHA: The startup-script can take 5–10 minutes. SSH in and run
journalctl -f -u google-startup-scripts to watch progress
Step 2 — Configure Ollama as a systemd Service
By default, Ollama starts when you first SSH in and stops when you log out. Registering it as a systemdsystemd is the init system on modern Linux (Ubuntu, Debian, Fedora). You register a program as a "service" with a unit file, then systemd manages starting it at boot, restarting on crash, and collecting logs. It is the standard production pattern for any long-running server process. service makes it start on boot and restart automatically on crash.
# SSH into the VM
gcloud compute ssh ollama-server --zone=us-central1-a
# Inside the VM: write a systemd unit file for Ollama
sudo tee /etc/systemd/system/ollama.service > /dev/null <<'EOF'
[Unit]
Description=Ollama LLM Server
After=network.target
[Service]
# Run as the ubuntu user so model cache goes to /home/ubuntu/.ollama
User=ubuntu
ExecStart=/usr/local/bin/ollama serve
# Only bind to localhost — the SSH tunnel handles external access
Environment="OLLAMA_HOST=127.0.0.1:11434"
Restart=always
RestartSec=5
# Give the GPU time to initialise at startup
TimeoutStartSec=60
[Install]
WantedBy=multi-user.target
EOF
# Enable and start the service
sudo systemctl daemon-reload
sudo systemctl enable ollama
sudo systemctl start ollama
# Verify it is running
sudo systemctl status ollama
# Expected: Active: active (running)
ollama serve bound to 127.0.0.1 (not 0.0.0.0)WHY: Binding to 127.0.0.1 means Ollama is never reachable from the public internet — only via SSH tunnel. This is the secure default.
GOTCHA: If you forget
OLLAMA_HOST=127.0.0.1:11434, Ollama listens on 0.0.0.0 and anyone who guesses your VM IP can run inference on your bill
Step 3 — Connect via SSH Tunnel
An SSH tunnelAn SSH tunnel (-L local:remote) tells your SSH client to forward any connection to a local port through the encrypted SSH channel to a port on the remote host. Your local process thinks it is connecting to localhost; the traffic actually travels over SSH to the remote server. forwards local port 11434 through the encrypted SSH channel to the VM’s 127.0.0.1:11434. Your agent code never sees the internet — it only ever connects to localhost.
# Open SSH tunnel in background (-N = no remote command, -f = background)
# -L 11434:localhost:11434 = forward local :11434 → VM's localhost:11434
gcloud compute ssh ollama-server --zone=us-central1-a \
-- -L 11434:localhost:11434 -N -f
# Now test it — your local port 11434 is forwarded to the cloud VM
curl http://localhost:11434/api/tags
# Expected: {"models": [{"name": "mistral:latest", ...}]}
Your agent code at http://localhost:11434/v1 travels through the encrypted SSH channel to the VM, then over the loopback interface to Ollama. No firewall rule needed. No public port exposed.
With the tunnel open, your existing agent code works unchanged. The only difference from local development is the tunnel process running in the background.
WHY: If the SSH tunnel is up and Ollama is running, this code is byte-for-byte identical to local development
GOTCHA: If you get a connection refused error, the tunnel is not open — run the gcloud ssh tunnel command first
# verify_cloud_connection.py
# WHAT: Test that the SSH-tunnelled cloud Ollama responds correctly
# WHY: The base_url is identical to local development — this is the point
# GOTCHA: OLLAMA_BASE_URL defaults to localhost; set it to test without tunnel
import os
from openai import OpenAI
def verify_connection(base_url: str | None = None) -> None:
"""Verify Ollama is reachable and the model is loaded."""
url = base_url or os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
client = OpenAI(base_url=url, api_key="ollama")
try:
# Quick ping — ask for a one-word reply to minimise latency
response = client.chat.completions.create(
model="mistral",
messages=[{"role": "user", "content": "Reply with the single word: connected"}],
max_tokens=10,
)
reply = response.choices[0].message.content.strip()
print(f"[OK] Ollama at {url} responded: {reply!r}")
print(f" Model: {response.model}")
print(f" Tokens used: {response.usage.total_tokens}")
except Exception as exc:
print(f"[FAIL] Cannot reach Ollama at {url}: {exc}")
print(" Is the SSH tunnel running?")
print(" Command: gcloud compute ssh ollama-server -- -L 11434:localhost:11434 -N -f")
raise SystemExit(1)
if __name__ == "__main__":
verify_connection()
// verifyCloudConnection.ts
// WHAT: Verify the SSH-tunnelled Ollama instance responds
// WHY: Identical base_url pattern to local development
// GOTCHA: Set OLLAMA_BASE_URL env var if not using the default SSH tunnel port
import OpenAI from "openai";
async function verifyConnection(baseUrl?: string): Promise<void> {
const url =
baseUrl ?? process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1";
const client = new OpenAI({ baseURL: url, apiKey: "ollama" });
try {
const response = await client.chat.completions.create({
model: "mistral",
messages: [
{ role: "user", content: "Reply with the single word: connected" },
],
max_tokens: 10,
});
const reply = response.choices[0].message.content?.trim();
console.log(`[OK] Ollama at ${url} responded: ${JSON.stringify(reply)}`);
console.log(` Model: ${response.model}`);
console.log(` Tokens used: ${response.usage?.total_tokens}`);
} catch (err) {
console.error(`[FAIL] Cannot reach Ollama at ${url}: ${err}`);
console.error(" Is the SSH tunnel running?");
console.error(
" Command: gcloud compute ssh ollama-server -- -L 11434:localhost:11434 -N -f"
);
process.exit(1);
}
}
verifyConnection();
You created a GCP VM with a T4 GPU, installed Ollama as a systemd service that starts on boot, and forwarded port 11434 through an SSH tunnel to your laptop. Your agent code saw zero changes — http://localhost:11434/v1 now resolves to a cloud GPU. The tunnel encrypts all traffic and leaves no public ports open on the VM.
Lab 2: AWS EC2 with T4 GPU
AWS provides the g4dn.xlarge instance with a T4 GPU and 16 GB VRAM — the same GPU as the GCP approach above. The Deep Learning AMIAWS Deep Learning AMI: an EC2 machine image pre-loaded with CUDA drivers, cuDNN, PyTorch, and other ML frameworks. Using it skips the manual CUDA driver installation step, saving 10–15 minutes at launch. (Ubuntu) pre-installs CUDA drivers, saving setup time.
You need an AWS account with EC2 access, a key pair (create at EC2 → Key Pairs if you do not have one), and the aws CLI configured with aws configure. The g4dn.xlarge is not available in every region — us-east-1 and us-west-2 always have it.
Step 1 — Launch the Instance
# Step 1a: Create a security group that allows SSH only (no inbound 11434)
aws ec2 create-security-group \
--group-name ollama-sg \
--description "SSH-only access for Ollama VM"
# Allow SSH from your current IP only (replace with your IP or use 0.0.0.0/0 for any)
MY_IP=$(curl -s https://checkip.amazonaws.com)
aws ec2 authorize-security-group-ingress \
--group-name ollama-sg \
--protocol tcp --port 22 --cidr "${MY_IP}/32"
# Step 1b: Launch g4dn.xlarge with Deep Learning AMI (Ubuntu 20.04)
# ami-0c2d0b2d77ffd5a16 is the DL AMI in us-east-1 — check AWS Marketplace for your region
INSTANCE_ID=$(aws ec2 run-instances \
--image-id ami-0c2d0b2d77ffd5a16 \
--instance-type g4dn.xlarge \
--key-name my-key \
--security-groups ollama-sg \
--block-device-mappings '[{"DeviceName":"/dev/sda1","Ebs":{"VolumeSize":50,"VolumeType":"gp3"}}]' \
--query 'Instances[0].InstanceId' --output text)
echo "Instance ID: $INSTANCE_ID"
# Wait until running
aws ec2 wait instance-running --instance-ids "$INSTANCE_ID"
# Get the public IP
VM_IP=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
echo "VM IP: $VM_IP"
WHY: Restricting SSH to your current IP prevents brute-force attempts; no port 11434 in the security group means Ollama is never internet-accessible
GOTCHA: The Deep Learning AMI ID changes with each update — check the AWS DL AMI page for the current ID in your region
Step 2 — Install Ollama and Pull Model
# SSH and install Ollama (CUDA is already on the DL AMI)
ssh -i my-key.pem ubuntu@${VM_IP} \
"curl -fsSL https://ollama.com/install.sh | sh && ollama pull mistral"
# Set up systemd service (same unit file as GCP lab)
ssh -i my-key.pem ubuntu@${VM_IP} 'sudo tee /etc/systemd/system/ollama.service > /dev/null' <<'EOF'
[Unit]
Description=Ollama LLM Server
After=network.target
[Service]
User=ubuntu
ExecStart=/usr/local/bin/ollama serve
Environment="OLLAMA_HOST=127.0.0.1:11434"
Restart=always
RestartSec=5
TimeoutStartSec=60
[Install]
WantedBy=multi-user.target
EOF
ssh -i my-key.pem ubuntu@${VM_IP} \
"sudo systemctl daemon-reload && sudo systemctl enable ollama && sudo systemctl start ollama"
Step 3 — Open SSH Tunnel
# Open SSH tunnel — same pattern as GCP, different SSH invocation
ssh -i my-key.pem -L 11434:localhost:11434 -N -f ubuntu@${VM_IP}
# Verify
curl http://localhost:11434/api/tags
# Expected: {"models":[{"name":"mistral:latest",...}]}
WHY: The whole point: changing from GCP to AWS requires changing the tunnel command, not the application code
GOTCHA: Kill any existing tunnel on :11434 before opening a new one (lsof -ti:11434 | xargs kill -9)
# verify_cloud_connection.py — identical to GCP version
# WHAT: Same connection test; only the tunnel command above changes
# WHY: Provider abstraction works at the infrastructure level, not the code level
import os
from openai import OpenAI
client = OpenAI(
base_url=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1"),
api_key="ollama",
)
response = client.chat.completions.create(
model="mistral",
messages=[{"role": "user", "content": "Say 'AWS GPU ready' and nothing else."}],
max_tokens=10,
)
print(response.choices[0].message.content)
# Expected: AWS GPU ready
// verifyConnection.ts — identical to GCP version
import OpenAI from "openai";
const client = new OpenAI({
baseURL: process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1",
apiKey: "ollama",
});
const response = await client.chat.completions.create({
model: "mistral",
messages: [{ role: "user", content: "Say 'AWS GPU ready' and nothing else." }],
max_tokens: 10,
});
console.log(response.choices[0].message.content);
// Expected: AWS GPU ready
Ollama has no authentication by default. If you open port 11434 in the security group (AWS) or firewall rules (GCP), anyone who finds your VM IP can pull models and run inference at your expense. The SSH tunnel pattern in this lab is the correct approach: Ollama binds to 127.0.0.1, the security group allows SSH only, and all access goes through the encrypted tunnel. If you need multiple teammates to access the VM, either share the SSH key or put an authenticated proxy (like the FastAPI service from M21) in front of Ollama.
Managed Open Source Alternatives
Several providers host open source models and expose them via an OpenAI-compatible API. The same OpenAI(base_url=...) SDK pattern works — you just swap the URL and provide a real API key instead of "ollama".
WHY: Zero infra to manage; free tiers for development; some offer sub-100ms latency via custom inference kernels
GOTCHA: Model names differ between providers — check each provider's docs for the exact model ID string
# managed_providers.py
# WHAT: Drop-in replacements for local Ollama using managed hosted APIs
# WHY: No VM to manage; free tiers work for dev; fastest inference on earth (Groq)
# GOTCHA: Each provider uses different model name strings — see their docs
import os
from openai import OpenAI
# ── Groq: fastest free tier, custom LPU hardware ──────────────────────────
# Free: 14,400 requests/day, 6,000 tokens/min
# Models: llama-3.3-70b-versatile, mixtral-8x7b-32768, gemma2-9b-it
groq_client = OpenAI(
base_url="https://api.groq.com/openai/v1",
api_key=os.environ["GROQ_API_KEY"],
)
# ── Together AI: large model catalogue, competitive pricing ───────────────
# Free: $1 credit on signup
# Models: mistralai/Mistral-7B-Instruct-v0.2, meta-llama/Llama-3-8b-chat-hf
together_client = OpenAI(
base_url="https://api.together.xyz/v1",
api_key=os.environ["TOGETHER_API_KEY"],
)
# ── Fireworks AI: fastest fine-tuned model serving ────────────────────────
# Free: $1 credit on signup
# Models: accounts/fireworks/models/mistral-7b-instruct, llama-v3-8b-instruct
fireworks_client = OpenAI(
base_url="https://api.fireworks.ai/inference/v1",
api_key=os.environ["FIREWORKS_API_KEY"],
)
# ── Amazon Bedrock via OpenAI-compat gateway ──────────────────────────────
# Requires bedrock-access-gateway: github.com/aws-samples/bedrock-access-gateway
# Exposes Llama 3.1, Mistral, and other Bedrock models via OpenAI format
bedrock_client = OpenAI(
base_url=(
f"https://{os.environ['BEDROCK_GATEWAY_ID']}"
".execute-api.us-east-1.amazonaws.com/prod/v1"
),
api_key=os.environ["BEDROCK_API_KEY"],
)
def call_provider(client: OpenAI, model: str, prompt: str) -> str:
"""Call any managed provider with the standard OpenAI chat format."""
response = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=256,
)
return response.choices[0].message.content
# Example: Groq for ultra-fast dev/test queries
reply = call_provider(
groq_client,
model="llama-3.3-70b-versatile",
prompt="What is the capital of France? One word.",
)
print(f"Groq says: {reply}") # Paris
// managedProviders.ts
// WHAT: Drop-in managed API clients using the OpenAI SDK
// WHY: Same SDK, different base_url — zero code divergence between providers
// GOTCHA: Never commit API keys — load from env
import OpenAI from "openai";
// ── Groq: custom LPU hardware, fastest on the market ─────────────────────
const groqClient = new OpenAI({
baseURL: "https://api.groq.com/openai/v1",
apiKey: process.env.GROQ_API_KEY!,
});
// ── Together AI ──────────────────────────────────────────────────────────
const togetherClient = new OpenAI({
baseURL: "https://api.together.xyz/v1",
apiKey: process.env.TOGETHER_API_KEY!,
});
// ── Fireworks AI ─────────────────────────────────────────────────────────
const fireworksClient = new OpenAI({
baseURL: "https://api.fireworks.ai/inference/v1",
apiKey: process.env.FIREWORKS_API_KEY!,
});
// ── Amazon Bedrock via OpenAI-compat gateway ─────────────────────────────
const bedrockClient = new OpenAI({
baseURL: `https://${process.env.BEDROCK_GATEWAY_ID}.execute-api.us-east-1.amazonaws.com/prod/v1`,
apiKey: process.env.BEDROCK_API_KEY!,
});
async function callProvider(
client: OpenAI,
model: string,
prompt: string
): Promise<string> {
const response = await client.chat.completions.create({
model,
messages: [{ role: "user", content: prompt }],
max_tokens: 256,
});
return response.choices[0].message.content ?? "";
}
// Example usage
const reply = await callProvider(
groqClient,
"llama-3.3-70b-versatile",
"What is the capital of France? One word."
);
console.log(`Groq says: ${reply}`); // Paris
Managed Provider Comparison
| Provider | Free Tier | Rate Limits (free) | Best Open Models | Avg Latency |
|---|---|---|---|---|
| Groq | Free forever | 14,400 req/day · 6K tok/min | Llama 3.3 70B, Mixtral 8x7B, Gemma2 9B | ~50 ms TTFT |
| Together AI | $1 credit | 60 req/min on free | Mistral 7B, Llama 3 8B/70B, Qwen 2.5 | ~200 ms TTFT |
| Fireworks AI | $1 credit | 60 req/min on free | Mistral 7B, Llama 3.1, Gemma 2 | ~150 ms TTFT |
| OpenRouter | Some free models | 20 req/min free | Routes to best provider per model | Varies by model |
| Bedrock Gateway | AWS free tier | AWS throttling applies | Llama 3.1, Mistral Large, Nova | ~300 ms TTFT |
Use managed APIs when: you are in early development, you want zero infra overhead, your usage is bursty (billing per token is efficient), or you need the largest models (70B+) that require expensive multi-GPU setups.
Use self-hosted Ollama when: data cannot leave your infrastructure (HIPAA, GDPR, financial), you need consistent latency without rate limits, token volume is high enough that per-token billing exceeds VM cost, or you need a specific fine-tuned model not offered by managed providers.
Provider-Agnostic Wrapper
Rather than scattering base_url and api_key lookups throughout your codebase, centralise provider selection in a single factory class. The LOCAL_MODEL_PROVIDER env var becomes the single dial that controls which backend every agent in your application uses.
WHY: Centralising provider config means you can switch all agents from Ollama to Groq by changing one env var
GOTCHA: The model name is provider-specific (e.g. "mistral" on Ollama, "mistralai/Mistral-7B-Instruct-v0.2" on Together)
# local_model_client.py
# WHAT: Factory that returns a configured OpenAI client for any supported provider
# WHY: One env var (LOCAL_MODEL_PROVIDER) controls all agents in the app
# GOTCHA: Model names are provider-specific — use get_default_model() to stay portable
import os
from dataclasses import dataclass
from openai import OpenAI
@dataclass
class ProviderConfig:
"""Hold provider-specific settings returned alongside the client."""
name: str
default_model: str
max_context_tokens: int
class LocalModelClient:
"""
Factory for OpenAI-compatible clients across Ollama and managed providers.
Usage:
client, cfg = LocalModelClient.create()
response = client.chat.completions.create(
model=cfg.default_model, messages=[...], max_tokens=512
)
Environment variables:
LOCAL_MODEL_PROVIDER: ollama | groq | together | fireworks (default: ollama)
OLLAMA_BASE_URL: Ollama endpoint (default: http://localhost:11434/v1)
GROQ_API_KEY: Groq API key
TOGETHER_API_KEY: Together AI API key
FIREWORKS_API_KEY: Fireworks AI API key
"""
CONFIGS: dict[str, ProviderConfig] = {
"ollama": ProviderConfig("ollama", "mistral", 32768),
"groq": ProviderConfig("groq", "llama-3.3-70b-versatile", 131072),
"together": ProviderConfig("together", "mistralai/Mistral-7B-Instruct-v0.2", 32768),
"fireworks": ProviderConfig("fireworks", "accounts/fireworks/models/mistral-7b-instruct", 32768),
}
@classmethod
def create(
cls,
provider: str | None = None,
) -> tuple[OpenAI, ProviderConfig]:
"""
Return a (client, config) tuple for the requested provider.
Args:
provider: One of "ollama", "groq", "together", "fireworks".
Defaults to LOCAL_MODEL_PROVIDER env var, then "ollama".
"""
name = (provider or os.getenv("LOCAL_MODEL_PROVIDER", "ollama")).lower()
if name not in cls.CONFIGS:
supported = ", ".join(cls.CONFIGS.keys())
raise ValueError(
f"Unknown provider '{name}'. Supported: {supported}"
)
cfg = cls.CONFIGS[name]
if name == "ollama":
base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1")
client = OpenAI(base_url=base_url, api_key="ollama")
elif name == "groq":
api_key = os.environ.get("GROQ_API_KEY")
if not api_key:
raise EnvironmentError("GROQ_API_KEY is not set")
client = OpenAI(
base_url="https://api.groq.com/openai/v1",
api_key=api_key,
)
elif name == "together":
api_key = os.environ.get("TOGETHER_API_KEY")
if not api_key:
raise EnvironmentError("TOGETHER_API_KEY is not set")
client = OpenAI(
base_url="https://api.together.xyz/v1",
api_key=api_key,
)
elif name == "fireworks":
api_key = os.environ.get("FIREWORKS_API_KEY")
if not api_key:
raise EnvironmentError("FIREWORKS_API_KEY is not set")
client = OpenAI(
base_url="https://api.fireworks.ai/inference/v1",
api_key=api_key,
)
else:
# Unreachable given the check above, but satisfies type checkers
raise ValueError(f"Unhandled provider: {name}")
return client, cfg
# ── Example usage ─────────────────────────────────────────────────────────
def run_agent_query(question: str) -> str:
"""Run a simple query against whichever provider is configured."""
client, cfg = LocalModelClient.create()
response = client.chat.completions.create(
model=cfg.default_model,
messages=[
{"role": "system", "content": "You are a helpful assistant. Be concise."},
{"role": "user", "content": question},
],
max_tokens=512,
)
return response.choices[0].message.content
if __name__ == "__main__":
# Switch provider with: LOCAL_MODEL_PROVIDER=groq python local_model_client.py
answer = run_agent_query("What is 17 * 23?")
print(answer) # 391
// localModelClient.ts
// WHAT: Factory for OpenAI-compatible clients across all supported providers
// WHY: One env var switch replaces local Ollama with Groq, Together, or Fireworks
// GOTCHA: Model names differ by provider — use defaultModel from the config
import OpenAI from "openai";
interface ProviderConfig {
name: string;
defaultModel: string;
maxContextTokens: number;
}
interface ClientBundle {
client: OpenAI;
config: ProviderConfig;
}
const PROVIDER_CONFIGS: Record<string, ProviderConfig> = {
ollama: {
name: "ollama",
defaultModel: "mistral",
maxContextTokens: 32768,
},
groq: {
name: "groq",
defaultModel: "llama-3.3-70b-versatile",
maxContextTokens: 131072,
},
together: {
name: "together",
defaultModel: "mistralai/Mistral-7B-Instruct-v0.2",
maxContextTokens: 32768,
},
fireworks: {
name: "fireworks",
defaultModel: "accounts/fireworks/models/mistral-7b-instruct",
maxContextTokens: 32768,
},
};
function requireEnv(key: string): string {
const val = process.env[key];
if (!val) throw new Error(`Environment variable ${key} is not set`);
return val;
}
export function createLocalModelClient(provider?: string): ClientBundle {
const name = (
provider ??
process.env.LOCAL_MODEL_PROVIDER ??
"ollama"
).toLowerCase();
const config = PROVIDER_CONFIGS[name];
if (!config) {
const supported = Object.keys(PROVIDER_CONFIGS).join(", ");
throw new Error(`Unknown provider '${name}'. Supported: ${supported}`);
}
let client: OpenAI;
switch (name) {
case "ollama":
client = new OpenAI({
baseURL:
process.env.OLLAMA_BASE_URL ?? "http://localhost:11434/v1",
apiKey: "ollama",
});
break;
case "groq":
client = new OpenAI({
baseURL: "https://api.groq.com/openai/v1",
apiKey: requireEnv("GROQ_API_KEY"),
});
break;
case "together":
client = new OpenAI({
baseURL: "https://api.together.xyz/v1",
apiKey: requireEnv("TOGETHER_API_KEY"),
});
break;
case "fireworks":
client = new OpenAI({
baseURL: "https://api.fireworks.ai/inference/v1",
apiKey: requireEnv("FIREWORKS_API_KEY"),
});
break;
default:
throw new Error(`Unhandled provider: ${name}`);
}
return { client, config };
}
// ── Example usage ────────────────────────────────────────────────────────
async function runAgentQuery(question: string): Promise<string> {
const { client, config } = createLocalModelClient();
const response = await client.chat.completions.create({
model: config.defaultModel,
messages: [
{ role: "system", content: "You are a helpful assistant. Be concise." },
{ role: "user", content: question },
],
max_tokens: 512,
});
return response.choices[0].message.content ?? "";
}
// Run with: LOCAL_MODEL_PROVIDER=groq npx ts-node localModelClient.ts
const answer = await runAgentQuery("What is 17 * 23?");
console.log(answer); // 391
The LocalModelClient.create() factory reads LOCAL_MODEL_PROVIDER from the environment and returns the matching pre-configured OpenAI client plus a ProviderConfig with the right default model name. Your agent code calls create() once at startup and never touches provider-specific logic again. To switch from local Ollama to Groq in CI, set LOCAL_MODEL_PROVIDER=groq in your CI environment — zero code changes.
Cost Estimator
Before committing to a provider, estimate what your inference load actually costs per month. The CostEstimator class takes tokens/day and uptime requirements, then computes monthly cost across each provider with a recommendation.
# cost_estimator.py
# WHAT: Estimate monthly inference cost across cloud providers
# WHY: "Just use Groq" sounds right until you model your actual token volume
# GOTCHA: VM costs assume the VM runs continuously; use hours_per_day to model
# cost for dev environments that only run during business hours
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class ProviderCost:
name: str
# For managed APIs: cost per 1 million input tokens
input_cost_per_mtok: Optional[float] = None
# For managed APIs: cost per 1 million output tokens
output_cost_per_mtok: Optional[float] = None
# For self-hosted VMs: cost per hour (compute only, no per-token fee)
hourly_rate: Optional[float] = None
notes: str = ""
PROVIDER_COSTS = [
ProviderCost("Ollama/Localhost", hourly_rate=0.0, notes="Existing hardware, electricity not counted"),
ProviderCost("GCP T4 VM", hourly_rate=0.35, notes="n1-standard-4 + T4; no per-token cost"),
ProviderCost("AWS g4dn.xlarge", hourly_rate=0.53, notes="On-demand; no per-token cost"),
ProviderCost("Vast.ai 3090", hourly_rate=0.20, notes="Spot price; availability varies"),
ProviderCost("Lambda Labs A10", hourly_rate=0.60, notes="Reserved; guaranteed availability"),
ProviderCost("Groq (free)", input_cost_per_mtok=0.0, output_cost_per_mtok=0.0, notes="14,400 req/day limit"),
ProviderCost("Groq (paid)", input_cost_per_mtok=0.05, output_cost_per_mtok=0.08, notes="Llama 3.3 70B pricing"),
ProviderCost("Together AI", input_cost_per_mtok=0.10, output_cost_per_mtok=0.10, notes="Mistral 7B pricing"),
ProviderCost("Fireworks AI", input_cost_per_mtok=0.10, output_cost_per_mtok=0.10, notes="Mistral 7B pricing"),
]
@dataclass
class EstimateResult:
provider: str
monthly_cost_usd: float
notes: str
recommended: bool = False
def estimate_monthly_costs(
tokens_per_day: int,
output_ratio: float = 0.3, # fraction of tokens that are output
hours_per_day: float = 24.0, # for VM-based providers: hours VM runs per day
) -> list[EstimateResult]:
"""
Estimate monthly costs across all providers.
Args:
tokens_per_day: Total tokens processed per day (input + output combined).
output_ratio: Fraction of tokens_per_day that are output tokens (0.0–1.0).
Defaults to 0.3 (typical for RAG / summarisation workloads).
hours_per_day: How many hours per day VM-based providers are running.
Use 8.0 for a dev machine, 24.0 for production.
Returns:
List of EstimateResult sorted by monthly cost.
"""
input_tokens_per_day = tokens_per_day * (1 - output_ratio)
output_tokens_per_day = tokens_per_day * output_ratio
days_per_month = 30.4 # average
results: list[EstimateResult] = []
for p in PROVIDER_COSTS:
if p.hourly_rate is not None:
# VM cost: hourly rate × hours/day × days/month
monthly = p.hourly_rate * hours_per_day * days_per_month
else:
# Managed API: per-token billing
input_mtok_per_month = (input_tokens_per_day * days_per_month) / 1_000_000
output_mtok_per_month = (output_tokens_per_day * days_per_month) / 1_000_000
monthly = (
(p.input_cost_per_mtok or 0) * input_mtok_per_month +
(p.output_cost_per_mtok or 0) * output_mtok_per_month
)
results.append(EstimateResult(
provider=p.name,
monthly_cost_usd=round(monthly, 2),
notes=p.notes,
))
results.sort(key=lambda r: r.monthly_cost_usd)
# Mark the lowest non-zero cost option as recommended
for r in results:
if r.monthly_cost_usd > 0:
r.recommended = True
break
return results
def print_estimate(tokens_per_day: int, hours_per_day: float = 24.0) -> None:
"""Print a formatted cost comparison table."""
results = estimate_monthly_costs(tokens_per_day, hours_per_day=hours_per_day)
total_mtok_month = tokens_per_day * 30.4 / 1_000_000
print(f"\nMonthly cost estimate for {tokens_per_day:,} tokens/day")
print(f"({total_mtok_month:.1f}M tokens/month, VM uptime: {hours_per_day:.0f} hr/day)")
print(f"{'Provider':<22} {'$/month':>9} {'Notes'}")
print("-" * 70)
for r in results:
tag = " ← recommended" if r.recommended else ""
print(f" {r.provider:<20} ${r.monthly_cost_usd:>8.2f} {r.notes}{tag}")
if __name__ == "__main__":
# Dev workload: 100K tokens/day, VM only 8 hours/day
print_estimate(tokens_per_day=100_000, hours_per_day=8)
# Production workload: 1M tokens/day, VM runs 24/7
print_estimate(tokens_per_day=1_000_000, hours_per_day=24)
At 100K tokens/day, managed APIs beat VMs on cost by 100x — use Groq free tier for dev. But at 10M tokens/day, the per-token fees on paid managed APIs reach $1,200/month while a single A10 VM costs $146/month regardless of token volume. The crossover point is typically around 2–5M tokens/month where self-hosted VMs become the cheaper option. The estimator above helps you find your exact crossover.
Knowledge Check
1. You open an SSH tunnel with ssh -L 11434:localhost:11434 -N -f ubuntu@<VM_IP>. Your agent code uses base_url="http://localhost:11434/v1". What changes?
2. Why must you add --maintenance-policy=TERMINATE when creating a GCP VM with a GPU?
3. You set OLLAMA_HOST=127.0.0.1:11434 in the systemd unit file. What is the security benefit?
4. Your app uses LocalModelClient.create(). You want all CI runs to use Groq instead of Ollama. What is the minimal change?
OpenAI(base_url=...) call in the codebase to use Groq's URL.groq_client.py file and update all imports in CI.LOCAL_MODEL_PROVIDER=groq and GROQ_API_KEY=<key> as CI environment variables — no code changes needed.provider="groq" as an argument to every agent function call.5. At what approximate monthly token volume does self-hosting an A10 GPU VM typically become cheaper than paying Groq per-token rates?
6. Which managed provider currently offers the lowest first-token latency (~50 ms) due to custom LPU hardware?
Module complete! Head to M24 to explore the frontier of open-source agents.