genaipros← The path
Line B · Build06 · Production APIs for GenAI
S0STAGE OVERVIEW

Production APIs for GenAI Services

You already know how tokens get made — TTFT and ITL, prefill vs decode, KV and prefix caches, continuous batching, and how KServe layers a model onto Kubernetes. This stage teaches the layer in front of all that: the production service that clients actually talk to. Five sessions, one boundary, one running lab project built against your Modal-hosted Gemma endpoint.

The one idea that frames everything

App layer vs model server — the boundary

In an earlier stage you learned that hand-wrapping an inference engine in FastAPI is an anti-pattern — "you're rebuilding a model server badly." That lesson was about where inference lives. This stage is the correct version of the instinct behind it: you still write a FastAPI service, but it never touches a GPU. It sits in front of a real model server (your vLLM endpoint on Modal) and owns everything the model server deliberately does not: identity, validation, product logic, streaming to your clients, caching, limits, resilience, tests, and deployment. The primary track calls this the "Be Lean: serve models externally" strategy : FastAPI becomes "the logical layer between your client and the external model server."

CLIENT browser / app / another service renders stream reconnect UX no secrets YOUR APP LAYER · FastAPI this stage · CPU-only · scales like any web app S1 auth · validation · typed contracts · routing S2 event loop · async I/O · background work S3 SSE / WebSocket streaming to clients S4 rate limits · caches · resilience · budgets S5 tests · load tests · container · gateways MODEL SERVER vLLM on Modal · prior stages /v1/chat/completions continuous batching KV + prefix cache FP8 quantization scheduling / preemption token streaming out GPU L4 24 GB prefill decode HTTPS+SSE httpx CUDA ▲ THE BOUNDARY — everything left of this line is this stage
Hover (or tap) any box to see what it owns — and which session teaches it.
client app layer (this stage) model server (done stages) the boundary

This color code is used in every diagram in every session: amber = your app layer, cyan = the model server. When a diagram gets complicated, follow the colors back to the boundary.

Resolving your earlier lesson — the litmus test

"Don't wrap an engine in FastAPI" means: if deleting your FastAPI code and calling the model server directly would lose nothing, your layer shouldn't exist. The service you build in this stage fails that deletion test on purpose — remove it and you lose auth, contracts, product streaming shape, caching, rate limits, budget control, and resilience. That's the difference between "rebuilding a model server badly" and building the layer model servers expect you to bring.

What you'll master
5
sessions, each: 3-pass concepts → decision tree → reality check → lab on your stack
2 sources
applied GenAI service engineering (the FastAPI text, 2025) as primary; API design fundamentals (standard references, 2025) as dip-in reference
1 lab
gemma-gateway — a production service layer built session-by-session in front of your gemma_modal.py vLLM endpoint
The session map
How your prior knowledge plugs in

you know Inference internals

TTFT/ITL, prefill vs decode, KV cache, batching, prefix caching. This stage uses them as levers you pull from the app side: prompt ordering for prefix-cache hits (S4), TTFT/ITL as load-test SLOs (S5), decode speed as the thing that sets your stream's pace (S3).

you know K8s serving & KServe

The 3-layer serving model maps directly: KServe's predictor ≈ your Modal vLLM function; the transformer/ingress layers ≈ what you now build by hand — so you understand it, and so you can judge gateway products in S5 instead of trusting vendor decks.

your stack gemma_modal.py

Gemma 4 E4B on vLLM 0.21, L4 GPU, scale-to-zero, OpenAI-compatible /v1/chat/completions, SSE streaming, a flash↔thinking switch via chat_template_kwargs, FP8 profile active. Every lab targets this endpoint.

your stack vLLM tutorial repo

Your README's intelligent router, budget tracker, prefix-caching demo, and Prometheus/Grafana stack are the "toy" versions of S4/S5 concepts — the labs upgrade them into a coherent, tested service.

How to use this workbook

S1SESSION ONE

The Service Layer — FastAPI anatomy & typed contracts

What a production app layer is made of: the ASGI machinery underneath FastAPI, the request lifecycle you'll rely on in every later session, and Pydantic contracts that turn "send me some JSON" into an enforceable promise.

Concept 1 · Anatomy of a FastAPI service

What is actually running when you run "an API"?

Pass 1 — Intuition

Think of your service as an airport. Uvicornthe server program that accepts network connections and speaks HTTP — is air-traffic control: it handles planes (connections) arriving on the runway and speaks the radio protocol. FastAPI is the terminal building: gates (routesURL paths mapped to handler functions) where each flight is processed. Dependency injectiona system where the framework builds and hands your handler the things it declares it needs — is the ground crew: every gate that says "I need fuel and a baggage cart" gets them delivered, without the gate knowing where fuel comes from. And lifespancode that runs exactly once at startup and once at shutdown — is opening the airport in the morning and locking it at night.

The point of the analogy: the terminal never flies planes. Your FastAPI app never runs inference. It receives, checks, routes, and hands off.

Pass 2 — Mechanism

FastAPI is not a server — it's a framework that plugs into a standard called ASGI (Asynchronous Server Gateway Interface: the Python contract between web servers and web apps that supports async code, streaming, and WebSockets). ASGI replaced the older WSGI (the synchronous predecessor — one request in, one response out, no streaming mid-response), and that difference is exactly why FastAPI can hold a token stream open while serving other users. FastAPI itself is a thin, typed layer over Starlette (the ASGI toolkit providing routing, middleware, responses), which is run by Uvicorn.

UVICORN ASGI server sockets · event loop HTTP parsing writes bytes/chunks STARLETTE ASGI toolkit routing · middleware StreamingResponse WebSockets FASTAPI typed framework DI · validation OpenAPI /docs typed params YOUR CODE handlers · schemas services · config httpx → vLLM each layer only talks to its neighbor — that is why you can swap servers, add middleware, or test handlers in isolation
Hover each layer to see its single job.

Now the lifecycle of one request through that stack — this exact sequence is the skeleton that S2 (concurrency), S3 (streaming), and S4 (the gauntlet) all hang off:

Uvicorn accepts the connection and parses HTTP into ASGI events. Nothing of yours has run yet. Keep-alive means one connection can carry many requests.

Middlewarefunctions wrapped around every request, seeing it on the way in and the response on the way out — runs first: CORS, request-ID injection, timing, gzip. Middleware is where cross-cutting concerns live so handlers stay clean. The material uses middleware for service monitoring; S4 adds auth and limits here.

The router matches method + path (POST /v1/chat) to one handler function. No match → automatic 404/405. Routers compose: an APIRouter per feature area, included into the app — the "modular structure" the material recommends .

Dependency injection resolves. Your handler's signature says http: HttpDep, user: UserDep; FastAPI builds each (running sub-dependencies, caching within the request) and injects them. This is how one shared httpx client, or "current authenticated user", reaches every handler without globals.

Pydantic validates the body against your request model — types coerced, constraints enforced, unknown shapes rejected with a structured 422 before your logic ever runs. Concept 2 below is entirely about this step.

Your handler runs — on the event loop if async def, in the thread pool if def (that fork is all of S2). Here you call the model server, the cache, the database.

The response model serializes your return value to JSON (or the stream starts — S3), response middleware runs outward, Uvicorn writes bytes. FastAPI also used this whole typed pipeline to generate the interactive OpenAPI docs at /docs for free.

1 / 7

Two mechanisms deserve their own snippets, because the code is the concept. First, lifespan — the once-per-process bracket where expensive shared things (an HTTP connection pool, config) are created:

# the app-wide setup/teardown bracket
@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http = httpx.AsyncClient(base_url=VLLM_URL)  # create once…
    yield                                                  # …serve requests…
    await app.state.http.aclose                          # …clean up once

app = FastAPI(lifespan=lifespan)

Second, a dependency — a plain function whose return value FastAPI injects wherever it's declared:

def get_http(request: Request) -> httpx.AsyncClient:
    return request.app.state.http            # hand out the shared client

HttpDep = Annotated[httpx.AsyncClient, Depends(get_http)]

@app.post("/v1/chat")
async def chat(req: ChatRequest, http: HttpDep):  # injected, testable, no globals
    ...

Why this matters beyond tidiness: in S5 you'll override get_http in tests to point at a fake model server — dependency injection is what makes the whole service testable without a GPU.

Pass 3 — Trade-offs & limits

FastAPI's overhead is noise next to inference. Framework routing + validation costs on the order of a millisecond per request; your Gemma endpoint takes hundreds to thousands of milliseconds to generate. The app layer is never your latency problem — until it blocks (S2) or buffers (S3).

Know why the boundary exists — the material's own list. §2 closes with "FastAPI Limitations" and it reads as a spec of what vLLM does instead: inefficient model memory management, thread limits, the GIL, no micro-batching of inference requests, no CPU/GPU workload splitting. Multiple Uvicorn workers can't share one in-memory model (each process would load its own copy makes this explicit). Every item on that list is a reason inference lives across the boundary, and none of it stops FastAPI being excellent at the app-layer jobs.

Structure decays without intent. Flat single-file apps are fine to learn with; the material's "modular structure" (routers/, services/, schemas/, dependencies.py) is what survives a second feature. The lab uses it from the start.

Version reality (July 2026): FastAPI is on the 0.13x line (0.138 shipped June 2026) with monthly minors, requires Python 3.10+, and recent releases refactored router internals (routers are now preserved, routes can be added after inclusion) and added optional frontend-serving. Treat the material's install instructions as historical; pin versions with a lockfile (the lab uses uv).

Concept 2 · Typed contracts with Pydantic

Turning "send me JSON" into an enforceable promise

Pass 1 — Intuition

A Pydantic model is a Python class that declares the exact shape of data — field names, types, and rules — and rejects anything that doesn't fit. Think of it as customs at the border. Every traveler (request) is inspected against a declared manifest before entering the country (your logic). Contraband — a 2-million-token prompt, a negative max_tokens, a role of "admin" in a chat message — is turned away at the border with a precise citation of what rule it broke, instead of being discovered later as a mysterious crash deep inland (or worse, forwarded to your GPU to burn money).

Same machinery, both directions: validation is checking incoming data against the model; serialization is converting your Python objects into outgoing JSON. One class defines both sides of the contract, and FastAPI turns it into live documentation.

Pass 2 — Mechanism

Declaring a model is declaring the contract — this snippet is the mechanism itself. It's the request shape your lab uses, deliberately a product-shaped subset of the OpenAI chat schema your Modal endpoint speaks:

class Msg(BaseModel):
    role: Literal["system", "user", "assistant"]   # enum, not free text
    content: str = Field(min_length=1, max_length=32_000)

class ChatRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")      # unknown fields → 422
    messages: list[Msg] = Field(min_length=1, max_length=64)
    max_tokens: int = Field(default=512, ge=1, le=2048)   # cap what GPU pays for
    temperature: float = Field(default=0.2, ge=0, le=2)
    thinking: bool = False                         #  Gemma flash ↔ thinking switch

What happens with it at runtime:

raw JSON untrusted bytes VALIDATE types · ranges · enums custom validators extra=forbid handler logic trusted typed object SERIALIZE response_model filters shapes the JSON out no accidental leaks /docs free 422 + exact violations (GPU untouched)
Hover each stage. The two amber stages are the contract enforcing itself — in both directions.

When declarations aren't enough, validatorsfunctions attached to a field or the whole model that run custom checks or transforms during validation — take over (source §4): a @field_validator can strip control characters from content; a @model_validator can enforce cross-field rules like "the first message must be the system role" (which, you already know, is also what keeps your vLLM prefix cache hitting — contract design and inference performance meet here). Computed fields add derived read-only outputs, and pydantic-settings applies the same validation to environment variables, so a missing VLLM_BASE_URL kills the process at startup with a clear error instead of a 3 a.m. 500.

Pass 3 — Trade-offs & limits

Validation is effectively free here. Pydantic v2's core is compiled Rust (pydantic-core), benchmarked 5–17× faster than v1 on typical models; validating a chat request costs microseconds against a generation that costs seconds. Never skip validation "for performance" on an LLM API.

Coercion vs strict is a real choice. By default Pydantic coerces ("42"42) — friendly for humans and sloppy clients. strict=True rejects instead — better for machine-to-machine contracts. Pick per model, deliberately.

extra="forbid" cuts both ways. On your product API it catches client typos (temprature) instantly. But if you ever promise "OpenAI-compatible" to consumers, forbidding unknown fields will break SDKs that send new optional params — compatibility layers must be tolerant readers (accept and ignore unknowns), which is exactly how vLLM and NIM behave. Product API: forbid. Compatibility API: ignore.

Where it breaks: validation cost does become visible on huge payloads (multi-MB document uploads) — bound sizes first (max_length, body-size limits at the proxy). And a schema can't judge meaning: "content is a string ≤ 32k chars" passes prompt injections happily. Semantic safety is a different layer — S4's guardrails.

Concept 3 · Designing the surface

