hands-on labIDE in your browser

Build an LLM Gateway: One Door Between Your Apps and the Model

Put a gateway you wrote in front of the model and give it the four floor capabilities every LLM feature needs: API keys with a request log, a price on every call, per-app rate limits and daily budgets, an exact-match response cache, retries with backoff and idempotency keys, model routing with a fallback, and a report over a replayed morning of traffic that shows who spent what.

time
90 min
checked steps
8
level
Intermediate
setup
None

$29.99a month with Pro

Pro opens all 8 steps of this lab and every other lab. Cancel any time.

no account needed · Pro opens all 8 steps

See Pro plans
Lab cockpit90 min · 8 stepsSession running
4 / 8 steps passingAn exact-match cache that cannot collide · step 5 of 8
gateway.py▶ Run✓ Check
       def cache_get(self, ck, now):            def cache_put(self, ck, data, now): 
TerminalOutput
step 5 of 8, replayed: An exact-match cache that cannot collide. The code, the output and the check are the lab's own.

The job

Three Northwind Cloud apps call the model directly with one shared provider key: a support bot, a search summariser and an internal evaluation job. Last month the bill doubled, nobody could say which app did it, the summariser hit the provider's rate limit and took the support bot down with it, and a retry loop charged one customer's question eleven times. You build the gateway all three apps will go through instead: keys, a log, prices, limits, a cache, retries, routing and a report. A fake upstream with a controllable clock replays the failures so every safeguard is tested without waiting for them to happen live.

8 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Pass-through: one door, and the model behind it

    Northwind's three apps call the model with one shared provider key, from three codebases, with three different ideas about retries.

    read this step below ↓

  2. 2

    Keys and the log: who is calling, and a record of every call

    The architecture lecture's floor has four capabilities, and two of them are this step.

  3. 3

    Price every call on the model that was actually served

    The bill is the sum of every call's tokens times the model's rate.

  4. 4

    Rate limits and daily budgets: stop the spend before it happens

    The provider has rate limits, but they protect the provider.

  5. 5

    An exact-match cache that cannot collide

    The support bot answers "How do I reset my password?" a few hundred times a day.

  6. 6

    Retries with backoff, and idempotency keys so clients can retry too

    Providers fail in three ways that deserve a retry and one that does not.

  7. 7

    Route by prompt and tier, fall back when a model is down

    Apps have been sending fast and smart since Step 1, and the gateway has been resolving those aliases to model ids.

  8. 8

    Replay a morning of traffic and report who spent what

    traffic.jsonl is a recorded morning: the support bot asking its usual questions (some of them twice), the search summariser bursting past its rate limit and then grinding through long documents on a tiny budget, the internal evaluation job hitting an upstream that fails twice, a request on the revoked key and one with no key at all.

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Pass-through: one door, and the model behind it

Northwind's three apps call the model with one shared provider key, from three codebases, with three different ideas about retries. The bill doubled last month and nobody can say which app did it. The fix is one door: a gateway every app calls instead of the provider, so that everything you add in the next seven steps (keys, a log, prices, limits, a cache, retries, routing) happens once, in one place, for all of them.

Open gateway.py. Gateway.handle() is already the full pipeline: it authenticates, routes, checks the idempotency store and the cache, applies limits, calls the upstream with retries and a fallback, prices the answer, logs a record and responds. Every stage delegates to a small method, and most of those methods are stubs that later steps fill in. Right now the gateway cannot even reach the model, because call_once() is the stub for this step.

upstream.py gives you two upstreams with the same interface. Upstream talks to the lab's model proxy over HTTP. FakeUpstream answers instantly with deterministic text and token counts, can be scripted to fail, and moves a fake clock, so the failure, limit and cache steps are exact and free. server.py puts Gateway.handle() behind a plain HTTP server, and client.py is the app: it sends a chat completion and prints the status, the gateway's response headers and the answer.

do this

1. Implement Gateway.call_once(body, model). Copy the request body, replace its model with the model the router resolved (clients send aliases such as fast and smart; the upstream needs the real id) and return self.upstream.chat(payload). Errors propagate: the retry step will catch them.

