Navigation
๐Ÿญ Platform & Infrastructureยท 10 min read

API Gateway

LLM-specific gateway concerns โ€” per-token rate limiting, streaming proxy, model routing, request logging with PII redaction.

api-gatewayrate-limitingauthenticationroutingloggingstreaming

The API gateway is the front door to your inference service. But LLM serving has gateway requirements that differ fundamentally from traditional web APIs: requests vary in cost by 100ร—, responses stream for seconds, and prompts contain sensitive data that must be logged carefully.


1. Why LLM Gateways Are Different

PropertyTraditional APILLM API
Request cost~uniform100ร— variance (10 tokens vs 10,000)
Response time50-200ms500ms-60s
Response deliverySingle JSONToken-by-token stream (SSE)
Rate limitingPer-requestPer-token (cost varies dramatically)
Payload sensitivityModerateHigh (prompts contain user data)
Backend selectionRound-robinModel-specific, prefix-cache-aware

A gateway that treats LLM requests like REST requests will fail โ€” you need token-aware rate limiting, streaming-compatible proxying, and model-specific routing.


2. Architecture

API Gateway Architecture

Client โ†’ Gateway โ†’ Router โ†’ Inference Pool โ†’ Streamed Response
           โ”‚                    โ”‚
           โ”œโ”€โ”€ Auth             โ”œโ”€โ”€ vLLM instance 1
           โ”œโ”€โ”€ Rate limit       โ”œโ”€โ”€ vLLM instance 2
           โ”œโ”€โ”€ Log              โ””โ”€โ”€ vLLM instance 3
           โ””โ”€โ”€ Transform

Request Flow

class LLMGateway:
    async def handle_request(self, request: Request) -> StreamingResponse:
        # 1. Authenticate
        tenant = await self.authenticate(request)
        
        # 2. Parse and validate
        body = await request.json()
        model = body.get("model", "default")
        max_tokens = body.get("max_tokens", 4096)
        
        # 3. Token-based rate limiting
        estimated_tokens = count_tokens(body["messages"]) + max_tokens
        if not await self.rate_limiter.allow(tenant.id, estimated_tokens):
            raise HTTPException(429, "Token rate limit exceeded")
        
        # 4. Route to appropriate backend
        backend = self.router.select(model, tenant.tier)
        
        # 5. Proxy request with streaming
        async def stream_response():
            actual_tokens = 0
            async for chunk in backend.stream(body):
                actual_tokens += chunk.token_count
                yield f"data: {json.dumps(chunk.dict())}\n\n"
            
            # 6. Log completion (after streaming finishes)
            await self.logger.log_completion(
                tenant=tenant,
                model=model,
                input_tokens=count_tokens(body["messages"]),
                output_tokens=actual_tokens,
                latency_ms=elapsed(),
            )
            
            # 7. Update billing meter
            await self.billing.record(tenant.id, actual_tokens)
        
        return StreamingResponse(stream_response(), media_type="text/event-stream")

3. Token-Based Rate Limiting

Per-request rate limiting is unfair โ€” one 10-token request and one 10,000-token request cost vastly different amounts.

Token Bucket with Per-Minute Limits

class TokenRateLimiter:
    """Rate limit by token count, not request count."""
    
    def __init__(self, redis: Redis):
        self.redis = redis
    
    async def allow(self, tenant_id: str, estimated_tokens: int) -> bool:
        key = f"ratelimit:{tenant_id}:{current_minute()}"
        
        # Get current usage
        current = int(await self.redis.get(key) or 0)
        limit = await self.get_limit(tenant_id)
        
        if current + estimated_tokens > limit:
            return False
        
        # Increment usage
        pipe = self.redis.pipeline()
        pipe.incrby(key, estimated_tokens)
        pipe.expire(key, 120)  # TTL: 2 minutes
        await pipe.execute()
        return True
    
    async def get_limit(self, tenant_id: str) -> int:
        """Per-tenant token limits."""
        tier = await self.get_tier(tenant_id)
        return {
            "free":       10_000,     # 10K tokens/min
            "starter":   100_000,     # 100K tokens/min
            "pro":     1_000_000,     # 1M tokens/min
            "enterprise": 10_000_000, # 10M tokens/min
        }[tier]

Multi-Dimensional Limits

Production systems need limits on multiple axes:

Limit dimensionTypical valuesWhy
Tokens per minute10K-10MCost control
Requests per minute60-10,000Prevent abuse patterns
Concurrent requests10-1,000Prevent resource monopolization
Max tokens per request4K-128KPrevent single huge requests
Monthly token budget1M-1BBilling cap

4. Request Routing

Model-Based Routing