Product-shaped API, OpenAI-shaped upstream

The reference track's core thesis is that an API is a product with an audience, and its naming and shape should speak the consumer's language — "use design to prevent domain-concept leaks." Applied here, a sharp question appears: should your public API just mirror OpenAI's /v1/chat/completions, since that's what your upstream speaks?

choose Product-shaped (default)

  • POST /v1/chat with your fields: messages, thinking, capped max_tokens. Small, forbid-extras, documented by your OpenAPI.
  • You can swap models, providers, and prompt strategies behind it without breaking clients — the material's "single entry point" streaming design says the same.
  • Hides knobs you don't want exposed (no client picks temperature=2 on your invoice).

choose OpenAI-compatible (when consumers are SDKs)

  • If your consumers are teams pointing existing OpenAI SDKs / LangChain at you, expose the standard shape and be a tolerant reader.
  • You inherit the ecosystem — and its expectations: exact SSE framing, [DONE], error format, model listing at /v1/models.
  • This is the moment to ask whether a gateway product should do it instead — S5's decision tree.

Versioning: keep /v1/ in the path from day one (both sources agree); additive changes are free, breaking changes get /v2/. REST resource conventions (reference) still apply to the non-generation parts of your API — conversations, files, jobs are nouns with CRUD; generation is the one verb-shaped POST that streams.

Decision tree

Where does this capability live?

The boundary, operationalized. For any feature request — "add caching", "support JSON mode", "log spend per team" — run it through the guard clauses. Follow no ↓ until a yes exits right; bottom-left is the default.

Q1 · Is it token math on the GPU? batching · KV / prefix cache · quantization speculative decoding · attention backend MODEL SERVER — vLLM engine config flip flags in gemma_modal.py; redeploy. Never rebuild in Python. yes no Q2 · Is it org-wide policy shared by many apps? central provider keys · cross-team budgets provider failover · company-wide audit GATEWAY PRODUCT — LiteLLM / Envoy AI GW / Kong platform-team territory. How to choose one: Session 5. yes no Q3 · Is it purely presentation? markdown rendering · typing effect · retry UX local drafts · scroll behavior CLIENT browser / app code. No secrets, no billing decisions, ever. yes no DEFAULT · YOUR APP LAYER (FastAPI) your users' auth · contracts · product logic · per-feature caching & limits · orchestration · stream shaping · cost/request
Hover each node for the test it applies. Anything nothing else claims belongs to your app layer — that's why this stage exists.
Reality check · mid-2025 → July 2026

As of July 2026 FastAPI sits on the 0.13x line: 0.136 (April), 0.137 and 0.138 (June 2026), which added built-in frontend serving (app.frontend) — plus a significant internal refactor that preserves APIRouter instances and allows adding routes after inclusion, and memory reductions in the dependency system. Python floor is 3.10+; docs recommend 3.12/3.13 for new projects. None of this changes the material's patterns — routing, DI, lifespan are stable — but pin exact versions and read release notes on upgrade.

Sources: fastapi.tiangolo.com/release-notes (Jul 2026) · releasebot.io FastAPI feed (Jul 2026) · skakarh.com 0.137.2 / 0.138.0 write-ups (Jun 2026)

Pydantic 2.11 (2025) focused on model build-time performance; the 2.12/2.13 line (2.13 beta Feb 2026) continues validation/serialization speedups on the Rust core. Practical effect: schema-heavy services start faster and validate cheaper — reinforcing "never skip validation." Separately, the same team shipped Pydantic AI v2 (stable 23 Jun 2026), an agent framework; know it exists so you don't confuse "Pydantic" (validation, used here) with "Pydantic AI" (agents, out of scope).

Sources: pydantic changelog & GitHub releases (Feb–May 2026) · nerdleveltech.com Pydantic AI v2 review (Jul 2026)

NIM for LLMs 2.x (docs current June 2026) is architecturally a thin proxy in front of a vLLM backend: the proxy owns /v1/health/live and /v1/health/ready, TLS termination, CORS, and request routing; vLLM owns inference and the OpenAI-compatible routes. That's the boundary drawn by NVIDIA's own product team — and note what the proxy does not include: your users' auth, product contracts, caching, budgets. NIM gives you transport plumbing; the app layer in this stage is still yours to build.

Sources: docs.nvidia.com NIM for LLMs — Architecture & API Reference (Jun 2026)

Where the material has aged

Building GenAI Services with FastAPI shipped April 2025: its examples lean on Azure OpenAI GPT-3.5 with api-version 2023-05-15, and its tooling section predates the ecosystem's consolidation on uv for dependency management. Substitute your own OpenAI-compatible endpoint everywhere the material says Azure, and use the lab's uv workflow instead of bare pip. The concepts in §2–4 are untouched by any of this.

Apply to my stack — Lab S1 · the gemma-gateway skeleton

Goal: a running FastAPI service in front of your Modal endpoint, with typed contracts, one shared upstream client, config validated at boot, and a health check that tells the truth. Non-streaming for now — streaming is S3's whole session.

gemma-gateway/ ├── pyproject.toml uv-managed; pinned deps ├── .env VLLM_BASE_URL=https://…modal.run └── app/ ├── main.py app factory · lifespan · routes ├── config.py pydantic-settings ├── schemas.py the contract └── deps.py dependency providers
# bootstrap
uv init gemma-gateway && cd gemma-gateway
uv add "fastapi[standard]" httpx pydantic-settings
# .env  — your deployed endpoint from `modal deploy gemma_modal.py`
#   VLLM_BASE_URL=https://<you>--gemma4-e4b-inference-serve.modal.run
# app/config.py — config is a contract too: bad env kills boot, loudly
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    vllm_base_url: str                 # required → startup fails if missing
    served_model: str = "gemma-4-e4b"  # matches --served-model-name
    request_timeout_s: float = 300.0   # generous: Modal may cold-boot (S4 tightens this)
    model_config = {"env_file": ".env"}

settings = Settings
# app/schemas.py — the product contract (subset of OpenAI's shape, on purpose)
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator

class Msg(BaseModel):
    role: Literal["system", "user", "assistant"]
    content: str = Field(min_length=1, max_length=32_000)

class ChatRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")
    messages: list[Msg] = Field(min_length=1, max_length=64)
    max_tokens: int = Field(default=512, ge=1, le=2048)
    temperature: float = Field(default=0.2, ge=0.0, le=2.0)
    thinking: bool = False              # Gemma flash ↔ thinking, per request

    @model_validator(mode="after")
    def system_first(self):             # contract rule that also feeds prefix cache
        roles = [m.role for m in self.messages]
        if "system" in roles and roles[0] != "system":
            raise ValueError("system message must come first")
        return self

class Usage(BaseModel):
    prompt_tokens: int
    completion_tokens: int

class ChatResponse(BaseModel):
    text: str
    usage: Usage
    model: str
# app/deps.py
from typing import Annotated
import httpx
from fastapi import Depends, Request

def get_http(request: Request) -> httpx.AsyncClient:
    return request.app.state.http

HttpDep = Annotated[httpx.AsyncClient, Depends(get_http)]
# app/main.py
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI, HTTPException
from .config import settings
from .deps import HttpDep
from .schemas import ChatRequest, ChatResponse, Usage

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http = httpx.AsyncClient(          # ONE pool for the process (S2 explains why)
        base_url=settings.vllm_base_url,
        timeout=httpx.Timeout(10.0, read=settings.request_timeout_s),
    )
    yield
    await app.state.http.aclose

app = FastAPI(title="gemma-gateway", version="0.1.0", lifespan=lifespan)

@app.get("/healthz")
async def healthz(http: HttpDep):
    try:                                          # truth, not vibes: ask the upstream
        r = await http.get("/health", timeout=5.0)
        upstream = "up" if r.status_code == 200 else f"status {r.status_code}"
    except httpx.HTTPError:
        upstream = "cold or unreachable"          # scale-to-zero: cold is normal
    return {"gateway": "up", "vllm": upstream}

@app.post("/v1/chat", response_model=ChatResponse)
async def chat(req: ChatRequest, http: HttpDep):
    payload = {
        "model": settings.served_model,
        "messages": [m.model_dump for m in req.messages],
        "max_tokens": req.max_tokens,
        "temperature": req.temperature,
        "stream": False,
        "chat_template_kwargs": {"enable_thinking": req.thinking},
    }
    r = await http.post("/v1/chat/completions", json=payload)
    if r.status_code != 200:
        raise HTTPException(502, f"model server: {r.text[:200]}")
    data = r.json
    return ChatResponse(
        text=data["choices"][0]["message"]["content"],
        usage=Usage(**{k: data["usage"][k] for k in ("prompt_tokens", "completion_tokens")}),
        model=data["model"],
    )
# run + verify
uv run fastapi dev app/main.py
curl -s localhost:8000/healthz                       # {"gateway":"up","vllm":"cold or unreachable"} at first
curl -s localhost:8000/v1/chat -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","content":"2+2? Be brief."}]}'
curl -s localhost:8000/v1/chat -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","content":"hi"}],"max_tokens":99999}'   # → 422, GPU untouched

Stack notes. (1) First real call may take minutes: your Modal function scales to zero and cold-boots vLLM (your file allows a 10-minute startup) — that's why request_timeout_s is generous for now and why /healthz reports "cold" honestly; S4 replaces this with real timeout budgets. (2) The system_first validator isn't just tidiness: with enable_prefix_caching=True in your quant profile, a stable system-prompt prefix is what makes vLLM's prefix cache hit. (3) Open localhost:8000/docs — your contract, rendered.

Same idea, four ecosystems

OSS This stage's stack

FastAPI + Starlette + Uvicorn + Pydantic + httpx. You own every layer; everything else in this row is a packaging of the same roles.

NVIDIA NIM

Ships the transport slice (proxy: health, TLS, CORS, routing) fused to vLLM. Your contracts, auth, and product logic still live in a service like this lab's.

AWS API GW / ALB

ALB or API Gateway fronts your container (ECS/EKS/App Runner). API GW can do coarse request validation, but Pydantic-grade contracts stay in-app.

GCP Cloud Run / GKE

Cloud Run gives ingress + TLS + autoscale for the gateway container; GKE Gateway API for cluster ingress. Same division: platform does transport, you do meaning.

S2SESSION TWO

Concurrency — one small process, hundreds of waiting requests

Your gateway's job is mostly waiting — on vLLM, on Redis, on the network. This session is about how a single Python process waits on hundreds of things at once, the one mistake that freezes all of them, and what to do when work outgrows the request entirely.

Concept 1 · The great inversion

On your side of the boundary, inference is I/O

Pass 1 — Intuition

Two words first. Concurrency is making progress on many tasks in overlapping time — juggling. Parallelism is literally executing several tasks at the same instant on separate cores — a subset of concurrency. The material's picture: one fast-food owner alternating between the register and the grill is concurrent; hiring three cooks who grill simultaneously is parallel.

Now the inversion that makes this session simple. In your inference stage, a request was compute-bound (limited by processor speed) and memory-bound (limited by how fast data moves to compute — the KV-cache story you know). But your gateway never does that work — it sends bytes across the boundary and waits. From the app layer's chair, a 4-second generation is indistinguishable from a slow database: it is I/O-bound (limited by waiting on input/output — network, disk, another service). The material says it directly: relying on an external model API is "the only instance where you can treat AI inference workloads as I/O-bound." Waiting is cheap. One small CPU process can hold hundreds of waits — if you wait correctly.

Pass 2 — Mechanism
GATEWAY'S VIEW of one /v1/chat request (≈4s total) await … (network + generation) — the process is free to serve others MODEL SERVER'S VIEW of the same request prefill (compute) decode, token by token (memory-bound, continuously batched) Same wall-clock seconds, opposite bottlenecks. That's why the two sides need different tools — and different stages of your curriculum.
Hover the bars. Amber slivers are the only CPU your gateway spends; everything between them is a wait you can overlap.

Python offers three ways to overlap work, and the GIL (Global Interpreter Lock — CPython's rule that only one thread executes Python bytecode at a time within a process) decides what each is good for: async I/O (one thread, tasks yield at await — best for many waits), multithreading (several threads, GIL serializes Python but releases during I/O waits — fine for waits, useless for Python-level compute), and multiprocessing (separate processes, true parallelism, separate memory). Your gateway is wait-shaped, so async I/O is its native tool; multiprocessing appears only as replicas — more container instances behind a load balancer.

Pass 3 — Trade-offs & limits

Why not just run many workers and stay sync? You can — it's simple and correct — but each worker process holds one request's wait at a time, so 200 concurrent 4-second generations need ~200 processes' worth of memory for work one async process could hold. Worse, the material point: separate processes share no memory, so anything loaded per-process is loaded N times. For a thin gateway that's tolerable; it's fatal for in-process models — one more brick in the boundary.

GIL, 2026 status: free-threaded CPython (PEP 703) has arrived as an opt-in build — experimental in 3.13, officially supported in 3.14 — but it targets CPU-parallel threading, which your I/O-bound gateway doesn't need. Async remains the right tool here regardless of the GIL's fate.

Async's real cost is discipline, not performance: every library in the hot path must be async-capable (httpx not requests, asyncpg not psycopg2's sync mode, redis.asyncio), and one lapse triggers the failure mode Concept 2 is about.