2. Run. The first command starts the gateway against the live model and sends one question through it. The second starts it with --fake and sends the same question to the fake upstream. Both print HTTP 200, an x-request-id, an x-model-served header, and an answer. Notice the served name: the provider reports meta-llama/llama-3.1-8b-instruct for a request that asked for meta/llama-3.1-8b-instruct. Step 3 prices on the served name for exactly that reason.

3. Read handle() once, top to bottom. You will not change it. Each later step fills in one of the methods it calls, and the order of those calls is the design: the cheapest checks first, the upstream call last.

Why this matters in practice

Gateways are sold as products with long feature lists. Every one of those features is a method in this file. The pipeline order is the part vendors rarely explain: authentication before anything else, the cache before the rate limiter (a cached answer costs nothing, so it should not spend a slot), the limiter before the upstream call (the point is to stop spend before it happens), pricing and logging after, on every path, including the failures.

gateway.py, the file you edit225 lines
"""gateway.py: one door between Northwind's apps and the model.

    python3 server.py --port 8088            # real upstream (the lab's model proxy)
    python3 server.py --port 8088 --fake     # deterministic fake upstream
    python3 client.py --key nw-sk-internal-91c0 --model fast "What does a gateway do?"

Every request goes through Gateway.handle(): authenticate -> route -> idempotency ->
cache -> limits -> upstream (with retries and fallback) -> price -> log -> respond.
"""
import hashlib
import json
import random
import time
import uuid
from collections import deque
from dataclasses import dataclass, field

import yaml

from upstream import UpstreamError, UpstreamTimeout, estimate_tokens

MODELS = {"fast": "meta/llama-3.1-8b-instruct", "smart": "meta/llama-3.3-70b-instruct"}
FALLBACKS = {MODELS["fast"]: MODELS["smart"], MODELS["smart"]: MODELS["fast"]}
RETRYABLE = {408, 429, 500, 502, 503, 504}
ANONYMOUS = {"key": None, "user": None, "app": None, "tier": "open", "rpm": None,
             "daily_budget_usd": None, "models": None, "active": True}


@dataclass
class Request:
    method: str
    path: str
    headers: dict = field(default_factory=dict)   # lower-case header names
    body: dict = None


@dataclass
class Response:
    status: int
    body: dict
    headers: dict = field(default_factory=dict)


def error(status, kind, message, headers=None):
    return Response(status, {"error": {"type": kind, "message": message}}, dict(headers or {}))


def load_keys(path="keys.json"):
    keys = json.load(open(path))
    for k, rec in keys.items():
        rec["key"] = k
    return keys


def load_pricing(path="pricing.yaml"):
    return yaml.safe_load(open(path))


