« Phase 04 · Warmup · Track Overview
Lab 01 — The LLM Gateway & Model Abstraction Layer
The problem
Twelve agent teams, six model providers, three regions, two capacity types, and a regulator who will ask where each inference ran. If every team calls providers directly you get: twelve sets of credentials, twelve retry policies (some of which retry payments), no idea what anything costs, no way to migrate off a provider, and no answer to "which model produced this decision?"
The gateway is the fix. It is the platform's choke point in the good sense — one place to enforce, one place to observe, one place to change providers — and it is the component this JD names first in the integrations section.
What you build
| # | Component | What it does |
|---|---|---|
| 1 | NormalizedRequest / NormalizedResponse / Usage | one shape for every provider, carrying the fields a provider SDK cannot: tenant, agent, classification, residency, latency budget, side-effecting |
| 2 | The error taxonomy | RateLimited, ProviderTimeout, ProviderUnavailable, ContentFiltered, InvalidRequest, QuotaExceeded, NoRouteAvailable, BudgetExhausted — each with retryable and fall_over flags |
| 3 | Deployment, Capacity | the real routing target, with per-tier pricing and an expected latency |
| 4 | RoutingRule, Router | policy-based routing on task class, tenant and classification, with residency and per-deployment ceilings as a second gate |
| 5 | TokenBucket, RateLimiter, QuotaLedger | per-tenant RPM and TPM, plus a monthly spend ceiling |
| 6 | hash_embed, ExactCache, SemanticCache | three cache tiers, tenant-partitioned, with a similarity floor |
| 7 | Accounting | token accounting and cost attribution by tenant / agent / deployment / provider / model, plus cache-hit and failover rates |
| 8 | Gateway.complete | the full path: quota → rate limit → route → cache → budget-aware fallback → account → cache |
Key concepts
| Concept | Where | Why it matters |
|---|---|---|
| Normalize errors, not just responses | the taxonomy | the happy path is easy; a caller cannot handle six providers' failure vocabularies |
fall_over ≠ retryable | ContentFiltered | a safety refusal is retryable nowhere and must not trigger failover — that is shopping for a compliant model |
| Deployment ≠ model | Deployment | one model in two regions has two latencies, two prices and two residency answers |
| Route on the caller, not the model name | RoutingRule | a caller that names a model has hard-coded a vendor decision into an agent |
| Budget-aware fallback | _execute_with_fallback | a fallback that does not fit the remaining budget converts partial degradation into a total SLO breach |
| No failover when side-effecting | same | a retried tool-executing call can double-execute |
| Tenant first in every key | cache_key | a semantic cache that crosses tenants is a breach with a great hit rate |
| Never cache a non-STOP finish | _cache_store | a truncated answer is not the answer |
| Account failures too | _record_failure | a cost model counting only successes under-reports during incidents |
| Failover rate | Accounting.failover_rate | shows a provider degrading before the error rate does |
Files
| File | Role |
|---|---|
| lab.py | your implementation |
| solution.py | reference; python solution.py runs a seven-part worked session |
| test_lab.py | 69 tests |
| requirements.txt | pytest |
Run
pip install -r requirements.txt
pytest test_lab.py -v
LAB_MODULE=solution pytest test_lab.py -v
python solution.py
Success criteria
-
All 69 tests green against your
lab.py. -
ContentFilteredhasfall_over = False, and a filtered primary raises rather than trying the next provider. - A rule naming an unknown deployment fails at construction, and a typo'd classification in a rule fails at construction.
-
restricted+eu-westyields no route, and the failure is accounted. - A token-limit rejection does not consume an RPM slot.
- The quota boundary is inclusive: spend == budget refuses.
-
A cache hit calls no provider and reports
cost_micros == 0. - The same prompt for a different tenant is a miss.
-
A
FinishReason.LENGTHresponse is never cached. -
A side-effecting request never falls over; a request with no headroom raises
BudgetExhausted. - Two identically-constructed gateways produce identical responses and identical records.
How this maps to the real stack
| This lab | The real thing | What we simplified |
|---|---|---|
Gateway | Azure APIM with AI policies · LiteLLM · Kong AI Gateway · Portkey · an in-house service | no HTTP, no streaming, no connection pooling |
NormalizedRequest | the OpenAI-compatible shape most gateways adopt, plus custom headers for tenant/classification | ours makes the platform fields first-class instead of headers |
| The error taxonomy | mapping six SDKs' exception hierarchies and status codes onto one | ours has 8 classes; a real one has more, and the mapping is where the bugs are |
RoutingRule | APIM policy expressions, LiteLLM router configs, or a rules service | no hot reload, no weighted splits, no learned routing |
TokenBucket | APIM rate-limit policies, Redis-backed distributed limiters | ours is in-process; a real one is shared across gateway replicas |
ExactCache / SemanticCache | Redis + an embedding model; provider-side prompt caching is a third, separate tier | ours embeds with a hash; real similarity needs a real embedding model and tuning |
Accounting | usage blocks from provider responses, exported to a metrics store and reconciled monthly against billing | ours computes from a deployment price table |
make_scripted_adapter | provider SDKs behind an adapter interface | ours is scripted so failures are reproducible |
Honest limits. No streaming (which changes usage reporting and makes cancellation matter), no distributed rate limiting (the hard part at scale), no provider-side prompt-cache accounting, no weighted or learned routing, no request hedging, and no circuit breaker per deployment — that last one arrives in Phase 10 and belongs here too.
Extensions
- A circuit breaker per deployment. Open after N failures in a window; skip open deployments
in
candidates(). Then measure how it interacts with the fallback chain. - Streaming. Make
completea generator. Discover that usage arrives last (or not at all unless you ask), and that your accounting has to survive a cancelled stream. - Distributed rate limiting. Replace the in-process bucket with a shared counter and think about what happens when the shared store is unavailable — fail open or fail shut?
- Weighted routing and canaries. Split traffic 95/5 to compare a new deployment, with the split deterministic per session so a user does not flip mid-conversation.
- Hedged requests. After p95, fire a second request to the fallback and take the first response. Then compute what it costs you in tokens versus what it buys in tail latency.
- Reconciliation. Add a monthly billing export and a job that compares it to
Accounting.cost_by("deployment"). The gap is where dropped usage blocks live.
Interview / resume bullets
- "Built the bank's LLM gateway: a normalized request/response and — the part that matters — a
normalized error taxonomy across six providers, with
retryableandfall_overas separate flags so a provider safety refusal never triggers failover to another model." - "Made routing a policy over task class, tenant and data classification, with residency and per-deployment classification ceilings as an independent second gate, so restricted data provably cannot leave the region."
- "Made fallback budget-aware: the gateway refuses a fallback that does not fit the caller's remaining latency budget, and refuses any fallback at all for side-effecting requests — which removed the double-execution risk from every agent team at once."
- "Introduced per-tenant token accounting and cost attribution by tenant, agent, deployment and model, including failed calls, plus a failover-rate metric that surfaces a degrading provider before its error rate does."