Concept 2 · The event loop and the thread pool

The machinery under async def — and the one way to freeze it

Pass 1 — Intuition

The event loop is a single-threaded scheduler that runs pieces of tasks, parking any task that's waiting and resuming it when its I/O completes. Picture one waiter with a perfect notebook: they take table 3's order, hand it to the kitchen, and — crucially — never stand at the kitchen window. They serve tables 4 through 60, and when the bell rings for table 3, the notebook says exactly where to resume. A coroutine is a function defined with async def whose execution can pause at each await and resume later — a notebook entry. await is the moment the waiter walks away from the window.

The thread pool is a fixed crew of worker threads the framework keeps around for code that insists on standing and waiting — a few old-school runners who do stand at the window so the head waiter doesn't have to. FastAPI's rule: async def handlers run on the loop; plain def handlers are shipped to the pool. Both keep the restaurant serving. The catastrophe is a third thing: the head waiter deciding to stand at the window personally. Every table stops being served at once.

Pass 2 — Mechanism

The material three-endpoint experiment, condensed — this code is the whole lesson:

@app.get("/fast")                     # ✓ coroutine + async client
async def fast:
    return await async_client.chat.completions.create(...)   # loop parks it; serves others

@app.get("/slow")                     # ✓ sync handler + sync client
def slow:
    return sync_client.chat.completions.create(...)          # FastAPI ships it to the thread pool

@app.get("/block")                    # ✗ THE bug: async def, blocking call inside
async def block:
    return sync_client.chat.completions.create(...)          # runs ON the loop → whole server stalls
HEALTHY: 6 concurrent requests event loop A,B,C,D ✓ pool thread 1 sync request E (holds thread while waiting) pool thread 2 sync request F POISONED: one blocking call inside async def event loop request A blocking the loop — B, C, D frozen · /healthz frozen · accepts frozen ✗ all Contrast the failure shapes: a busy THREAD POOL degrades (queue grows, sync endpoints slow). A blocked LOOP is a full outage — even endpoints that never touch the model stop responding. That asymmetry is why the async rule below is absolute. RULE: async def only when every slow thing inside is awaited. Unsure or stuck with a sync library? Plain def — the pool has you.
Hover the lanes. Amber slivers on the loop, gray bars on pool threads, red bar = the one-line outage.

Two escape hatches when a sync-only library must run inside async code: await asyncio.to_thread(fn, …) / run_in_threadpool (push just that call onto the pool and await its result), or wrap fan-out I/O with asyncio.gather(*coros) (run many coroutines concurrently and collect all results) — which the material uses to fetch many URLs at once in its web-scraper project, and which your lab reuses below.

Pass 3 — Trade-offs & limits

The pool is small on purpose: the default AnyIO thread pool under FastAPI is 40 threads. Forty concurrent sync requests and the 41st queues — which is graceful degradation, but with 4-second generations that's 10 req/s ceiling per instance for sync handlers. Async handlers have no such ceiling on waits; their limit is upstream capacity (and S4's deliberate limits).

Detection beats belief. A blocked loop looks like "the service randomly hangs under load." Tools: PYTHONASYNCIODEBUG=1 logs slow callbacks; py-spy dump --pid … shows exactly which line the loop thread is stuck on; and your p99 will scream long before your p50 does (S5 makes this measurable).

Blocking ≠ only network. CPU work on the loop blocks it identically: parsing a 100-page PDF, tokenizing megabytes, json.loads on a huge body. Push heavy CPU to to_thread (GIL releases for much C-backed work) or to a worker process — Concept 3.

Concept 3 · When work outgrows the request

BackgroundTasks, real queues, and the 202 pattern

Pass 1 — Intuition

Some work shouldn't hold a connection open: enriching 5,000 tickers, generating a batch of images, re-indexing documents. The universal shape is the dry cleaner: you hand over the shirts, get a ticket immediately, and come back later. In HTTP that's 202 Accepted (the status code meaning "received and queued, not done") plus a job id, then either polling (the client periodically asks "is it done?") or a push notification when ready. FastAPI gives you a zero-infrastructure starter — BackgroundTasks, work scheduled to run in the same process after the response is sent — and the honest question of this concept is when that stops being enough and a task queue (a broker like Redis holding jobs that separate worker processes pull and execute) takes over.

Pass 2 — Mechanism

Client submits the batch to POST /v1/batch. The handler validates (S1 contract!), generates a job id, records status queued in a store, and schedules the work — it does not start doing it inline.

Respond 202 + ticket immediately: {"job_id": "…", "status_url": "/v1/jobs/…"}. Total request time: milliseconds. The connection is free; no load balancer timeout can hurt you; mobile clients can vanish and return.

The work runs elsewhere. Path A — BackgroundTasks.add_task(fn, …): FastAPI runs fn after the response, on the loop if async, on the pool if sync. Same process, same resources. Path B — a queue: the handler only enqueues; separate worker processes (arq/Celery… or a Modal function) pull and execute, restart-safe and independently scalable.

Workers update the store (running, progress, then done/failed+error). The store is the contract between execution and tracking — an in-memory dict for learning, Redis/Postgres in production so any replica can answer.