class Gateway:
    def __init__(self, upstream, keys=None, pricing=None, log_path="gateway.jsonl", cache_ttl=300,
                 max_attempts=3, base_delay=0.2, auto_threshold_tokens=800,
                 clock=time.time, sleep=time.sleep, rng=None):
        self.upstream = upstream
        self.keys = keys if keys is not None else load_keys()
        self.pricing = pricing if pricing is not None else load_pricing()
        self.log_path = log_path
        self.cache_ttl = cache_ttl
        self.max_attempts = max_attempts
        self.base_delay = base_delay
        self.auto_threshold_tokens = auto_threshold_tokens
        self.clock, self.sleep, self.rng = clock, sleep, (rng or random.Random())
        self.windows = {}      # key -> deque of upstream-call timestamps (rate limit window)
        self.spend = {}        # key -> {"day": int, "usd": float}
        self.cache = {}        # cache key -> {"ts": float, "data": dict}
        self.idempotent = {}   # (key, idempotency-key) -> Response

    # ------------------------------------------------------------------ entry point
    def handle(self, req):
        t0 = self.clock()
        rec = {"request_id": uuid.uuid4().hex[:12], "ts": round(t0, 3), "method": req.method, "path": req.path,
               "user": None, "app": None, "model": None, "served": None, "served_by": None, "status": None,
               "latency_ms": None, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
               "cache": None, "attempts": 0, "route": None, "error": None}
        try:
            resp = self._dispatch(req, rec)
        except NotImplementedError as e:
            resp = error(501, "not_implemented", str(e))
        except Exception as e:  # a gateway must answer even when it is broken
            resp = error(500, "internal", f"{type(e).__name__}: {e}")
        rec["status"] = resp.status
        rec["latency_ms"] = round((self.clock() - t0) * 1000, 1)
        if resp.status >= 400 and not rec["error"]:
            rec["error"] = resp.body.get("error", {}).get("type", "error")
        resp.headers.setdefault("x-request-id", rec["request_id"])
        self.log(rec)
        return resp

    def _dispatch(self, req, rec):
        if req.method == "GET" and req.path == "/health":
            return Response(200, {"status": "ok"})
        key = self.authenticate(req.headers)
        if key is None:
            return error(401, "auth", "missing, unknown or revoked API key; send Authorization: Bearer <key>")
        rec["user"], rec["app"] = key["user"], key["app"]
        if req.method == "GET" and req.path == "/v1/usage":
            return Response(200, self.usage(key))
        if req.method != "POST" or req.path != "/v1/chat/completions":
            return error(404, "not_found", f"{req.method} {req.path} is not served by this gateway")
        body = req.body
        if not isinstance(body, dict) or not isinstance(body.get("messages"), list) or not body["messages"]:
            return error(400, "invalid_request", "body must be a JSON object with a non-empty messages list")
        if body.get("stream"):
            return error(400, "invalid_request", "this gateway does not stream; remove stream from the request")
        rec["model"] = body.get("model") or "auto"

        route = self.route(body, key)
        if isinstance(route, Response):
            return route
        rec["route"] = route
        model = route["primary"]

        idem = req.headers.get("idempotency-key")
        if idem:
            replay = self.idempotent.get((key["key"], idem))
            if replay is not None:
                rec["cache"], rec["served"] = "idempotent", replay.headers.get("x-model-served")
                return Response(replay.status, replay.body, dict(replay.headers, **{"x-idempotent-replay": "true"}))

        ck = self.cache_key(body, model)
        bypass = "no-cache" in req.headers.get("cache-control", "")
        hit = None if bypass else self.cache_get(ck, self.clock())
        if hit is not None:
            rec["cache"], rec["served"] = "hit", hit.get("model")
            return Response(200, hit, {"x-cache": "HIT", "x-cost-usd": "0.00000000",
                                       "x-model-served": hit.get("model", ""), "x-gateway-user": key["user"]})
        rec["cache"] = "bypass" if bypass else "miss"

        limited = self.check_limits(key, self.clock())
        if limited is not None:
            return limited

        status, data, attempts = self.call_with_retries(body, model)
        rec["attempts"], served_by = attempts, "primary"
        pass  # the fallback model arrives in Step 07
        if status != 200:
            rec["error"] = "upstream"
            msg = data.get("error", {}).get("message") if isinstance(data, dict) else None
            return error(502, "upstream", f"upstream returned {status}" + (f": {msg}" if msg else ""),
                         {"x-upstream-status": str(status), "x-upstream-attempts": str(rec["attempts"])})

        usage = data.get("usage") or {}
        served = data.get("model") or model
        pt, ct = usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0)
        cost = self.price(served, usage)
        rec.update(served=served, served_by=served_by, prompt_tokens=pt, completion_tokens=ct, cost_usd=cost)
        self.record_spend(key, cost, self.clock())
        self.cache_put(ck, data, self.clock())
        resp = Response(200, data, {"x-cache": "BYPASS" if bypass else "MISS", "x-model-served": served,
                                    "x-served-by": served_by, "x-cost-usd": f"{cost:.8f}", "x-tokens": f"{pt}+{ct}",
                                    "x-upstream-attempts": str(rec["attempts"]), "x-gateway-user": key["user"]})
        if idem:
            self.idempotent[(key["key"], idem)] = resp
        return resp

    # ------------------------------------------------------------------ Step 1: the call
    def call_once(self, body, model):
        """One upstream call with `model` substituted for whatever alias the client sent.
        Returns the upstream JSON; raises UpstreamError / UpstreamTimeout."""
        # TODO (Step 01): payload = dict(body, model=model); return self.upstream.chat(payload)
        raise NotImplementedError("Step 01: implement Gateway.call_once()")

    # ------------------------------------------------------------------ Step 2: keys and the log
    def authenticate(self, headers):
        """Return the key record for `Authorization: Bearer <key>`, or None."""
        return dict(ANONYMOUS)  # everyone is anonymous until Step 02

    def log(self, rec):
        """Append one JSON line per request to self.log_path."""
        pass  # the request log arrives in Step 02

    # ------------------------------------------------------------------ Step 3: price and account
    def price(self, model, usage):
        """USD for one call: tokens / 1e6 * the per-million rate for the SERVED model name."""
        return 0.0  # pricing arrives in Step 03

    def record_spend(self, key, cost, now):
        """Add `cost` to the key's spend for today (UTC day = int(now // 86400))."""
        pass  # spend accounting arrives in Step 03

    def usage(self, key):
        """What GET /v1/usage returns for the calling key."""
        return {"user": key["user"], "app": key["app"], "note": "usage accounting arrives in Step 03"}

    # ------------------------------------------------------------------ Step 4: limits and budgets
    def check_limits(self, key, now):
        """None if the call may proceed, else a 429 Response. Counts only calls that reach the upstream."""
        return None  # limits arrive in Step 04

    # ------------------------------------------------------------------ Step 5: exact-match cache
    def cache_key(self, body, model):
        """sha256 of the canonical JSON of everything that changes the answer."""
        return None  # caching arrives in Step 05

    def cache_get(self, ck, now):
        return None  # caching arrives in Step 05

    def cache_put(self, ck, data, now):
        pass  # caching arrives in Step 05

    # ------------------------------------------------------------------ Step 6: retries
    def call_with_retries(self, body, model):
        """(status, data, attempts). Retries RETRYABLE statuses and timeouts with exponential
        backoff and jitter, honours retry-after, never retries other 4xx."""
        try:
            return 200, self.call_once(body, model), 1
        except UpstreamTimeout as e:
            return 504, {"error": {"type": "upstream_timeout", "message": str(e)}}, 1
        except UpstreamError as e:
            return e.status, e.body, 1

    # ------------------------------------------------------------------ Step 7: routing
    def route(self, body, key):
        """{"requested", "primary", "fallback", "reason"} or a 403 Response."""
        requested = body.get("model") or "auto"
        return {"requested": requested, "primary": MODELS.get(requested, requested), "fallback": None, "reason": "passthrough"}

