Stand up the first slice of an AI-ready platform: serve a small open model behind an OpenAI-compatible endpoint, then front it with an inference gateway (LiteLLM) that gives you virtual keys, per-key budgets, a model allowlist, and automatic fallback to a hosted API when the local model is down.
Load-test it and read the two numbers that define serving performance — time-to-first-token (TTFT) and tokens-per-second (TPS) — plus a cost figure.
This is capstone lab 1 of 3. The gateway you build here is the exact system Lab 14 instruments with tracing and guardrails, and Lab 15 exposes and gates via MCP + prompt CI. Build the config cleanly — you will live in it for two more labs.
Advanced · ~90 minutes hands-on. The ₹0 path uses Ollama (or vLLM CPU-mode) for the local model and a hosted API only as the fallback route (a few paise). A GPU serving path is desk-checked against docs.
Self-contained. Python 3.11+, and an Anthropic API key for the fallback route.
mkdir ai-platform && cd ai-platform
python -m venv .venv && source .venv/bin/activate
pip install "litellm[proxy]" httpx
export ANTHROPIC_API_KEY="sk-ant-..."
You need a local OpenAI-compatible model server. Pick the path that matches your hardware:
curl -fsSL https://ollama.com/install.sh | sh then ollama pull qwen2.5:0.5b && ollama serve. Ollama exposes an OpenAI-compatible API at http://localhost:11434/v1.pip install vllm then vllm serve Qwen/Qwen2.5-0.5B-Instruct --device cpu --port 8001. Serves at http://localhost:8001/v1. CPU decode is slow but real — good for reading TTFT/TPS behaviour under batching.vllm serve command without --device cpu gives production-shaped numbers. Verify the current vllm serve flags against the vLLM docs before renting GPU time.Verify the LiteLLM config.yaml schema, virtual-key endpoints, and fallback syntax against the current LiteLLM proxy docs — the proxy surface moves. The shapes below are the standard form at time of writing.
Confirm the local model answers. With Ollama running:
curl -s http://localhost:11434/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"qwen2.5:0.5b","messages":[{"role":"user","content":"say hi"}]}' \
| python -m json.tool
You should get a completion. This is your "backend model" — the thing the gateway sits in front of.
Write the gateway config. This one file is the whole point of the lab: it declares the local model, a hosted fallback, a model allowlist, and the fallback routing. Create config.yaml:
model_list:
- model_name: platform-small # the name callers use
litellm_params:
model: openai/qwen2.5:0.5b # openai/ prefix = OpenAI-compatible backend
api_base: http://localhost:11434/v1
api_key: "ollama" # Ollama ignores it; LiteLLM requires a value
- model_name: platform-fallback
litellm_params:
model: anthropic/claude-haiku-4-5
api_key: os.environ/ANTHROPIC_API_KEY
litellm_settings:
fallbacks: [{"platform-small": ["platform-fallback"]}]
general_settings:
master_key: sk-master-CHANGEME # admin key that mints virtual keys
The fallbacks block is the reliability feature: if platform-small errors or is unreachable, the gateway retries the request on platform-fallback transparently — the caller's key and request are unchanged.
Run the gateway.
litellm --config config.yaml --port 4000
It comes up on http://localhost:4000, speaking the OpenAI API. Every request now flows: caller → LiteLLM (key check, budget, routing) → model.
Mint a virtual key with a budget and a model allowlist. Callers never get the master key. Each team gets a scoped virtual key:
curl -s http://localhost:4000/key/generate \
-H "Authorization: Bearer sk-master-CHANGEME" \
-H "content-type: application/json" \
-d '{"models": ["platform-small"], "max_budget": 0.50, "key_alias": "team-search"}' \
| python -m json.tool
The response contains a key like sk-.... It can call only platform-small, and once it has spent $0.50 the gateway refuses further requests. Save the key as TEAM_KEY.
Call through the gateway with the virtual key.
curl -s http://localhost:4000/v1/chat/completions \
-H "Authorization: Bearer $TEAM_KEY" -H 'content-type: application/json' \
-d '{"model":"platform-small","messages":[{"role":"user","content":"one-line SRE tip"}]}'
Then prove the allowlist bites: swap platform-small for platform-fallback in the body and confirm the virtual key is rejected — it isn't allowlisted for that model.
Load-test it and read TTFT/TPS. Create loadtest.py — it fires N concurrent streaming requests and records time-to-first-token and tokens-per-second per request:
import asyncio, time, os, httpx
KEY = os.environ["TEAM_KEY"]; URL = "http://localhost:4000/v1/chat/completions"
async def one(client, i):
body = {"model": "platform-small", "stream": True,
"messages": [{"role": "user", "content": f"Summarize incident {i} in 2 lines"}]}
start = time.perf_counter(); first = None; toks = 0
async with client.stream("POST", URL, json=body,
headers={"Authorization": f"Bearer {KEY}"}) as r:
async for line in r.alines():
if line.startswith("data:") and "content" in line:
if first is None: first = time.perf_counter()
toks += 1
end = time.perf_counter()
ttft = (first or end) - start
tps = toks / (end - (first or end)) if first and end > first else 0
return ttft, tps
async def main(n=20):
async with httpx.AsyncClient(timeout=60) as c:
res = await asyncio.gather(*(one(c, i) for i in range(n)))
ttfts = sorted(x[0] for x in res); tpss = [x[1] for x in res]
print(f"n={n} TTFT p50={ttfts[n//2]:.2f}s p95={ttfts[int(n*0.95)]:.2f}s "
f"mean TPS={sum(tpss)/n:.1f}")
asyncio.run(main())
Run python loadtest.py. Record the numbers — they are your artifact and the Lab 14 baseline. On CPU the TPS will be single-digit; that's expected and the point — you're learning to read the numbers, not to hit a target.
Prove the fallback fires. Stop Ollama (Ctrl-C its terminal) and re-run one gateway request. It should still return a valid completion — served by platform-fallback — demonstrating the gateway's reliability layer. Restart Ollama afterward.
The gateway, not the model server, is the platform surface. Virtual keys, budgets, allowlists, and fallback are what turn "a model on a port" into something you can safely offer 300 developers — and each is a seam Labs 14 and 15 pull on.
platform-small successfully and is rejected when it tries platform-fallback (allowlist enforced).loadtest.py prints p50/p95 TTFT and a mean TPS across 20 concurrent requests.max_budget to a tiny value, spend it, and confirm the gateway returns a budget-exceeded error.The ai-platform/ folder (config.yaml, loadtest.py) plus BASELINE.md recording the backend used, p50/p95 TTFT, mean TPS, and one line confirming the fallback and budget both fired. Keep this folder — Labs 14 and 15 build directly on it. Self-grade (pass at 4/5):
model not found: the openai/ prefix and api_base must both point at your backend; for Ollama the model name is qwen2.5:0.5b, for vLLM it's the HF repo id you served.401: you used the master key in the Authorization header of /v1/chat/completions instead of the minted virtual key. Master key is only for /key/generate.fallbacks list uses the model_name values, and that the error is a connection error (a 400 from a bad request won't trigger a fallback).n=5; CPU decode serialises heavily and 20 concurrent 0.5B requests can saturate a laptop.This architecture is the client-data-compliance answer for services companies: the gateway lets you keep all inference on a client-approved backend (a model served inside the client VPC, or Bedrock in their tenancy) while the fallback and every other feature stay identical. Point platform-small at the client's model, set the fallback to another client-sanctioned endpoint rather than the public API, and use per-team virtual keys to enforce which client's data each team's key may touch. Record "which backend serves which client" in BASELINE.md as a design decision — it's the artifact an MSA review will ask for.