class ModelRouter:
    """Route requests to the correct model backend."""
    
    def __init__(self, backends: dict[str, list[Backend]]):
        self.backends = backends  # model_name โ†’ [backend instances]
    
    def select(self, model: str, tenant_tier: str) -> Backend:
        candidates = self.backends.get(model, [])
        if not candidates:
            raise HTTPException(404, f"Model {model} not available")
        
        # Filter by tenant tier (enterprise gets dedicated instances)
        if tenant_tier == "enterprise":
            candidates = [b for b in candidates if b.pool == "dedicated"] or candidates
        
        # Select backend with shortest queue
        return min(candidates, key=lambda b: b.queue_depth)

A/B Traffic Splitting

Test new model versions on a subset of traffic:

class ABRouter:
    def __init__(self, control_backend, treatment_backend, treatment_pct=0.05):
        self.control = control_backend
        self.treatment = treatment_backend
        self.treatment_pct = treatment_pct
    
    def select(self, request_id: str) -> Backend:
        # Deterministic split based on request ID (consistent per user)
        if hash(request_id) % 100 < self.treatment_pct * 100:
            return self.treatment
        return self.control

5. Authentication and Tenant Management

class AuthMiddleware:
    async def authenticate(self, request: Request) -> Tenant:
        # API key authentication
        api_key = request.headers.get("Authorization", "").replace("Bearer ", "")
        if not api_key:
            raise HTTPException(401, "Missing API key")
        
        # Look up tenant (cached in Redis)
        tenant = await self.cache.get(f"apikey:{api_key}")
        if not tenant:
            tenant = await self.db.get_tenant_by_key(api_key)
            if not tenant:
                raise HTTPException(401, "Invalid API key")
            await self.cache.setex(f"apikey:{api_key}", 300, tenant.json())
        
        # Check if key is rotated/expired
        if tenant.key_expires_at and tenant.key_expires_at < datetime.utcnow():
            raise HTTPException(401, "API key expired")
        
        return tenant

Key Management

FeatureImplementation
Key rotationIssue new key, grace period for old key (72h), then revoke
Key scopingPer-key model access list, max tokens, IP allowlist
Usage attributionEvery request tagged with tenant + key ID for billing
Audit trailLog key creation, rotation, revocation events

6. Request/Response Logging

LLM logs are sensitive โ€” prompts contain user data, completions may contain PII.

class RequestLogger:
    async def log_completion(self, tenant, model, input_tokens, output_tokens, 
                              latency_ms, prompt=None, completion=None):
        log_entry = {
            "timestamp": datetime.utcnow().isoformat(),
            "tenant_id": tenant.id,
            "model": model,
            "input_tokens": input_tokens,
            "output_tokens": output_tokens,
            "latency_ms": latency_ms,
            "status": "success",
        }
        
        # PII redaction for prompt/completion logging
        if tenant.log_prompts:
            log_entry["prompt_hash"] = hashlib.sha256(prompt.encode()).hexdigest()
            if tenant.log_level == "full":
                log_entry["prompt"] = self.redact_pii(prompt)
                log_entry["completion"] = self.redact_pii(completion)
        
        await self.log_store.write(log_entry)
    
    def redact_pii(self, text: str) -> str:
        """Remove emails, phone numbers, SSNs, credit cards."""
        import re
        text = re.sub(r'\b[\w.+-]+@[\w-]+\.[\w.]+\b', '[EMAIL]', text)
        text = re.sub(r'\b\d{3}[-.]?\d{3}[-.]?\d{4}\b', '[PHONE]', text)
        text = re.sub(r'\b\d{3}-\d{2}-\d{4}\b', '[SSN]', text)
        text = re.sub(r'\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b', '[CARD]', text)
        return text

Log Retention Policies

Data typeRetentionStorage
Request metadata (tokens, latency)90 daysTime-series DB (ClickHouse)
Prompt/completion (redacted)30 daysEncrypted object storage
Billing records7 yearsImmutable audit log
Error traces14 daysLog aggregation (ELK)

7. Gateway Implementation Options

OptionProsConsBest for
Kong + pluginsMature, plugin ecosystemLLM streaming needs custom workEnterprise with existing Kong
Envoy + Lua/WASMHigh performance, streaming nativeComplex configurationHigh-throughput platforms
Custom (FastAPI/Go)Full control, LLM-optimizedBuild everything yourselfStartups, specific requirements
LiteLLM ProxyLLM-native, multi-providerLess battle-tested at scaleMulti-provider routing
Cloudflare AI GatewayManaged, caching, analyticsVendor lock-inQuick start

Self-Check

  • Explain why per-request rate limiting is insufficient for LLM APIs
  • Implement token-based rate limiting with per-minute buckets
  • Design model-based routing with A/B traffic splitting
  • Build a request logger with PII redaction for prompt/completion data
  • Compare gateway implementation options for LLM serving

References