provided for you:client.pykeys.jsonpricing.yamlserver.pyupstream.py

what you get

  • An editor and terminal in your browser
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Python: classes, dicts, json, try/except, a while loop
  • HTTP basics: status codes, headers, JSON bodies
  • Read the module's caching, rate-limit and architecture lectures first

Exam domains covered

Using LLMs via APILLM OperationsCost Optimization

Part of the LLMOps and MLOps track

Lab 7 of 22. vLLM serving, load tests against SLOs, tracing, drift monitoring and prompt tests in CI.

Questions about this lab

Do I need FastAPI, LiteLLM or another gateway product for this lab?

No. The gateway is a Python class behind the standard library's HTTP server, and the model is reached through the OpenAI-compatible chat completions endpoint. Knowing what the class does makes any commercial gateway's feature list readable.

Which model does the lab call?

Hosted Llama 3.1 8B and Llama 3.3 70B instruct models behind the lab's proxy for the live steps. A deterministic fake upstream with a controllable clock stands in for the failure, rate-limit, cache and replay steps, so those checks are exact and cost nothing.

How is cost attributed per app?

Every request carries an API key that maps to a user and an app. The gateway prices each call from the usage the provider returns, keyed on the model name the provider actually served, writes it to the request log, and the report rolls spend up per app and per user.

What stops a client retry from charging twice?

An Idempotency-Key header. The gateway stores the response for that key and replays it on a repeat, so a client that retries after a network error gets the same answer without a second upstream call.

Why put a gateway between your apps and the model

The demo architecture is a browser, an arrow and a provider. The first production architecture adds one box in the middle, and that box is where authentication, logging, rate limiting and observability live. Commercial gateways sell those four things plus caching, retries and routing. Building a small one yourself, on the plain chat completions API, shows what each of those features costs to get right and makes the commercial ones easy to evaluate. The lab is a single Python class behind a plain HTTP server. Each step adds one capability and tests it against a deterministic fake upstream: keys that are attributed and revoked, a request log, prices keyed on the model the provider actually served, a sliding rate-limit window and a daily budget, an exact-match cache that must not collide across users, exponential backoff that honours retry-after and stops for non-retryable errors, idempotency keys that make client retries safe, routing by prompt size and tier with a fallback model, and a per-app report over a replayed morning of traffic with cache hit rates, spend and p95 latency.