Building AI Agents with Open Source Models
Track 7: Production & Deployment OS Track · Module 23 of 24
⏱ 85 min 📊 Intermediate
Open Source Track — M21B All code uses 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 LocalModelClient provider-agnostic wrapper driven by an env var
  • Estimate monthly inference cost across providers from a tokens/day budget

Why Move From Local to Cloud?

Analogy — The Sleeping Analyst

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.

Why It Matters — Real Numbers

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.

8
tok/s on M2 Pro
(CPU, Mistral 7B-Q4)
40
tok/s on T4 GPU
(GCP, $0.35/hr)
80
tok/s on RTX 3090
(Vast.ai, $0.20/hr)
The One-Env-Var Promise

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
Cost vs. Speed — Provider Comparison
Speed (tok/s, scaled)
Cost ($/hr, scaled)
Bar chart: Vast.ai 80 tok/s $0.20/hr, RunPod 80 tok/s $0.22/hr, GCP 40 tok/s $0.35/hr, AWS 40 tok/s $0.53/hr, Lambda 100 tok/s $0.60/hr, CoreWeave 200 tok/s $2.21/hr
Cost Framing

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.

The comparison gives you the map. Now let’s walk through the actual steps for the two most common cloud providers: GCP and AWS. Each lab produces a running Ollama instance reachable from your dev machine via an SSH tunnel — no firewall rules, no public ports, no exposure.

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.

Prerequisites

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
WHAT: Creates a 4-vCPU VM with a T4 GPU, starts the CUDA driver installation via startup-script, and pulls Mistral 7B
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)
WHAT: Writes a systemd unit file that runs 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", ...}]}
SSH Tunnel — Traffic Flow
💻
Your Laptop
localhost:11434
Encrypted SSH
GCP VM
SSH daemon
loopback only
🦎
Ollama
127.0.0.1:11434

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.

Laptop :11434 → Encrypted SSH → GCP VM SSH daemon → loopback → Ollama 127.0.0.1:11434. 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.

WHAT: Verify the cloud Ollama connection works with the same code used in M01
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();
What Just Happened?

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.

Prerequisites

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"
WHAT: Creates an SSH-only security group, then launches a g4dn.xlarge with 50 GB gp3 SSD
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",...}]}
WHAT: Connection test — byte-for-byte identical to the GCP test
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
Security: Never Open Port 11434 to the Internet

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.

Managing your own VM is powerful but comes with operational overhead: you patch the OS, monitor disk space, and restart Ollama after crashes. For teams that want the open-source model advantage without the DevOps work, managed OpenAI-compatible APIs are the answer.

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".

WHAT: Four managed providers that accept OpenAI SDK requests, each with different strengths
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
When to Use Managed vs. Self-Hosted

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 Selection — Decision Tree
Can data leave your infra?
NO →
Self-host only
Ollama on VM (GCP / AWS)
Yes. Need >70B model?
YES →
Together AI / Fireworks
Speed or cost priority?
SPEED →
Groq (free tier)
COST →
Vast.ai / RunPod
Decision: Data must stay in-house → Ollama on VM. Data can leave → Need 70B+ → Together/Fireworks. Speed priority → Groq. Cost priority → Vast.ai/RunPod.

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.

WHAT: A factory class that reads LOCAL_MODEL_PROVIDER and returns a configured OpenAI client
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
What Just Happened?

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)
Sample output (100K tokens/day, 8 hr/day VM):
Monthly cost estimate for 100,000 tokens/day (3.0M tokens/month, VM uptime: 8 hr/day) ---------------------------------------------------------------------- Provider $/month Notes Ollama/Localhost $0.00 Existing hardware, electricity not counted Groq (free) $0.00 14,400 req/day limit ← recommended Groq (paid) $0.12 Llama 3.3 70B pricing Together AI $0.30 Mistral 7B pricing Fireworks AI $0.30 Mistral 7B pricing Vast.ai 3090 $48.64 Spot price; availability varies GCP T4 VM $85.12 n1-standard-4 + T4; no per-token cost AWS g4dn.xlarge $128.93 On-demand; no per-token cost Lambda Labs A10 $145.92 Reserved; guaranteed availability
Reading the Numbers

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?

A
Your agent code must be updated to use the VM's IP address as the base_url.
B
Nothing changes in the agent code — requests to localhost:11434 are transparently forwarded to the VM's Ollama through the SSH channel.
C
You must open port 11434 in the VM's firewall for the tunnel to work.
D
The SSH tunnel only works when Ollama is bound to 0.0.0.0, not 127.0.0.1.

2. Why must you add --maintenance-policy=TERMINATE when creating a GCP VM with a GPU?

A
It makes the VM terminate automatically after 24 hours to save costs.
B
It enables the CUDA drivers to install correctly during first boot.
C
GCP VMs with attached GPUs do not support live migration — TERMINATE tells GCP to stop the VM instead of trying to migrate it during host maintenance.
D
It is required to access GPU instances in the us-central1-a zone.

3. You set OLLAMA_HOST=127.0.0.1:11434 in the systemd unit file. What is the security benefit?

A
Ollama runs faster when bound to the loopback interface.
B
Ollama is only reachable from within the VM itself — not from the internet, even if a firewall rule is misconfigured — because the loopback interface is not routable.
C
It encrypts traffic between the SSH tunnel and Ollama.
D
The SSH tunnel requires Ollama to be on 127.0.0.1 to function.

4. Your app uses LocalModelClient.create(). You want all CI runs to use Groq instead of Ollama. What is the minimal change?

A
Update every OpenAI(base_url=...) call in the codebase to use Groq's URL.
B
Create a separate groq_client.py file and update all imports in CI.
C
Set LOCAL_MODEL_PROVIDER=groq and GROQ_API_KEY=<key> as CI environment variables — no code changes needed.
D
Pass 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?

A
100K tokens/month — VMs are almost always cheaper than APIs.
B
500K tokens/month.
C
2–5M tokens/month — below that threshold, per-token managed APIs are cheaper; above it, the flat VM hourly rate wins.
D
50M tokens/month — managed APIs are almost always cheaper at any realistic usage.

6. Which managed provider currently offers the lowest first-token latency (~50 ms) due to custom LPU hardware?

A
Together AI
B
Groq — their Language Processing Unit (LPU) custom silicon delivers ~50 ms TTFT, significantly faster than GPU-based providers.
C
Fireworks AI
D
Amazon Bedrock
0 / 6

Module complete! Head to M24 to explore the frontier of open-source agents.