Client collects: polls GET /v1/jobs/{id} (the material's short-polling use case — cheap status checks are fine to poll), or you push completion over the S3 stream / a webhook (reference source §9). Results live in the store or object storage, referenced by the job.

1 / 5
Pass 3 — Trade-offs & limits
BackgroundTasks (in-process)Task queue + workers
Infra costZero — it's just FastAPIBroker (Redis) + worker deployment + monitoring
Survives crash / redeployNo — dies with the process, silentlyYes — jobs persist in the broker; retries built in
Resource isolationCompetes with request serving (same loop/pool)Workers scale independently; can even sit on other machines
VisibilityYours to build; nothing by defaultQueue depth, retries, dead-letter — standard tooling
Right forFire-and-forget minutiae: usage logging, cache warm-up, sending a webhook, <~30s best-effort workAnything a user would notice losing: batches, long generations, paid work, scheduled jobs

Your stack already has the queue's big brother: gemma_modal.py's OfflineEnricher class — a Modal function that loads the engine in-process and chews a whole JSONL batch. For heavy offline work, the gateway's correct move is often "submit to Modal, track the call id" rather than running anything itself: Modal is your durable worker tier. The material's warning stands either way : background work still shares the machine — schedule the coordination in the gateway, run the weight elsewhere.

Decision tree

sync def · async def · background task · queue

Run every new endpoint or task through this before writing it. Follow no ↓; first yes exits right; bottom-left is the default.

Q1 · Will it outlive the request — or must it survive crashes, redeploys, retries? multi-minute batches · paid jobs · schedules QUEUE + WORKERS → 202 + job id arq / Celery on Redis — or submit to a Modal function (your stack) yes no Q2 · Can it run after the response — short, best-effort, loss-tolerable? usage logging · cache warm-up · webhooks out FastAPI BackgroundTasks zero infra; same process; response returns first yes no Q3 · Is every slow call on this path truly async (awaited, non-blocking)? httpx · redis.asyncio · asyncpg · to_thread-wrapped CPU async def — the event loop the gateway's default for talking to vLLM, Redis, the web yes no DEFAULT · plain def (thread pool) safe with any library · ~40-thread ceiling per instance honest and boring beats fast and frozen
Hover each node for its test. Note the ordering: durability first, deferral second — the sync/async choice only matters for work that stays in the request.
Reality check · mid-2025 → July 2026

A widely-shared 2026 postmortem pattern ("Why Your Streaming LLM Endpoint Hangs Under Load") describes exactly Concept 2's math in the wild: sync workers each pinned by a long-lived LLM response, the first symptom appearing precisely when concurrency reaches worker count — invisible locally, an outage in production. Its fixes are this session verbatim: async workers for I/O-bound paths, and (previewing S3) disabling proxy buffering plus heartbeats. Treat "works with 3 users" as zero evidence.

Source: pithycyborg.substack.com — "Why Your Streaming LLM Endpoint Hangs Under Load" (2026)

The material footnoted the GIL's removal as a future plan; it has since shipped as opt-in free-threaded builds — experimental in Python 3.13, officially supported in 3.14. It unlocks CPU-parallel threads for compute-heavy Python; an I/O-bound gateway gains essentially nothing, and parts of the async ecosystem are still validating against it. Keep standard builds + asyncio for this layer; revisit if you ever do heavy in-process CPU work (you shouldn't — that's what the boundary and Modal are for).

Sources: CPython 3.13/3.14 release notes on free-threading (2024–2025) · source §5 footnote for the "before" state

2026 deployment guides still show gunicorn managing uvicorn workers, but flag its sharp edges for AI services: worker --timeout kills long generations, and multi-worker SSE needs care since each worker holds its own connections. The idiom that has won for containerized gateways: one async uvicorn process per container, scale by adding containers — the orchestrator (Cloud Run, K8s, ECS) is your process manager. Your lab and S5's Dockerfile follow it.

Sources: markaicode.com FastAPI-on-Docker production guide (May 2026) · FastAPI deployment docs (2026)

Apply to my stack — Lab S2 · fan-out, jobs, and seeing the freeze

Goal: three additions to gemma-gateway: a concurrent fan-out endpoint (gather), the 202-job pattern, and a controlled reproduction of the blocked-loop outage so you never mistake it for load.

# app/routers/research.py — fan-out: fetch N URLs concurrently, then ONE Gemma call
# (the material "Talk to the Web" project, gateway-shaped)
import asyncio, httpx
from fastapi import APIRouter
from ..deps import HttpDep
from ..schemas import SummarizeRequest, ChatResponse   # SummarizeRequest: urls list[HttpUrl] ≤5, question str

router = APIRouter(prefix="/v1")

async def fetch_text(client: httpx.AsyncClient, url: str) -> str:
    r = await client.get(url, timeout=8.0, follow_redirects=True)
    r.raise_for_status
    return r.text[:20_000]                       # bound what you feed the GPU

@router.post("/summarize-urls", response_model=ChatResponse)
async def summarize(req: SummarizeRequest, http: HttpDep):
    async with httpx.AsyncClient as web:       # separate pool for the public web
        pages = await asyncio.gather(            # all fetches in flight AT ONCE
            *(fetch_text(web, str(u)) for u in req.urls),
            return_exceptions=True,              # one bad URL ≠ failed request
        )
    docs = [p for p in pages if isinstance(p, str)]
    prompt = f"{req.question}\n\nSOURCES:\n" + "\n---\n".join(docs)
    ...                                          # build messages, call vLLM as in S1
# app/routers/jobs.py — the 202 pattern (in-memory store: teaching scaffold; Redis in prod)
import uuid
from fastapi import APIRouter, BackgroundTasks, HTTPException
router = APIRouter(prefix="/v1")
JOBS: dict[str, dict] = {}                       # job_id → {"status", "results"}

async def run_batch(job_id: str, prompts: list[str]):
    JOBS[job_id]["status"] = "running"
    try:
        results = []
        for p in prompts:                        # sequential on purpose: don't stampede
            results.append(await call_gemma(p))  #   your one L4 (S4 adds real limits)
        JOBS[job_id].update(status="done", results=results)
    except Exception as e:
        JOBS[job_id].update(status="failed", error=str(e))

@router.post("/batch", status_code=202)
async def submit(req: BatchRequest, bg: BackgroundTasks):
    job_id = uuid.uuid4.hex[:12]
    JOBS[job_id] = {"status": "queued"}
    bg.add_task(run_batch, job_id, req.prompts)  # scheduled; response returns NOW
    return {"job_id": job_id, "status_url": f"/v1/jobs/{job_id}"}

@router.get("/jobs/{job_id}")
async def status(job_id: str):
    if job_id not in JOBS: raise HTTPException(404)
    return JOBS[job_id]
# app/routers/demo.py — reproduce the outage, then feel the fix
import time, asyncio
from fastapi import APIRouter
router = APIRouter(prefix="/demo")

@router.get("/block")
async def block:          # ✗ the bug, on purpose
    time.sleep(8)
    return {"survived": True}

@router.get("/ok")
async def ok:
    await asyncio.sleep(0)   # canary: instant when the loop is healthy
    return {"loop": "alive"}
# the experiment — two terminals
curl localhost:8000/demo/block &        # start the poison…
time curl localhost:8000/demo/ok        # …frozen ~8s. Now change block to `def` (or
                                        #  `await asyncio.sleep(8)`) → /demo/ok is instant again.
# bonus: py-spy dump --pid $(pgrep -f uvicorn) while frozen — see the loop thread stuck in sleep

Stack notes. (1) The batch worker calls Gemma sequentially — deliberate: your Modal profile caps max_inputs=64 per replica and you share it with interactive traffic; S4 turns that instinct into explicit concurrency limits. (2) For batches worth money, the tree says queue — and your queue is modal run gemma_modal.py::batch_enrich: durable, isolated, GPU-priced by the second. The gateway's role shrinks to submit + track. (3) JOBS in memory means a second replica can't answer for the first — you'll fix stores like this with Redis in S4.

Same idea, four ecosystems

OSS asyncio / AnyIO

Event loop + 40-thread pool inside every FastAPI process; arq/Celery when work needs durability.

Modal your stack

@modal.concurrent(max_inputs) is per-replica admission control; Functions.spawn are a managed durable queue. OfflineEnricher = worker tier.

AWS SQS / Lambda

202 pattern as a service: API writes to SQS, workers on Lambda/ECS consume; Step Functions for multi-step jobs.

GCP Cloud Tasks / Pub-Sub

Cloud Tasks pushes jobs at a controlled rate to Cloud Run workers — rate-limited fan-out built in.

S3SESSION THREE

Streaming — moving tokens from vLLM to humans

You know why TTFT matters; this session is how first tokens actually reach a browser. The transport menu and how to choose, the SSE wire format byte by byte, the two-stream passthrough at the heart of your gateway, and the proxies that silently buffer or kill streams in production.

Concept 1 · The transport menu

Five ways to deliver results that aren't ready yet

Pass 1 — Intuition

Plain HTTP is a vending machine: one coin, one complete item. Generation breaks that shape — the item is produced over seconds. The menu of fixes, as couriers:

Pass 2 — Mechanism
time →    ▲ = client sends    ▼ = server delivers    ━ = connection held open short poll (finally: data) long poll SSE ▼ [DONE] WebSocket upgrade gRPC streaming (not drawn): shape like SSE or WebSocket depending on mode, but typed, over HTTP/2, service-to-service. Webhooks (reference) invert everything: the SERVER calls a URL the client registered — right for "notify my system when the batch finishes," not for token streams.
Hover each timeline. The shapes ARE the trade-offs: count the handshakes, count the directions.
transportdirectionmessages / connectionbrowser storyinfra frictionLLM fit
short pollclient pulls1trivial (fetch)nonejob status only
long pollclient pulls, server holds1trivialtimeout tuninglegacy fallback
SSEserver → clientmanynative (EventSource) / fetch-reader for POSTlow — plain HTTP; buffering must be offthe default
WebSocketboth, anytimemanynative (WebSocket)medium — upgrade support at every hop; your own reconnect/heartbeatvoice, live interrupts, collab
gRPC streamany (by mode)manyneeds grpc-web proxyHTTP/2 end-to-end; codegenservice↔service internals
Pass 3 — Trade-offs & limits

SSE's two famous catches. (1) The browser's EventSource only issues GET — no body — so real chat (long histories) uses POST + manual stream reading via fetch, trading away free auto-reconnect (the material walks both; the lab uses POST). (2) Over HTTP/1.1, browsers cap ~6 connections per origin — a few open SSE tabs can starve a site; HTTP/2 multiplexing (reference) removes the cap and is table stakes in 2026.

WebSocket's cost is operational, not conceptual: every proxy, LB, and gateway on the path must pass the upgrade; reconnect, heartbeat, and resume logic are yours to write (SSE gives them free via retry:/Last-Event-ID); and long-lived stateful connections make rolling deploys and autoscaling stickier. Pay it when you need the second direction mid-stream — voice, "stop generating" without a second request, human-approves-tool-call agent UIs.

The material's design rule survives every transport : one streaming entry point, with body/params selecting behavior server-side — not an endpoint per feature. State stays on the server; clients stay dumb.

Concept 2 · The wire format and the passthrough

What SSE bytes look like, and the two-stream relay your gateway runs

Pass 1 — Intuition

Your gateway is a relay commentator: it listens to the raw feed from the stadium (vLLM's SSE) and re-broadcasts to your audience — same events, your voice. It can drop internal chatter, rename things, add its own markers ("that was the reasoning; here's the answer"), and if a listener hangs up, it tells the stadium to stop the feed. Two streams, one loop: read a chunk upstream → transform → write a chunk downstream, repeated per token.

Pass 2 — Mechanism

First, the bytes. An SSE stream is UTF-8 text: each event is a few field: value lines ended by one blank line — that blank line is the delimiter that makes it a protocol:

HTTP/1.1 200 OK Content-Type: text/event-stream event: token data: {"t": " Paris"} id: 41 ␊ ← blank line = dispatch : ping retry: 3000 data: [DONE] What vLLM actually sends you data-only events (no event: names), each an OpenAI chunk: choices[0].delta.content choices[0].delta.reasoning_content …that second field is your Gemma reasoning parser at work — thinking tokens arrive separately from answer tokens. Your gateway can surface that as named events — a UX gift. …then: data: [DONE]
Hover each line of the frame. The format is five field names and a blank line — that's the whole spec surface you need.

Now the relay itself — an async generator (an async def function that yields values over time; each yield becomes one chunk written to the client) bridging the two streams. This skeleton is the mechanism (full version in the lab):

async def relay(payload: dict, http: httpx.AsyncClient):
    async with http.stream("POST", "/v1/chat/completions", json=payload) as r:
        async for line in r.aiter_lines:            #  stream IN from vLLM…
            if not line.startswith("data: "): continue
            if line == "data: [DONE]":
                yield "event: done\ndata: {}\n\n"; return
            delta = json.loads(line[6:])["choices"][0]["delta"]
            if txt := delta.get("reasoning_content"):
                yield f"event: reasoning\ndata: {json.dumps({'t': txt})}\n\n"
            if txt := delta.get("content"):           #  …transform…
                yield f"event: token\ndata: {json.dumps({'t': txt})}\n\n"

@app.post("/v1/chat/stream")
async def chat_stream(req: ChatRequest, http: HttpDep):
    return StreamingResponse(relay(build_payload(req), http),   #  …stream OUT
        media_type="text/event-stream",
        headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})

Note what the amber layer just earned its keep doing: it split reasoning_content from content into named events (your product's stream shape), hid vLLM's raw chunk schema from clients, and set the two headers that keep intermediary proxies honest — Concept 3's subject. Backpressurethe slower side pacing the faster side so buffers don't grow without bound — comes free here: each yield awaits the socket write to the client, and while paused, you're not pulling from vLLM either.

Pass 3 — Trade-offs & limits

Passthrough vs re-emit. Forwarding vLLM's bytes untouched is simplest and cheapest — right when you promised OpenAI-compatibility. Re-emitting typed events (above) buys a stable product contract and reasoning separation, at the cost of one JSON parse+dump per chunk — microseconds against a ~10–100 tokens/sec stream; negligible until you proxy thousands of concurrent streams, at which point you're choosing a Go/Envoy data plane anyway (S5).

Get usage before [DONE]: send "stream_options": {"include_usage": true} upstream and vLLM's final chunk carries token counts — feed them to S4's budget tracker; without it, streaming responses are invisible to cost accounting.

Ordering & loss: SSE inherits TCP's in-order delivery per connection; what it can't survive is the connection. If mid-stream death must be resumable, you need id: per event plus server-side replay state — real work. Most chat products instead make regeneration cheap and idempotent. Decide which product you are.

Concept 3 · What kills streams in production

Buffering proxies, idle timeouts, and the disconnect you must propagate

Pass 1 — Intuition

Your stream is water through a hose with many couplings — CDN, load balancer, ingress, server, the model host's own edge. Any coupling can quietly do one of three hostile things: pool the water (a buffering proxyan intermediary that collects the whole response before forwarding it — turning your live stream into one big splash at the end), auto-shutoff on stillness (an idle timeoutthe proxy closing connections that carry no bytes for N seconds — fatal during a long prefill pause), or ignore a dropped hose end (the client left, but nobody told the stadium, so the GPU keeps generating tokens into the void — you pay for every one).

The cruel part: none of these exist on localhost. This is the canonical "worked in dev, dies in prod" session.

Pass 2 — Mechanism
The real path of one token stream — hover each hop's hazard browser reconnect UX CDN ~100s cap · caching load balancer 60s idle default nginx / ingress buffers by default gemma-gateway heartbeat · cancel Modal edge platform policies vLLM aborts on close The disconnect, propagated (the money path) 1 · client closes tab 2 · gateway detects, exits generator, closes upstream httpx stream 3 · vLLM aborts generation, batch slot freed — tokens stop billing The four-line survival kit, every streaming route: Cache-Control: no-store · X-Accel-Buffering: no · heartbeat ": ping" ≤ every 30s · on disconnect → close upstream
Hover every hop: red-dashed boxes are where prod streams die. Then follow the bottom row — cancellation is a cost feature, not a courtesy.
Pass 3 — Trade-offs & limits · the numbers wall
hop (defaults, mid-2026)limityour move
AWS ALB60s idle timeout defaultraise to 300–600s; heartbeat anyway
Cloudflare (non-ent.)~100s streaming response windowheartbeats; bypass/adjust for stream routes
nginx-family ingressbuffers responses by defaultX-Accel-Buffering: no per route
GCP Cloud Runrequest timeout default 300s (configurable to 60 min); SSE supportedset timeout ≥ worst stream; heartbeat
AWS API Gateway (REST)streaming needs STREAM transfer mode: ≤15 min, idle 5 min (regional) / 30s (edge)enable STREAM mode or front with ALB instead
your Modal upstreamscale-to-zero cold boot can precede first token by minuteswarm-up ping, honest TTFT budgets (S4), or min_containers=1 when it matters

Two closing judgments. Heartbeat cadence: every 15–30s covers every default above at ~0 cost; tie it to your tightest hop. Resumability: Last-Event-ID replay demands server-side stream state — the queue-backed pattern (generate into a store, serve SSE from it) that 2026 guides recommend for mobile-heavy apps; adopt it when reconnect-with-context is a product requirement, not before.

Decision tree

How should tokens reach this client?

Q1 · Must the client send data mid-stream, same channel? voice barge-in · live steering · in-stream tool approval WebSocket own the reconnect/heartbeat logic; verify every hop passes upgrades yes no Q2 · Service-to-service only, on infra you control, wanting typed contracts? no browsers · HTTP/2 everywhere · codegen welcome gRPC streaming reference source §8 — proto-defined, HTTP/2, internal excellence yes no Q3 · Is it batchy — minutes-long, no human watching intermediate output? image/batch jobs · offline enrichment · scheduled work 202 + polling (or webhook out) your S2 jobs pattern; short polling's legitimate home yes no DEFAULT · SSE (POST + stream) the LLM lingua franca: OpenAI, Anthropic, vLLM, NIM all speak it survival kit: no-store · no-buffering · heartbeat · cancel-on-disconnect
Hover each node. Notice the tree never asks 'is SSE good enough?' — it asks what would force you OFF the default.
Reality check · mid-2025 → July 2026

Chat Completions streams data-only SSE chunks (choices[].delta, terminated by [DONE]) — the dialect vLLM serves you. The Responses API, which OpenAI now recommends for streaming, uses named semantic events (response.created, response.output_text.delta, response.completed, error) with a full final object in response.completed. Migration writeups this spring stress that adapters must now track lifecycle events, not just concatenate deltas — and OpenAI's Codex has begun requiring wire_api = "responses" from custom providers. Your typed-event relay in this session is a miniature of the same evolution: naming events beats guessing from deltas. Watch vLLM/NIM: NIM's current API reference already lists a responses route alongside chat completions.

Sources: developers.openai.com streaming guide (2026) "From Chat Completions to Responses" migration notes, github gist (May 2026) · NIM LLM API reference (Jun 2026)

Independent engineering guides this year converge on the same checklist Concept 3 taught: disable buffering at every layer (nginx proxy_buffering off / X-Accel-Buffering: no, plus CDN and ALB equivalents); raise LB idle timeouts or make them moot with heartbeats; use id:Last-Event-ID when resume matters; and wire client disconnect through to model cancellation so a closed tab stops the meter. The same pieces also document the SSE-vs-WS boundary moving: agentic in-stream approvals and voice push teams to WebSocket; token delivery stays SSE.

Sources: firsttoken.dev "Streaming LLM responses without breaking your backend" (2026) · buildmvpfast.com SSE vs WebSockets (Mar 2026) · rajpoot.dev streaming patterns (May 2026)

Where the material has aged

§6's mechanics are exactly right and its GET-vs-POST SSE treatment is still the best short explanation in print. Three updates: (1) its streaming examples target Azure OpenAI GPT-3.5, api-version 2023-05-15 — dial in your own endpoint; (2) it inserts await asyncio.sleep(0.05) per chunk "to reduce back pressure" — at 2026 decode speeds that artificially caps you at ~20 events/s and adds seconds to long answers; real backpressure is already handled by awaited writes, so throttle only if a measured client problem demands it; (3) it hand-rolls data: framing (great for learning — the lab does too), but know sse-starlette exists for production niceties like automatic pings and clean id:/retry: handling.

Apply to my stack — Lab S3 · the streaming relay, production-grade

Goal: POST /v1/chat/stream that relays your Modal vLLM stream as typed events (reasoning / token / done-with-usage), heartbeats while prefill or cold-boot keeps the wire silent, and cancels upstream the instant the client leaves.

# app/streaming.py
import asyncio, json
import httpx
from fastapi import Request

def sse(event: str, data: dict) -> str:
    return f"event: {event}\ndata: {json.dumps(data)}\n\n"

async def relay(payload: dict, http: httpx.AsyncClient, request: Request):
    # one heartbeat task keeps every idle-timeout clock on the path at zero
    q: asyncio.Queue[str | None] = asyncio.Queue

    async def pump:
        try:
            async with http.stream("POST", "/v1/chat/completions", json=payload) as r:
                r.raise_for_status
                async for line in r.aiter_lines:
                    if not line.startswith("data: "):
                        continue
                    raw = line[6:]
                    if raw == "[DONE]":
                        break
                    chunk = json.loads(raw)
                    if chunk.get("usage"):                       # final chunk (include_usage)
                        await q.put(sse("done", {"usage": chunk["usage"]}))
                        continue
                    delta = chunk["choices"][0]["delta"]
                    if t := delta.get("reasoning_content"):
                        await q.put(sse("reasoning", {"t": t}))
                    if t := delta.get("content"):
                        await q.put(sse("token", {"t": t}))
        except httpx.HTTPError as e:
            await q.put(sse("error", {"detail": str(e)[:200]}))
        finally:
            await q.put(None)                                    # sentinel: pump finished

    task = asyncio.create_task(pump)
    try:
        while True:
            if await request.is_disconnected:                  # client left →
                break                                            #   stop paying for tokens
            try:
                item = await asyncio.wait_for(q.get, timeout=15.0)
            except asyncio.TimeoutError:
                yield ": ping\n\n"                               # silence → heartbeat
                continue
            if item is None:
                break
            yield item
    finally:
        task.cancel                                            # closes upstream stream →
        await asyncio.gather(task, return_exceptions=True)       #   vLLM aborts, slot freed
# app/main.py — the route
from fastapi.responses import StreamingResponse
from .streaming import relay

@app.post("/v1/chat/stream")
async def chat_stream(req: ChatRequest, request: Request, http: HttpDep):
    payload = {
        "model": settings.served_model,
        "messages": [m.model_dump for m in req.messages],
        "max_tokens": req.max_tokens, "temperature": req.temperature,
        "stream": True,
        "stream_options": {"include_usage": True},   # usage in the final chunk → S4 budgets
        "chat_template_kwargs": {"enable_thinking": req.thinking},
    }
    return StreamingResponse(
        relay(payload, http, request),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"},
    )
# verify — watch reasoning and answer arrive as different event types
curl -N localhost:8000/v1/chat/stream -H 'content-type: application/json' \
  -d '{"messages":[{"role":"user","content":"Why is the sky blue?"}],"thinking":true}'
# then: start a long generation, Ctrl-C curl mid-stream, and watch your Modal logs —
# the request aborts within a beat. That's the disconnect propagating to the GPU.

Stack notes. (1) The queue+pump split exists so heartbeats can fire while awaiting upstream — during Modal cold boots the wire may be silent for a minute-plus, and this keeps every proxy on the path from declaring you idle. (2) thinking: true flips Gemma's mode per request via chat_template_kwargs — your reasoning-parser tokens surface as event: reasoning, letting a client render a collapsible "thinking…" block for free. (3) Browser client in one line each: fetch(url, {method:"POST", body}).then(r => r.body.getReader) and decode — the material POST-SSE client is exactly this, worth copying wholesale.

Same idea, four ecosystems

OSS SSE + sse-starlette

StreamingResponse / sse-starlette on FastAPI; nginx buffering off; identical dialect to your upstream.

NVIDIA NIM

Streams OpenAI-style SSE from the container (proxy passes it through) — same stream:true chunks your relay parses.

AWS streaming modes

ALB passes SSE (raise idle timeout); API GW REST needs STREAM transfer mode (≤15 min); Lambda has response streaming with its own caps.

GCP Cloud Run

HTTP streaming + SSE supported; set request timeout ≥ longest stream; GKE ingress → same nginx/Envoy buffering rules.

S4SESSION FOUR

Protect & Optimize — the request gauntlet

A GPU-backed endpoint is a machine that converts requests into money spent. This session builds the gauntlet every request must run — identity, limits, caches, guardrails, and the resilience trio — ordered so the cheap checks absorb abuse before anything expensive happens.

The organizing picture

Order by cost: cheap gates first, GPU last

authenticate µs · who are you validate µs · S1 contract limits · budget ~ms · Redis caches ms · hit = done guardrail in ms–100ms guarded vLLM call timeouts · retries · breaker seconds · the only $ step account debit usage left of the cyan box, a rejected request costs microseconds-to-milliseconds; inside it, seconds and dollars. The gauntlet exists to make "no" cheap. amber = your dependencies/middleware (this session's lab) · cyan = the boundary crossing
Hover each gate. Everything in this session is one of these boxes; the lab wires all seven around your Modal endpoint.
Concept 1 · Identity first

Authentication & authorization, LLM-flavored

Pass 1 — Intuition

Authentication (verifying who is calling) and authorization (deciding what that identity may do) are the hotel front desk: the keycard proves you're a guest (authn); which floors it opens is authz. Three keycard styles matter here: an API keya long random secret you issue, store hashed, and look up per request — a plain card the desk checks against its ledger (instantly revocable); a JWTa JSON Web Token: signed claims (who, what scopes, until when) the server verifies without any lookup — a card with your details embossed and a tamper-proof seal (stateless, but valid until expiry even if you want it dead); and OAuth 2the protocol for obtaining tokens via a third party ("sign in with GitHub") without ever seeing passwords. Everything downstream — limits, budgets, caches, audit — hangs off the identity this step establishes.

Pass 2 — Mechanism

In FastAPI, auth is just a dependency (S1's machinery doing security work) — resolve identity or raise, and every protected route declares it:

async def current_key(authorization: Annotated[str, Header]) -> ApiKey:
    token = authorization.removeprefix("Bearer ").strip
    key = await keys.lookup(hash256(token))        # store HASHES, never raw keys
    if key is None or key.revoked:
        raise HTTPException(401, "invalid key", headers={"WWW-Authenticate": "Bearer"})
    return key                                     # carries: owner, scopes, tier

KeyDep = Annotated[ApiKey, Depends(current_key)]   # → chat(req, key: KeyDep, …)

Authorization then reads the identity: the material tour — RBAC (role-based: permissions attach to roles like "admin"/"member"), ABAC (attribute-based: rules over attributes — tier, org, time), ReBAC (relationship-based: "may access documents they own/share") — collapses, for a model API, into scopes on the key: chat, chat:thinking, batch, admin. A one-line check per route ("batch" in key.scopes) covers most products; graduate to a policy engine when relationships appear.

Pass 3 — Trade-offs & limits

API keys vs JWT is revocation vs lookups: keys need a (cached) store hit but die instantly; JWTs verify offline but live until exp — so keep them short-lived (minutes–hours) and pair with refresh tokens. Machine-to-machine LLM APIs overwhelmingly use keys (every provider you've called does); JWTs shine when a human session already exists. Hygiene that's non-negotiable: keys hashed at rest, transmitted only in headers (query params leak into logs — the material XSS caution for GET applies doubly), prefix-identifiable (gg_live_…) so leaked keys are traceable, and rotation supported from day one. LLM twist: identity gates capabilities that cost differently — thinking mode burns multiples of flash mode's tokens, so it's a scope, not a boolean anyone can flip.

Concept 2 · Rate limiting & friends

Limit tokens, not just requests

Pass 1 — Intuition

Rate limiting is rejecting requests beyond N per time window, per identity — the nightclub bouncer with a per-hour wristband count. Its siblings: throttlingslowing processing instead of rejecting (letting people queue outside); load sheddingdeliberately dropping excess when the system nears saturation, to protect everyone already inside (fire-marshal cap); and quotaa longer-horizon allowance, like tokens per day (monthly membership). The LLM twist that changes everything: one guest can drink a hundred times more than another. A request with 30k prompt tokens and max_tokens=2048 is not one unit of load — so mature LLM APIs limit requests per minute AND tokens per minute/day. Requests protect the door; tokens protect the bar.

Pass 2 — Mechanism
token bucket refill R/s ↓ burst ≤ B · spend per cost leaky bucket constant drain · smooths spikes fixed window N here… …+N here boundary burst = 2N leak sliding window rolling count · accurate enforcement point: a dependency/middleware backed by Redis, so every replica shares one truth (the material warning: per-instance in-memory counters silently multiply your limits by replica count behind a load balancer)
Hover the four algorithms. The lab implements a Redis token bucket — the one that extends naturally from counting requests to counting LLM tokens.

Token-aware flow: pre-check an estimate before calling (prompt chars/4max_tokens as worst case) against remaining quota, then post-debit actuals from the usage the stream's final chunk delivers (S3's include_usage earning its keep). Off-the-shelf: slowapi decorates routes with per-IP/per-user limits (source §9 walks it, including the storage_uri="redis://…" switch for multi-replica truth and exempting /healthz); it counts requests only — the token dimension you build, or buy at the gateway (S5).

Pass 3 — Trade-offs & limits

Answer 429s well: include Retry-After and remaining-quota headers — clients that know when to return don't hammer. Key the right identity: per-IP alone is evadable (VPNs, CGNAT also makes it unfair — the material's point); per-key is primary, per-IP a backstop for unauthenticated surface. Streaming wrinkle: you can't debit exact tokens until generation ends — reserve the estimate, reconcile after; accept small overshoot rather than double-counting. Load shedding is the honest last line: your Modal config already sheds at the platform edge (max_inputs=64 per replica, max_containers=2) — when the gateway sees upstream queueing (rising TTFT, 429/503s), shedding early at the gauntlet with a clear 503 beats stacking doomed requests. Throttle streams? Rarely: pacing token delivery adds latency for everyone to slow abusers you should be limiting instead.

Concept 3 · Caching

Exact, semantic, and the prefix cache you already run

Pass 1 — Intuition

Three librarians, three caches. The photocopier clerk is an exact-match cachestore the response under a hash of the byte-identical request; same bytes in, same copy out. The reference librarian is a semantic cachestore responses under an embedding (a vector capturing meaning) of the prompt; a new question close enough in meaning — above a similarity threshold — gets the stored answer: she recognizes "how do I reset my password?" and "password reset — how?" as the same question. And the bookmark on the reading desk is vLLM's prefix cache, which you already know from the inference stage — it never skips the answer, it skips re-reading the shared beginning of the prompt (recomputing KV for a repeated prefix). Different layers, different wins: the first two save the whole generation; the third makes unavoidable generations start faster.

Pass 2 — Mechanism
prompt cache-eligible? exact GET hash key · µs embed text → vector · ms vector search nearest neighbor score ≥ τ ? the tunable HIT · serve ~ms · $0 GPU MISS · vLLM then store pair yes no exact hit → serve immediately ↑ meanwhile at the engine: vLLM's prefix cache (ON in your quant profile) accelerates every MISS whose prompt shares a prefix — the app layer's contribution is prompt DISCIPLINE: stable system prompt first (your S1 validator), variable user content last. eviction when full (source §10): LRU default, TTLs for anything whose truth expires — a stale cached answer is a bug with a smile.
Hover the pipeline. Two decisions run the show: the hash (exact) and the threshold τ (semantic).
Pass 3 — Trade-offs & limits

The failure mode that matters is the confident wrong answer. A semantic false hit doesn't error — it serves someone else's answer fluently. Defenses: threshold tuned against a labeled sample, cache keys segmented by anything that changes truth (model, system-prompt version, language, tier), and TTLs. Never cache personalized generations without the user in the key — the classic leak. Economics: the material cites a study where a semantic cache cut API calls ~69% across a large user base; your number is whatever your traffic's paraphrase-repetition rate is — measure hit rate before believing any vendor's. A hit costs ~10–50ms and $0 GPU vs seconds and real money; even 15% hit rate on a head-heavy FAQ workload pays for Redis many times over. Streaming: cache the assembled text (your relay's done path has it), replay as chunks on hit. Thinking mode: cache the answer, not the reasoning — or skip caching thinking requests entirely; they're the expensive, rare, freshness-sensitive tail.

Concept 4 · Resilience & budgets

Timeouts, classified retries, circuit breakers, cost caps

Pass 1 — Intuition

Calling an upstream without protection is phoning with no discipline. The disciplines: a timeouta deadline after which you abandon the wait — hang up after two minutes on hold, always; a retry with exponential backoff and jitterre-attempt failed calls with growing, randomized delays — call back only if the line was busy, never if you already placed the order (you'd order twice), and not everyone redialing on the same beat; a circuit breakerafter N consecutive failures, stop calling for a cool-down (open), then probe with one test call (half-open) before resuming (closed) — stop calling the number that's been dead all morning, glance at it hourly; and a budgeta hard cap on spend per identity per period — the phone bill your teenager cannot exceed. Idempotencyan operation safe to perform twice with the same result — is the property deciding what's retryable at all.

Pass 2 — Mechanism

Before spending, check the wallet. Estimated cost (prompt-token estimatemax_tokens ceiling) vs the key's remaining daily token budget in Redis. Over → 429 with when-it-resets (some APIs use 402; pick one and document it). This is your repo's budget-tracker idea, promoted to a gate that runs before the GPU, not a report after.

Three clocks, not one. (a) connect ~5s — is anyone there? (b) first token — TTFT budget; the honest one for streams, implemented as wait_for around the first chunk; (c) total stream — a generation may legitimately run minutes, so total is generous while first-token is strict. Your Modal wrinkle: cold boot can push real TTFT past any sane budget — either treat cold as a special state (probe /health, tell the client "warming up") or accept a long first-token budget on the first request after idle.

Not all failures deserve a retry. Retryable: connect errors, 503/429 (respect Retry-After), first-token timeout before any byte was streamed. Not retryable: 4xx (your request is wrong — retrying repeats the mistake), and — critical for LLMs — anything after streaming began: the user saw half an answer; a silent retry generates a different one. Mid-stream death is surfaced (event: error), and the client offers regenerate.

Backoff grows, jitter desynchronizes. Delays like 0.5s → 1s → 2s (cap ~3 attempts), each multiplied by a random factor. Without jitter, a blip turns every waiting client into a synchronized retry storm that re-kills the recovering upstream. Remember your queueing intuition from the inference stage: retries are extra arrivals aimed at a server that's already saturated.

The breaker is a three-state machine shared per upstream: closed normal, counting recent failures → threshold trips to open: fail instantly for a cool-down (30–60s), sparing the upstream and giving callers a fast honest 503 instead of a slow doomed wait → half-open: admit one probe; success closes, failure re-opens. With one upstream (your Modal endpoint) the breaker's gift is fast failure + recovery space; with fallbacks (a second model, a canned response) it's what makes failover instant.

After the stream ends, reconcile. The done event's usage debits actual tokens against the budget and appends to the usage log (a BackgroundTask — S2). Now budgets, dashboards, and per-key billing all read from one truth. The loop from gate (step 1) to settle (step 6) is what makes cost a controlled input instead of a monthly surprise.

1 / 6
Pass 3 — Trade-offs & limits

Retries multiply load: 3 attempts at 100% failure = 3× traffic at the worst moment — cap attempts, jitter always, and let the breaker end the stampede. Timeout budgets compose: if your client gives you 10s and you give vLLM 10s, you've left zero for yourself — inner deadlines must be strictly smaller than outer ones. Breaker thresholds are judgment: too twitchy (trips on 2 blips) and you fail needlessly; too numb (50 failures) and it never helps — start ~5 consecutive failures / 30s cool-down and tune with S5's load tests. Budget math for your stack: an L4 at roughly $0.80/hr serving ~50 output tok/s ≈ $0.004 per 1k output tokens of compute time — so a 100k-token/day per-key budget bounds each key to pennies of GPU; the point isn't the pennies, it's that runaway loops and leaked keys hit a wall you chose.

Decision tree

Exact cache · semantic cache · no response cache

Q1 · Is each answer personalized or stateful? user-specific context · live tools · deep multi-turn anything where user A's answer ≠ user B's NO response cache — cache components instead embeddings · retrieval · tool results; lean on engine prefix cache yes no Q2 · Are repeats byte-identical? templated prompts · temperature ≈ 0 · canonical JSON machine callers hitting the same asks EXACT-MATCH cache hash(model+messages+params) → Redis · µs hits · zero false positives yes no Q3 · Do many users paraphrase the same asks — and is a close-enough answer acceptable? FAQ / support / head-heavy RAG · tolerance for near-dupes SEMANTIC cache (on top of exact) redisvl / LangCache · start τ ≈ 0.65–0.85 · watch false-hit rate weekly yes no DEFAULT · no cache — measure first instrument repetition for a week · add exact → semantic in that order, each justified by observed hit rate
Hover each node. The ladder is deliberate: none → exact → +semantic, each rung earned by measurement.
Reality check · mid-2025 → July 2026

Redis now offers LangCache — a managed semantic-cache API (Redis Cloud, in preview) with automatic embedding via cache-tuned models (langcache-embed family) — and publishes concrete tuning guidance: start around 0.65 similarity for FAQ-style traffic, lower if paraphrases miss, raise if wrong answers appear; general write-ups peg common production thresholds at 0.85–0.95 depending on embedding scale. The OSS path is redisvl's SemanticCache. Independent 2026 benchmarking of two-level (exact→semantic) setups against local models validates the ladder this session teaches.

Sources: redis.io LangCache docs & tutorial (Jan–Mar 2026) · redisvl SemanticCache docs · aiechoes.substack.com two-level benchmark (Mar 2026)

What you hand-build here is crystallizing into products: Envoy AI Gateway ships token-based rate limiting and cost tracking as Kubernetes-native policy (1–3ms overhead, early-stage provider coverage); Kong AI Gateway 3.14 (Apr 2026) governs LLM/MCP/agent traffic with advanced AI rate-limiting and semantic prompt-guard plugins (enterprise tier). Two readings: (1) your S4 design — limit tokens, not just requests — is the industry pattern, and (2) when many teams need it, S5's tree may hand this box to a gateway. Building it once by hand is what lets you evaluate those products on substance.

Sources: dev.to open-source AI gateway comparison (Mar 2026) · kosmoy.com LiteLLM vs Kong AI Gateway, verified Jul 16 2026 · odock.ai Envoy AI Gateway vs LiteLLM (Jun 2026)

Where the material has aged

§10's from-scratch Qdrant semantic cache remains the best way to learn the mechanism — but its off-the-shelf pick, gptcache, has gone quiet since the material's window; 2026 production guidance centers on redisvl/LangCache or gateway-level caches instead. §9's slowapi walkthrough is still accurate, including its own caveat that async/WebSocket limiting needs fastapi-limiter; the durable lesson there is the Redis-backed-counters-across-replicas requirement, which every current tool shares. Its example threshold of 0.35 Euclidean distance also reads inverted against today's cosine-similarity conventions (higher = closer) — know which metric your tool reports before copying any number.

Apply to my stack — Lab S4 · wiring the gauntlet

Goal: add Redis-backed identity, token-aware limits + budgets, the two-level cache, and a guarded upstream call to gemma-gateway. One container dependency: docker run -d -p 6379:6379 redis:7; uv add "redis" "redisvl".

# app/gauntlet.py — identity, limits, budget (Redis = shared truth across replicas)
import hashlib, time
from fastapi import Depends, Header, HTTPException
from redis.asyncio import Redis
r = Redis(host="localhost", decode_responses=True)

KEYS = {hashlib.sha256(b"gg_live_demo123").hexdigest:
        {"owner": "you", "scopes": {"chat", "chat:thinking"}, "tpd": 200_000}}

async def current_key(authorization: str = Header(...)):
    h = hashlib.sha256(authorization.removeprefix("Bearer ").encode).hexdigest
    if h not in KEYS: raise HTTPException(401, "invalid key")
    return {"id": h[:12], **KEYS[h]}

async def check_limits(key: dict, est_tokens: int):
    kid, now_min = key["id"], int(time.time // 60)
    reqs = await r.incr(f"rl:{kid}:{now_min}")            # fixed window: requests/min
    await r.expire(f"rl:{kid}:{now_min}", 120)
    if reqs > 30:
        raise HTTPException(429, "rate limit: 30 req/min",
                            headers={"Retry-After": "60"})
    day = time.strftime("%Y%m%d")
    used = int(await r.get(f"bg:{kid}:{day}") or 0)       # token budget: pre-check estimate
    if used + est_tokens > key["tpd"]:
        raise HTTPException(429, f"daily token budget exhausted ({key['tpd']})")

async def debit(key_id: str, tokens: int):               # post-stream reconcile (BackgroundTask)
    day = time.strftime("%Y%m%d")
    await r.incrby(f"bg:{key_id}:{day}", tokens)
    await r.expire(f"bg:{key_id}:{day}", 172_800)
# app/cache.py — level 1 exact, level 2 semantic
import hashlib, json
from redisvl.extensions.cache.llm import SemanticCache

def exact_key(payload: dict) -> str:
    blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return "ex:" + hashlib.sha256(blob.encode).hexdigest

sem = SemanticCache(name="ggsem", redis_url="redis://localhost:6379",
                    distance_threshold=0.15)      # redisvl uses DISTANCE: lower = closer.
                                                  # start strict; loosen only against data.
async def lookup(payload: dict, prompt: str):
    if hit := await r.get(exact_key(payload)):                    # µs level
        return json.loads(hit), "exact"
    if res := await sem.acheck(prompt=prompt, num_results=1):     # ms level
        return json.loads(res[0]["response"]), "semantic"
    return None, None

async def store(payload: dict, prompt: str, response: dict):
    await r.set(exact_key(payload), json.dumps(response), ex=3600)
    await sem.astore(prompt=prompt, response=json.dumps(response))
# app/guarded.py — timeout envelope + classified retry + minimal breaker
import asyncio, random, time
import httpx
from fastapi import HTTPException

class Breaker:
    def __init__(self, threshold=5, cooldown=30):
        self.fails, self.threshold, self.cooldown, self.opened = 0, threshold, cooldown, 0.0
    def gate(self):
        if self.fails >= self.threshold and time.time - self.opened < self.cooldown:
            raise HTTPException(503, "model server unavailable (breaker open)")
    def ok(self):   self.fails = 0
    def fail(self):
        self.fails += 1
        if self.fails == self.threshold: self.opened = time.time

breaker = Breaker

async def guarded_post(http: httpx.AsyncClient, path: str, payload: dict):
    breaker.gate
    for attempt in range(3):                                 # retry CONNECT-level only
        try:
            resp = await http.post(path, json=payload)
            if resp.status_code in (429, 503) and attempt < 2:
                await asyncio.sleep((0.5 * 2**attempt) * random.uniform(0.7, 1.3))
                continue                                     # backoff + jitter
            resp.raise_for_status; breaker.ok; return resp
        except (httpx.ConnectError, httpx.ConnectTimeout):
            breaker.fail
            if attempt == 2: raise HTTPException(503, "model server unreachable")
            await asyncio.sleep((0.5 * 2**attempt) * random.uniform(0.7, 1.3))
# the handler, gauntlet-ordered (streaming variant guards the pump the same way;
#  first-token timeout = wait_for around the first queue item in S3's relay)
@app.post("/v1/chat", response_model=ChatResponse)
async def chat(req: ChatRequest, bg: BackgroundTasks,
               key: Annotated[dict, Depends(current_key)], http: HttpDep):
    if req.thinking and "chat:thinking" not in key["scopes"]:
        raise HTTPException(403, "thinking mode not in your plan")     # authz = scope
    est = sum(len(m.content) for m in req.messages) // 4 + req.max_tokens
    await check_limits(key, est)                                       # limits + budget
    payload = build_payload(req)
    prompt = req.messages[-1].content
    cacheable = (req.temperature <= 0.3) and not req.thinking
    if cacheable:
        hit, level = await lookup(payload, prompt)
        if hit: return ChatResponse(**hit, cached=level)               # $0 exit
    resp = await guarded_post(http, "/v1/chat/completions", payload)   # the $ step
    data = shape_response(resp.json)
    if cacheable: bg.add_task(store, payload, prompt, data.model_dump)
    bg.add_task(debit, key["id"], data.usage.prompt_tokens + data.usage.completion_tokens)
    return data
# verify the gauntlet bites
for i in $(seq 1 35); do curl -so /dev/null -w "%{http_code} " \
  localhost:8000/v1/chat -H "Authorization: Bearer gg_live_demo123" \
  -H 'content-type: application/json' -d '{"messages":[{"role":"user","content":"hi"}]}'; done
# → 200 …then 429s at #31. Repeat one question twice → second returns cached:"exact" in ~ms.
# Rephrase it → cached:"semantic". Stop your Modal app → five requests → fast 503 "breaker open".

Stack notes. (1) Order is the lesson: scope → limits → cache → guarded call → debit; every rejection above the call costs microseconds. (2) cacheable excludes thinking mode and hot temperatures — your flash/thinking split maps cleanly onto cache policy. (3) The breaker + your Modal scale-to-zero interact: a cold start looks like slow, not down — that's why only connect-level failures trip it here, and why the S3 heartbeat keeps streams alive through warm-up. (4) Prefix-cache discipline is already enforced by S1's system_first validator — S4 adds nothing at the engine because you configured that stage correctly months ago.

Same idea, four ecosystems

OSS Redis + redisvl

Counters, budgets, exact + semantic cache, breaker state — one Redis serves the whole gauntlet across replicas.

Gateways LiteLLM / Envoy / Kong

Virtual keys, token-based limits, budgets, semantic caching as config — the productized gauntlet. S5 decides build vs adopt.

AWS API GW + ElastiCache

Usage plans give request throttling/quotas per key; token-awareness and semantic caching remain app/gateway concerns.

GCP Apigee / Memorystore

Apigee policies for quotas and spike arrest; Memorystore Redis backs the app-level token math.

S5SESSION FIVE

Prove & Ship — testing, load, containers, and the build-vs-buy call

Everything so far is claims. This session turns claims into evidence: tests that survive non-deterministic models, load tests that measure what users feel (TTFT, not "request duration"), a container that ships the gateway anywhere — and the honest question of when to stop building and adopt an AI gateway product.

Concept 1 · Testing a service wrapped around a non-deterministic model

Test your layer exactly; test the model statistically

Pass 1 — Intuition

You can't assert output == "Paris is the capital…" against a generative model — same input, different phrasing tomorrow. The escape is a clean split along your favorite line, the boundary. Everything amber is deterministic: given this request, does validation reject it? Is the payload to vLLM shaped right? Does the relay frame SSE correctly? Does the gauntlet 429? All exactly testable — if the model server is replaced by a stand-in. That stand-in is a mock (a fake dependency that returns scripted responses and records how it was called) or a stub (the simpler cousin: canned responses, no call recording). Everything cyan — answer quality — gets a different discipline the material frames well: treat model behavior statistically — run N samples, assert properties and rates ("≥95% contain a JSON block", "0/50 leak the system prompt"), not exact strings. That's the world of evals, a later stage; today you build the harness that makes the amber layer provably correct.

Pass 2 — Mechanism

The testing pyramid, gateway edition — and the two FastAPI mechanisms that make its base cheap:

unit & contract — your logic, exact asserts, no model integration — real app, mocked model server smoke — real Modal endpoint cost and flakiness rise as you climb; volume and speed live at the bottom. The model itself is tested statistically, off this pyramid — that's evals.
Hover each layer: what it tests, what replaces the model, and when it runs.

Mechanism one — dependency override: FastAPI's built-in switch, app.dependency_overrides[real_dep] = fake_dep, replacing any dependency for the duration of a test. Because S1 injected the upstream client as get_http, swapping the entire model server for a script is one line — the architectural payoff of DI. Mechanism two — fixtures (pytest functions that build reusable setup — a client, a fake upstream — injected into tests by argument name), with conftest.py sharing them across files and parametrize running one test over many cases (the material walks all three, plus setup/teardown via yield).

# the whole trick in eight lines (full harness in the lab)
def fake_vllm(request: httpx.Request) -> httpx.Response:      # scripted "model server"
    return httpx.Response(200, json=OPENAI_SHAPED_REPLY)

transport = httpx.MockTransport(fake_vllm)                    # httpx's built-in stub layer
app.dependency_overrides[get_http] = lambda: httpx.AsyncClient(
    transport=transport, base_url="http://fake")              # S1's DI seam, flipped
# every route now runs its FULL logic — auth, limits, cache, relay — against the script
Pass 3 — Trade-offs & limits

Mocks drift. A mock encodes your belief about vLLM's responses; when vLLM upgrades (new fields, changed finish_reasons), green tests can lie. Antidotes: build mock payloads from captured real responses (re-record on upgrades), and keep the thin smoke layer against the live endpoint. Test the unhappy paths hardest: 422s for every contract rule, 401/403/429 from the gauntlet, upstream 503 → breaker behavior, malformed SSE lines mid-stream — production is mostly unhappy paths. Flaky ≠ broken: the material example is sobering — a study found GPT-4's accuracy on a prime-number task collapsing from 84% to 51% between two 2023 snapshots. Same API, same prompt, different quarter. Anything asserting on live model output belongs in statistical evals with thresholds, never in the commit-blocking suite; a single-sample "the model answered correctly" test is a coin you'll eventually lose.

Concept 2 · Load testing

"Request duration" lies about streams — measure TTFT, ITL, goodput

Pass 1 — Intuition

A classic load tool reports "p95 request duration: 24s" — and for a streaming endpoint that's useless, maybe great news (long thoughtful answers) or a disaster (users staring at nothing). You already own the right vocabulary from the inference stage; now it becomes the load test's vocabulary: TTFT (time to first token — how long until the user sees anything), ITL (inter-token latency — the pace tokens arrive after that), and the roll-up 2026 practice has settled on, goodputthroughput counting only requests that met their latency targets (e.g. TTFT < 500ms AND ITL < 50ms), not merely completed. Raw throughput says the kitchen shipped 100 plates; goodput says how many arrived hot. A load test that measures request duration on a stream conflates the two numbers users actually feel.

Pass 2 — Mechanism
One streamed request, as the load tool must see it t₀ send t₁ first token TTFT = t₁ − t₀ ITL = gap between tokens (report p95) t₂ [DONE] "request duration" = TTFT + generation — the number that conflates both goodput = share of requests with TTFT AND p95-ITL inside target, at a given concurrency — plot it vs load; the knee is your capacity.
Hover t₀ → t₂. A load tool earns its keep only if it timestamps token EVENTS — which is why SSE-aware tooling matters.

Two more mechanics decide whether results mean anything. Open vs closed loop: a closed-loop test (N virtual users, each waiting for its response before sending again) self-throttles — as the system slows, arrival rate drops, hiding the very overload you're probing. An open-loop test (requests fired at a fixed rate, e.g. Poisson arrivals, regardless of completions) models real users, who don't coordinate — your queueing-theory stage predicted exactly this failure of closed-loop thinking. Tool support: the tool must parse SSE and expose per-event timestamps as custom metrics — plain "HTTP duration" tools measure the wrong thing out of the box.

Pass 3 — Trade-offs & limits
toolSSE / token timingnotes for this stage
k6 (+ xk6-sse)via extension; custom Trend metrics for TTFT/ITLJS scenarios, open-loop arrival rates; needs a custom build with the SSE extension — the lab's choice
LocustDIY event parsing in Pythonfamiliar Python; single-core per process (GIL) — distribute workers for high RPS; LLM-specific forks exist (e.g. "LLM Locust") adding token metrics
GuideLLM (Red Hat)native TTFT/ITL/throughputpurpose-built for OpenAI-compatible endpoints — point it at the gateway or straight at vLLM to isolate your layer's overhead
Gatlingnative SSE supportJVM; strong reporting; heavier setup
genai-perf (NVIDIA)native LLM metricsthe vendor-standard companion when comparing against NIM/Triton

Judgment calls: load test the gateway and the upstream separately before together — GuideLLM straight at Modal gives the model-server baseline; the gateway run on top isolates your layer's added TTFT (target: single-digit ms warm). Test through the gauntlet with real keys so limiters and caches participate (a 95% cache-hit load test flatters you; disable or vary prompts to probe the miss path). And test the failure modes you built: kill the Modal app mid-run and watch the breaker turn a hang into fast 503s.

Concept 3 · Containers & deployment

One image, one worker, many replicas

Pass 1 — Intuition

§12's map of ways to run this thing — a VM you administer, serverless functions (code that runs per-request on managed infrastructure, billed per invocation), a managed PaaS, or containersprocesses shipped with their entire filesystem (Python, dependencies, your code) as one immutable image, so "works on my machine" becomes "works anywhere the image runs". The shipping-container metaphor is literal: standardized box, any ship, any crane. For a gateway the industry answer is settled: containers, because every deploy target left standing — Cloud Run, ECS/Fargate, Kubernetes, even Modal — is fundamentally "give me an image." And note what the boundary buys you at this final step: your image is CPU-only, small, and boring. No CUDA, no weights, no GPU drivers — all of that lives across the boundary in the Modal image you already maintain. Two artifacts, two lifecycles, two bills.

Pass 2 — Mechanism

A production image is built in stages — a multi-stage build (a Dockerfile with several FROM stages, where only chosen files from earlier stages are copied into the final image, leaving build tools behind). Walk the five moves; the lab has the full file:

Stage one exists to be thrown away. Start from an image that ships uv (Astral publishes one), and install dependencies in two layers: first uv sync --frozen --no-install-project against only pyproject.tomluv.lock — so this slow layer is cached until dependencies change — then copy your code and sync the project itself. --frozen means "the lockfile is law"; UV_COMPILE_BYTECODE=1 pre-compiles .pyc so containers start faster; a cache mount keeps uv's download cache out of the image.

Stage two starts clean — plain python:3.12-slim — and copies exactly one thing from the builder: the finished virtual environment (plus your app/). No compilers, no uv, no package caches ride along. Result: a few-hundred-MB image instead of a gigabyte-plus, faster to pull on every scale-up, with a smaller attack surface.

Run as nobody important. Create a non-root user and USER it (container escapes land in an unprivileged account); pin the base image; add a HEALTHCHECK hitting your S1 /healthz so the orchestrator can tell "process up" from "actually serving" — the deep-vs-shallow health distinction from S1, now enforced by the platform.

One uvicorn, no worker tree. CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] — a single async worker per container. Configuration enters as environment variables (your S1 Settings reads them): same image in dev and prod, only the env differs. Secrets (API keys, Redis URL) come from the platform's secret store, never baked into layers.

Concurrency comes from replicas. Need more capacity? Run more containers behind the load balancer — the orchestrator is your process manager, health-checking, restarting, and rolling deploys included. This is the S2 reality-check idiom operationalized: horizontal replicas instead of an in-container gunicorn tree, which keeps streams, memory, and failure domains simple (one container = one event loop = one blast radius).

1 / 5
Pass 3 — Trade-offs & limits

Picking the target is mostly picking who manages what. Cloud Run (or Azure Container Apps): hand over the image, get autoscaling + HTTPS; mind two knobs from S3 — request timeout must exceed your longest stream, and scale-to-zero gives your gateway a cold start too (mild — CPU containers boot in seconds, but stack it mentally atop Modal's GPU cold boot). ECS/Fargate + ALB: more wiring, more control — remember the 60s idle timeout. Kubernetes: you already speak it from the serving stage; the gateway is a plain Deployment + Service + Ingress, no KServe ceremony needed, and its replicas autoscale on CPU while your GPU pool scales on very different signals — independent scaling being precisely the boundary's promise. Sizing: an async gateway is cheap — a 0.5-vCPU/512MB replica comfortably relays hundreds of concurrent streams; you'll scale for redundancy (≥2 replicas) before you scale for load. What stays out: Redis is a managed service or its own container, never inside the gateway image — replicas must share it (S4's whole premise).

The landscape

Build vs buy: the AI-gateway market, July 2026

Everything you built in S1–S4 now exists, in some configuration, as products called AI gatewaysreverse proxies specialized for LLM traffic: provider routing, virtual keys, token-aware limits, budgets, caching, observability as configuration. Knowing the categories keeps you honest about the tree below:

Python proxy LiteLLM

The default multi-provider gateway: 100+ providers behind one OpenAI-shaped API, virtual keys, per-key budgets, spend tracking, a management UI. Trade-off named by every 2026 comparison: it's a Python data plane — feature-rich, with a real throughput/latency ceiling under heavy concurrent streaming.

K8s-native Envoy AI Gateway

CNCF lineage: LLM policy — token-based rate limiting, cost tracking, provider failover — as Kubernetes CRDs on Envoy's wire-speed data plane (1–3ms overhead). The infra-team answer when the platform already runs Envoy/Gateway API.

Enterprise plugin Kong AI Gateway 3.14

April 2026 release folds LLM, MCP, and agent-to-agent traffic into Kong's plugin ecosystem: advanced AI rate limiting, semantic prompt guards, provider abstraction — compelling where Kong already fronts your APIs; enterprise licensing for the good parts.

Go data plane Bifrost (and kin)

The performance flank: Go-based gateways advertising microsecond-class overhead at thousands of RPS — an order beyond Python proxies. For teams whose bottleneck genuinely is the proxy tier, not the GPUs behind it.

Plus the managed tier — Cloudflare AI Gateway, Portkey, Helicone, OpenRouter — gateway-as-a-service with caching, analytics, and routing for a per-request fee or markup. And the economics that frame every choice here: at mid-2026 rates an H100 at ~$2.40/hr serving a 70B FP8 model works out near $1.67 per million output tokens, against roughly $10 per million for GPT-4o-class API output — self-hosting pays if utilization is real, which is exactly what your batching + caching + limits machinery exists to make true. Your L4 at ~$0.80/hr is the same equation at hobby scale.

Decision tree

Who should be your gateway?

The stage-closing call. Follow no ↓; first yes exits right; bottom-left is the default — and notice the default is the thing you just built.

Q1 · Many teams sharing many providers, needing central keys, budgets & a management UI? org-wide LLM access · finance wants one dashboard LiteLLM proxy — or managed (Portkey / Cloudflare) virtual keys, budgets, 100+ providers; accept the Python-proxy ceiling yes no Q2 · K8s platform team enforcing token limits & routing as infra-level policy? fleet-wide guardrails · CRDs over code · wire-speed Envoy AI Gateway / Kong AI Gateway token-native limiting at 1–3ms; your FastAPI keeps the business logic yes no Q3 · Is the proxy tier itself your measured bottleneck at scale? 1000s of concurrent streams · µs budgets · load-test proof Compiled data plane (Bifrost / Envoy) Go/C++ proxies at µs–ms overhead; FastAPI stays for logic-heavy routes yes no DEFAULT · your FastAPI app layer IS the gateway single product, few upstreams, custom logic · revisit when a "yes" appears above — migration is config, not rewrite
Hover each node. The exits are additive, not exclusive — a LiteLLM management plane can front product gateways; Envoy can front FastAPI.
Reality check · mid-2025 → July 2026

Current practitioner guidance is blunt: classic "request duration" metrics conflate TTFT with generation time and must be replaced by token-event timing; capacity gets reported as goodput — throughput under SLO (typical interactive targets: TTFT < 500ms, ITL < 50ms) — with open-loop (Poisson) arrivals to avoid the closed-loop self-throttling trap. The tool shelf matured to match: k6's SSE extension, Gatling's native SSE support, Red Hat's GuideLLM and LLM-specific Locust derivatives shipping TTFT/ITL out of the box. Your S5 lab implements the k6 path so the numbers you quote are the numbers the field means.

Sources: tianpan.co LLM load-testing guide (Apr 2026) · latitude.so metrics-that-matter (Feb 2026) · premai.io benchmarking survey (Mar 2026) · gatling.io SSE docs

The 2026 idiom for Python images is uv-first: Astral's official images and docs prescribe the two-layer uv sync --frozen pattern with cache mounts, UV_COMPILE_BYTECODE=1, and copying only the finished .venv into a slim (or distroless, UID 65532) runtime — dependency installs measured in seconds and images cut by hundreds of MB versus the pip-era flow. Paired with the replicas-over-worker-trees idiom from S2's reality check, the lab's Dockerfile is current best practice, not just a working example.

Sources: docs.astral.sh uv Docker integration (2026, uv 0.11.x) · nerdleveltech.com uv+distroless walkthrough (May 2026) · markaicode.com FastAPI container guide (May 2026)

Mid-2026 comparisons converge on the taxonomy the tree encodes: Python management planes (LiteLLM — richest features, real perf ceiling), infrastructure data planes (Envoy AI Gateway's CRD-driven token limits; Kong 3.14 adding LLM/MCP/agent governance), and speed-first Go proxies (Bifrost's microsecond-class claims), with managed services (Cloudflare, Portkey, Helicone, OpenRouter) wrapping the same features as SaaS. The consistent editorial line: pick by who operates it and where policy should live, not by feature checklists — every column now advertises caching, budgets, and token limits.

Sources: kosmoy.com LiteLLM-vs-Kong (verified Jul 16 2026) · dev.to OSS AI-gateway comparison (Mar 2026) · odock.ai Envoy-vs-LiteLLM (Jun 2026) · getmaxim.ai gateway roundup (2026) · spheron.network self-hosting economics (Mar 2026)

Where the material has aged

§11 is the material at its most durable — fixtures, parametrization, mocking discipline, and the treat-model-quality-statistically framing all read as current. §12 shows its 2025 vintage in tooling, not architecture: its pip/requirements.txt and docker init workflow predates the uv-first idiom above, and its gunicorn-workers guidance predates the replicas idiom; its taxonomy of deployment options and its multi-stage/caching fundamentals remain exactly right. Neither source covers stream-aware load testing (TTFT/ITL/goodput) — that practice hardened after their windows, which is why this session leans on 2026 field sources for it.

Apply to my stack — Lab S5 · the proof kit

Goal: a test harness that exercises the whole gauntlet against a scripted vLLM, a k6 script that measures TTFT/ITL through your stream, and the container that ships it. New files:

gemma-gateway/ ├── tests/ │ ├── conftest.py # fake vLLM + app fixtures │ ├── test_contract.py # S1's promises, enforced forever │ ├── test_gauntlet.py # S4's gates │ └── test_stream.py # S3's framing ├── load/stream_test.js # k6 + xk6-sse ├── Dockerfile └── .dockerignore
# tests/conftest.py — the scripted model server, wired through S1's DI seam
import json, httpx, pytest
from httpx import ASGITransport
from app.main import app
from app.deps import get_http

OPENAI_REPLY = {"id": "cmpl-test", "model": "gemma-4-e4b",
  "choices": [{"index": 0, "finish_reason": "stop",
               "message": {"role": "assistant", "content": "Test answer."}}],
  "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}}

def sse_bytes -> bytes:                       # captured-shape stream: reasoning → tokens → usage → DONE
    chunks = [
        {"choices": [{"delta": {"reasoning_content": "thinking…"}, "index": 0}]},
        {"choices": [{"delta": {"content": "Test"}, "index": 0}]},
        {"choices": [{"delta": {"content": " answer."}, "index": 0}]},
        {"choices": [], "usage": OPENAI_REPLY["usage"]},
    ]
    body = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks)
    return (body + "data: [DONE]\n\n").encode

def fake_vllm(request: httpx.Request) -> httpx.Response:
    if request.url.path == "/health":
        return httpx.Response(200)
    payload = json.loads(request.content)
    if payload.get("stream"):
        return httpx.Response(200, content=sse_bytes,
                              headers={"content-type": "text/event-stream"})
    return httpx.Response(200, json=OPENAI_REPLY)

@pytest.fixture
async def client(monkeypatch):
    fake = httpx.AsyncClient(transport=httpx.MockTransport(fake_vllm),
                             base_url="http://fake-vllm")
    app.dependency_overrides[get_http] = lambda: fake        # the one-line swap
    async with httpx.AsyncClient(transport=ASGITransport(app=app),
                                 base_url="http://test") as c:
        yield c                                               # setup ↑ / teardown ↓ via yield
    app.dependency_overrides.clear; await fake.aclose

AUTH = {"Authorization": "Bearer gg_live_demo123"}
# tests/test_contract.py — every 422 is a promise kept
import pytest
from tests.conftest import AUTH

@pytest.mark.parametrize("body", [
    {"messages": []},                                              # empty conversation
    {"messages": [{"role": "user", "content": "hi"}], "temperature": 3.0},
    {"messages": [{"role": "user", "content": "hi"}], "max_tokens": 999_999},
    {"messages": [{"role": "user", "content": "hi"}], "tempratuer": 0.2},  # typo → forbid
])
async def test_bad_requests_are_422(client, body):
    r = await client.post("/v1/chat", json=body, headers=AUTH)
    assert r.status_code == 422

async def test_missing_key_is_401(client):
    r = await client.post("/v1/chat",
        json={"messages": [{"role": "user", "content": "hi"}]})
    assert r.status_code in (401, 422)   # 422 if Header(...) itself rejects; assert intent, not accident
# tests/test_stream.py — assert the FRAMING, never the words
from tests.conftest import AUTH

async def test_stream_event_grammar(client):
    events = []
    async with client.stream("POST", "/v1/chat/stream", headers=AUTH,
        json={"messages": [{"role": "user", "content": "hi"}], "thinking": True}) as r:
        assert r.status_code == 200
        assert r.headers["content-type"].startswith("text/event-stream")
        async for line in r.aiter_lines:
            if line.startswith("event: "):
                events.append(line.removeprefix("event: "))
    assert events[0] == "reasoning"          # thinking surfaces first…
    assert "token" in events                 # …answer tokens present…
    assert events[-1] == "done"              # …and the stream announces completion
    # properties, not prose: this test survives every model upgrade
# load/stream_test.js — k6 + SSE: TTFT and ITL as first-class metrics
// build once:  xk6 build --with github.com/phymbert/xk6-sse   (custom k6 binary)
import sse from "k6/x/sse";
import { Trend, Rate } from "k6/metrics";

const ttft = new Trend("ttft_ms", true);
const itl  = new Trend("itl_ms", true);
const ok   = new Rate("stream_completed");

export const options = {
  scenarios: { chat: {                       // OPEN loop: fixed arrival rate,
    executor: "constant-arrival-rate",       //   slowdowns can't hide behind it
    rate: 5, timeUnit: "1s", duration: "2m",
    preAllocatedVUs: 60,
  }},
  thresholds: { ttft_ms: ["p(95)<1500"], itl_ms: ["p(95)<80"] },  // your SLOs, enforced
};

export default function  {
  const t0 = Date.now; let first = 0, last = 0, done = false;
  sse.open("http://localhost:8000/v1/chat/stream", {
    method: "POST",
    headers: { "Content-Type": "application/json",
               "Authorization": "Bearer gg_live_demo123" },
    body: JSON.stringify({ messages: [{ role: "user",
           content: `Explain queueing theory. (${__ITER})` }] }),   // vary → dodge S4 cache
  }, (client) => {
    client.on("event", (ev) => {
      const now = Date.now;
      if (!first) { first = now; ttft.add(now - t0); }
      else if (ev.name === "token") { itl.add(now - last); }
      last = now;
      if (ev.name === "done") { done = true; ok.add(true); client.close; }
    });
    client.on("error",  => { ok.add(false); client.close; });
  });
}
# Dockerfile — two stages, uv-first, single async worker
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --frozen --no-install-project --no-dev      # deps layer: cached until lock changes
COPY app/ app/
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev

FROM python:3.12-slim-bookworm
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --from=builder --chown=app:app /app /app            # .venv + code, nothing else
ENV PATH="/app/.venv/bin:$PATH"
USER app
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD ["python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=2)"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# .dockerignore — keep the context honest
.venv/
__pycache__/
.git/
tests/
load/
*.md
# run the proof kit
uv add --dev pytest pytest-asyncio            # asyncio_mode = "auto" in pyproject
uv run pytest -q                              # green in <2s — no GPU touched
docker build -t gemma-gateway:0.1 .
docker run -p 8000:8000 --env-file .env gemma-gateway:0.1
./k6 run load/stream_test.js                  # first vs the mocked upstream, then vs Modal:
                                              #   the TTFT delta between runs = YOUR layer's cost
# deploy sketch (Cloud Run): gcloud run deploy gemma-gateway --image … \
#   --timeout=3600 --min-instances=1 --set-env-vars VLLM_BASE_URL=… --set-secrets …

Stack notes. (1) sse_bytes mirrors your real upstream — reasoning deltas, then content, then a usage-only chunk (because your relay requests include_usage), then [DONE]; when you upgrade vLLM on Modal, re-capture one real stream and refresh this fixture. (2) The k6 threshold p95 TTFT < 1500ms assumes a warm Modal container — run the smoke warm-up first, or your scale-to-zero cold boot fails the run by itself (a fact worth knowing, not hiding: record cold separately). (3) The image contains no secrets and no model anything — VLLM_BASE_URL arrives at runtime, so the same artifact serves staging and prod. The boundary you drew in Tab 0 is now visible as two independently shippable containers.

Same idea, four ecosystems

OSS pytest · k6 · Docker

The lab's exact kit: DI-override harness, xk6-sse for token timing, uv multi-stage images — portable everywhere below.

NVIDIA genai-perf · NIM

genai-perf reports TTFT/ITL against any OpenAI-compatible endpoint; NIM is the "buy" answer to this whole tab — a hardened, health-checked container with the proxy layer prebuilt.

AWS ECS/Fargate + ALB

Push the image to ECR, run on Fargate behind ALB (raise idle timeout for S3); Distributed Load Testing on AWS for fleet-scale k6.

GCP Cloud Run

Deploy the image directly; set --timeout ≥ longest stream, min-instances for warm gateways; Cloud Build for CI images.

Stage complete — what you now own

A contract-first gateway (S1) that waits correctly at any concurrency (S2), streams tokens through hostile infrastructure and cancels what nobody's watching (S3), makes "no" cheap with identity, token-aware limits, layered caches, and a guarded upstream (S4) — proven by tests and token-level load metrics, shipped as a hardened container, with an evidence-based answer to "build or buy" (S5). The anti-pattern from your earlier stage is fully inverted: not FastAPI instead of a model server, but FastAPI as the product-shaped brain in front of one. Natural next stages when you're ready: observability (tracing every gauntlet gate), and evals (the statistical testing this stage deliberately deferred).

← 05The path
Next stage · 07 →genaipros · 06 · Production APIs for GenAIAI for Everyone ↗