Free lesson · GenAI Solutions Architecture
Implement tenant-aware routing with per-tenant model and guardrail config
You will build a TenantAwareRouter that extracts tenant context from every incoming request and applies tenant-specific model selection, guardrail policies, and prompt configurations. Implement TenantContextExtractor as a FastAPI dependency that reads the X-Tenant-ID header (or extracts tenant from the JWT tenant_id claim), validates the tenant exists and is active in the tenants table, and attaches a TenantContext Pydantic model (with tenant_id, tenant_name, model_config, guardrail_config, rate_limit) to the request state. Build TenantModelRouter that hooks into LiteLLM's pre_call callback: override the model parameter with the tenant's default_model, apply max_tokens_per_request from tenant config, and inject tenant-specific system prompts from tenant_prompt_templates PostgreSQL table (with tenant_id, template_name, template_content, is_active). Implement TenantGuardrailApplier that loads the tenant's NeMo Guardrails configuration: each tenant has a separate Colang file set stored in tenant_guardrail_configs table with tenant_id, config_type (input_rail, output_rail, dialog_rail), colang_content, and is_active. Build per-tenant guardrail instances cached in Redis with key guardrail:{tenant_id}:{config_hash}. Implement TenantFallbackChain defining per-tenant model fallback sequences: if the primary model returns an error, try fallback_models in order, each with its own token limit and cost ceiling. Store fallback chains in tenant_fallback_configs table. Expose GET /api/v1/tenants/{id}/routing-config showing the effective routing configuration. Emit metrics: tenant_requests_total{tenant_id,model}, tenant_guardrail_blocks_total{tenant_id,rail_type}, tenant_fallback_triggered_total{tenant_id,from_model,to_model}, tenant_routing_latency_seconds{tenant_id}. Build TenantPromptInjectionGuard that adds tenant-specific protections against prompt injection attacks targeting other tenants' configurations. Implement TenantContextValidator that verifies the X-Tenant-ID header matches the JWT tenant_id claim and that the API key belongs to the claimed tenant, preventing tenant impersonation. Define TenantAuthValidation Pydantic model with tenant_id, auth_method, header_tenant, jwt_tenant, api_key_tenant, match_result, validation_time_ms. Store validation failures in tenant_auth_failures table for security analysis. Build TenantRoutingCache using Redis hash tenant_routing:{tenant_id} that caches the complete routing configuration (model, guardrails, fallback chain, rate limits) with a 5-minute TTL, reducing PostgreSQL lookups per request from 4 to 0 for cache hits. Implement TenantConfigChangeNotifier that broadcasts configuration changes to all running instances via Redis pub/sub channel tenant_config_updates, ensuring cache invalidation across the fleet within 1 second. Build TenantTrafficAnalyzer that monitors per-tenant traffic patterns: request volume by hour, model usage distribution, guardrail trigger rate, and fallback frequency. Store analysis in tenant_traffic_patterns table for capacity planning. Create Grafana tenant routing dashboard showing: request flow per tenant, model distribution, guardrail trigger heatmap, fallback chain utilization, and cache hit rate.
Course: GenAI Architecture & Design Patterns · Chapter 24 · Multi-Tenant AI Platform
Free to read — no subscription required.
Introduction
When you serve many customers from one AI platform, every tenant wants something different — a
stricter guardrail, a private retrieval index, Opus instead of Sonnet, a tighter rate limit — and
shipping the wrong policy to a regulated tenant can breach an SLA before anyone notices. A
multi-tenant GenAI platform serves many customers from one deployment, but each tenant needs its
own model choice, guardrails, retrieval index, eval suite, and rate limit. This lesson shows how to
resolve a per-tenant config object at the request edge and thread it through one shared code path —
so onboarding a new tenant is a YAML edit, not a fork — while preserving auditability via a
config_version that flows into every log and trace. By the end you'll be able to model the
TenantConfig schema, wire up the TenantRouter resolver, and verify config_version flows into
every audit log.
Key Terminology
- TenantConfig — the frozen Pydantic object resolved per request that carries the tenant's
primary model, guardrails, retrieval index, eval suite, rate limit, and
config_version; every downstream component reads from this single snapshot so business logic stays tenant-agnostic. - TenantRouter — the resolver that maps a tenant identity (signed JWT claim or
X-Tenant-Idheader) to a mergedTenantConfig, owns the watcher that hot-reloads the config store, and refuses unknown tenants with 403 rather than silently falling back to a default. - Default-and-override merge — field-by-field merge of
_default.yamlwith a tenant's YAML, so unset fields inherit fleet defaults and a fleet-wide change propagates to non-pinning tenants without per-tenant edits. config_version— a short SHA over the merged tenant document, attached to every log line, span, and eval run so behavior changes are traceable to the exact config that produced them.
Concepts
The single code path, many tenants principle
Every request enters the same FastAPI handler. Before the LLM call, a TenantRouter extracts the
tenant identity from a signed JWT claim (or, for service-to-service calls, an X-Tenant-Id header),
looks up the merged config, and attaches it to the request scope. From that point on, the model
client, prompt assembler, guardrail middleware, and retrieval client all read from the same
TenantConfig object. There is no tenant-specific branching in business logic — only data lookups
(see Code Walkthrough).
Resolution happens once and the resulting object fans out as read-only context. Treating
TenantConfig as immutable for the lifetime of a request prevents a subtle class of bugs where a
config reload halfway through a streamed response swaps models mid-call.
Tiers, feature flags, and pilot rollouts
Tier is a coarse policy lever: free gets claude-haiku-default and 60 rpm, pro gets Sonnet and
600 rpm, enterprise unlocks Opus, custom retrieval indexes, and 5000 rpm. Encode tier defaults in
_default.yaml variants or a tier-template loader; do not bake tier logic into the router.
For a new model rollout, add a feature flag in routing.feature_flags and let the model router
consult it. Pilot tenants flip the flag in their override; everyone else continues on the previous
primary. Once telemetry and eval results from pilots are clean for a release window, flip the flag
default to true in _default.yaml. Tenants that opted out have already pinned false, so the
fleet-wide change is safe.
Operating discipline
- Treat
_default.yamlas production code. Review it in PRs, run a contract test that loads every tenant file against the new default to catch validation regressions. - Pin the
config_versionof every offline eval run. Eval results without a config version cannot be compared across time. - Reload events emit a metric and a log line; spikes mean someone is editing the ConfigMap by hand.
- A request never falls back to "default tenant" when resolution fails — it returns 403. Silent defaults hide misconfigured clients.
- The watcher debounces rapid edits (300 ms) so a multi-file
kubectl applyproduces one reload.
Pitfalls
- Mutating
TenantConfigmid-request after a reload. Resolve once at the request edge and pass the same object down. - Putting secrets in tenant YAML. Keep API keys in a Secret reference; the YAML carries an alias.
- Allowing tenants to override fields that are not safe to override (model names not on the approved list, raised rate limits). Validate against an allowlist in the Pydantic model.
- Forgetting that
config_versionmust change when_default.yamlchanges. The hash captures the merged document for exactly this reason. - Rolling a new model by flipping
_default.yamldirectly. Always pilot through a feature flag. - Skipping the eval suite per tier. A free-tier model that passes the enterprise eval is over-spec'd and expensive; an enterprise model that only ran the baseline eval is under-tested.
Code Walkthrough
Building on the previous section, this walkthrough demonstrates the YAML config store, the
TenantConfig schema, the TenantRouter resolver, and the audit log line that stamps every LLM
call with tenant_id + config_version.
Storing tenant configs and reloading them
In Kubernetes, the natural home is a ConfigMap mounted at /etc/tenants/. Each tenant gets one YAML
file, and a global _default.yaml provides the baseline. A filesystem watcher (watchdog or a kube
informer when the ConfigMap is huge) reloads on change without a pod restart.
Code snippetyaml
1# /etc/tenants/_default.yaml 2tier: free 3system_prompt: "You are a helpful assistant. Be concise." 4routing: 5 primary_model: claude-sonnet-default 6 fallback_models: [claude-haiku-default] 7 feature_flags: 8 enable_new_router_2026q2: false 9guardrails: 10 pii_redaction: true 11 profanity_filter: soft 12 max_output_tokens: 2048 13retrieval_index: shared-default 14eval_suite: baseline-eval-v1 15rate_limit_rpm: 60 16 17--- 18# /etc/tenants/acme-health.yaml (enterprise tenant override) 19tenant_id: acme-health 20tier: enterprise 21system_prompt: | 22 You are Acme Health's clinical-documentation assistant. 23 Never produce a diagnosis; always cite the source paragraph. 24routing: 25 primary_model: claude-opus-clinical 26 fallback_models: [claude-sonnet-default] 27guardrails: 28 blocked_topics: [recreational-drugs, self-harm-instructions] 29 profanity_filter: strict 30retrieval_index: acme-health-emr-v3 31eval_suite: clinical-doc-eval-v4 32rate_limit_rpm: 1200
The default-and-override merge is field-by-field, not document-replace. A tenant that only sets
rate_limit_rpm keeps every other inherited value. This is what allows the platform team to roll a
fleet-wide change to _default.yaml and have it propagate to every tenant that has not pinned the
field.
Schema, resolver, and audit trail
Pydantic gives validation, defaults, and a predictable JSON-schema export for tooling; frozen=True
enforces the immutability contract. The TenantRouter swaps the resolved dict atomically under an
RLock so the watcher thread cannot race a request reader. Every LLM call logs tenant_id plus
config_version so behavioral drift becomes a SQL query against a timestamped version rather than
a forensic exercise.
Code snippetpython
1import hashlib 2import json 3from pathlib import Path 4from threading import RLock 5from typing import Literal 6 7import structlog 8import yaml 9from fastapi import HTTPException, Request 10from pydantic import BaseModel, ConfigDict, Field 11 12class GuardrailConfig(BaseModel): 13 pii_redaction: bool = True 14 blocked_topics: list[str] = Field(default_factory=list) 15 max_output_tokens: int = 2048 16 profanity_filter: Literal["off", "soft", "strict"] = "soft" 17 18class RoutingConfig(BaseModel): 19 primary_model: str = "claude-sonnet-default" 20 fallback_models: list[str] = Field(default_factory=list) 21 feature_flags: dict[str, bool] = Field(default_factory=dict) 22 23class TenantConfig(BaseModel): 24 model_config = ConfigDict(frozen=True) 25 26 tenant_id: str 27 tier: Literal["free", "pro", "enterprise"] = "free" 28 config_version: str # SHA of the merged config; flows into traces 29 system_prompt: str 30 routing: RoutingConfig = RoutingConfig() 31 guardrails: GuardrailConfig = GuardrailConfig() 32 retrieval_index: str = "shared-default" 33 eval_suite: str = "baseline-eval-v1" 34 rate_limit_rpm: int = 60 35 36def _hash(doc: dict) -> str: 37 return hashlib.sha256(json.dumps(doc, sort_keys=True).encode()).hexdigest()[:12] 38 39class TenantRouter: 40 def __init__(self, config_dir: Path): 41 self._dir = config_dir 42 self._lock = RLock() 43 self._configs: dict[str, TenantConfig] = {} 44 self._default: dict = {} 45 self.reload() 46 47 def reload(self) -> None: 48 with self._lock: 49 self._default = yaml.safe_load((self._dir / "_default.yaml").read_text()) 50 new: dict[str, TenantConfig] = {} 51 for path in self._dir.glob("*.yaml"): 52 if path.name.startswith("_"): 53 continue 54 raw = yaml.safe_load(path.read_text()) 55 merged = _deep_merge(self._default, raw) 56 merged["config_version"] = _hash(merged) 57 new[merged["tenant_id"]] = TenantConfig(**merged) 58 self._configs = new 59 60 def resolve(self, request: Request) -> TenantConfig: 61 tenant_id = _extract_tenant_id(request) # JWT claim or X-Tenant-Id header 62 with self._lock: 63 cfg = self._configs.get(tenant_id) 64 if cfg is None: 65 raise HTTPException(status_code=403, detail="unknown tenant") 66 return cfg 67 68log = structlog.get_logger() 69 70async def llm_call(request: Request, body: dict, router: TenantRouter): 71 cfg = router.resolve(request) 72 response = await gateway.invoke( 73 model=cfg.routing.primary_model, 74 system=cfg.system_prompt, 75 messages=body["messages"], 76 guardrails=cfg.guardrails, 77 ) 78 log.info( 79 "llm_call", 80 tenant_id=cfg.tenant_id, 81 config_version=cfg.config_version, 82 model=cfg.routing.primary_model, 83 eval_suite=cfg.eval_suite, 84 tier=cfg.tier, 85 ) 86 return response
_deep_merge walks both documents and prefers the override at every leaf — implement it explicitly
rather than relying on dict.update, which clobbers nested keys. You'll know it works when a
tenant-scoped request shows the merged primary_model in the structured log alongside a
config_version that changes the moment you edit and reload the ConfigMap.
Do's and Don'ts
Do's
- ✓Do implement
_deep_mergeexplicitly — walking both documents and preferring the override at every leaf — this is what allows a tenant YAML that sets onlyrate_limit_rpmto automatically inherit fleet-wide changes tosystem_prompt,guardrails, orrouting.fallback_modelsfrom_default.yaml; without it, a field the tenant never touched can silently disappear. - ✓Do compute
config_versionashashlib.sha256(json.dumps(merged, sort_keys=True))on the fully merged dict after_deep_merge, not on the raw tenant file — the hash must capture the effective policy in force, so a_default.yamlchange that propagates to a tenant who hasn't pinned the field registers as a newconfig_versionin everystructlogline and OpenTelemetry span, making behavioral drift a SQL query rather than a forensic exercise. - ✓Do declare
TenantConfigwithConfigDict(frozen=True)and hold theRLockonly for the atomicself._configs = newswap insidereload()— the frozen object guarantees no downstream component (prompt builder, guardrails, retriever) can mutate the config afterresolve()hands it off, and the narrow lock window lets awatchdogfilesystem reload proceed without blocking concurrent request readers.
Don'ts
- ✗Don't use
dict.updateas the default-override merge strategy — it performs a shallow merge, so a tenant YAML that only overridesrouting.primary_modelsilently clobbers the entireroutingsub-document, wipingfallback_modelsandfeature_flagsinherited from_default.yamlwith no validation error from Pydantic. - ✗Don't compute
config_versionbefore_deep_mergeruns on the raw tenant override YAML — a pre-merge hash stays constant when_default.yamlchanges, so tenants that inherit the updated default appear unchanged in traces;blocked_topicsguardrails oreval_suitechanges propagated through the default become invisible in the audit log until a tenant explicitly edits their own file. - ✗Don't silently return a fallback config when
resolve()gets an unknowntenant_id— raiseHTTPException(status_code=403, detail="unknown tenant")instead; routing an unrecognized client through_default.yamlpolicy risks applying free-tierrate_limit_rpm: 60, skipping enterpriseblocked_topicsguardrails likerecreational-drugsorself-harm-instructions, and borrowingshared-defaultretrieval instead of the tenant's private EMR index.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Solutions Architecture subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in GenAI Architecture & Design Patterns
- Ch 12Optimize A2A network topology for latency and reliability
- Ch 12Create A2A network operations dashboard with federation view
- Ch 13Implement event backbone with Redis Streams for AI workloads
- Ch 16Build agent pool manager with lifecycle and capability registration
- Ch 16Validate orchestration correctness with agent trajectory evaluation
- Ch 20Build unified data access layer across vector, graph, and relational stores
- Ch 24Implement tenant-aware routing with per-tenant model and guardrail configYou are here