
Beyond the API Key: Architecting a Multi-Provider AI Gateway with RBAC, Cost Routing, and Fallback Chains — Lessons from 40k+ Star OSS Tools
Design a production AI gateway that abstracts providers, enforces RBAC, routes by cost, and cascades through fallbacks — with patterns distilled from top OSS AI infra tools.
A single OPENAI_API_KEY in your .env file is the AI equivalent of a single point of failure. One provider outage, one rate-limit change, one 20% price hike, and your production inference pipeline grinds to a halt. The teams shipping AI features at scale have converged on a pattern: a multi-provider AI gateway that sits between your application and every LLM provider, enforcing access control, optimizing cost, and surviving failures through orchestrated fallback chains. This architecture is not theoretical — it is the core design behind several 40k+ star open-source projects, and the patterns they've validated over hundreds of thousands of production calls are now ready to be internalized by any engineering team.
This deep-dive dissects the architecture layer by layer, shows how RBAC, cost-aware routing, and fallback chains interact, and distills the concrete patterns from the OSS ecosystem into a reference implementation you can adapt.
Table of Contents
- 1. The Core Problem: Why "Just Call the API" Falls Apart
- 2. Architecture Overview: The Five Layers
- 3. Provider Abstraction: The Normalization Contract
- 4. RBAC for AI Workloads
- 5. Cost-Aware Routing
- 6. Fallback Chains and Resilience Semantics
- 7. Observability: What You Must Log
- 8. Lessons from the OSS Ecosystem
- 9. Reference Implementation
- 10. Production Hardening
- 11. Frequently Asked Questions
1. The Core Problem: Why "Just Call the API" Falls Apart
When you embed a single provider's SDK directly into your service, you are coupling four distinct concerns into one dependency:
- Availability — the provider's uptime and rate limits.
- Cost — the per-token pricing, which changes without notice.
- Access control — which of your teams or microservices can call which models, and at what volume.
- Capability — model-specific features (structured output, vision, function calling) that differ across vendors.
A gateway decouples all four. Your application talks to a single, stable internal endpoint. The gateway handles the rest. This is not new in software — it is the same pattern as an API gateway, a load balancer, or a service mesh, applied to LLM inference. But LLMs introduce new dimensions: token-based (not request-based) billing, multi-model routing, and the need to cascade across providers when one is degraded.
2. Architecture Overview: The Five Layers
The reference architecture, as distilled from tools like LiteLLM, Portkey Gateway, and the Vercel AI SDK, consists of five layers stacked vertically:
┌─────────────────────────────────────────────────────────────┐
│ Layer 5: Application Layer │
│ (Your service calls a single /v1/chat/completions endpoint)│
├─────────────────────────────────────────────────────────────┤
│ Layer 4: Fallback & Resilience Engine │
│ (circuit breakers, retry with backoff, provider cascading) │
├─────────────────────────────────────────────────────────────┤
│ Layer 3: Routing & Cost Optimization │
│ (cost-aware model selection, budget enforcement) │
├─────────────────────────────────────────────────────────────┤
│ Layer 2: Authorization & RBAC │
│ (per-team, per-model, per-feature access control) │
├─────────────────────────────────────────────────────────────┤
│ Layer 1: Provider Abstraction & Normalization │
│ (unified schema, adapter pattern, streaming passthrough) │
└─────────────────────────────────────────────────────────────┘
Each layer is independently testable and replaceable. Layer 1 normalizes the wire format; Layer 2 decides who can call what; Layer 3 decides which model to use; Layer 4 decides what happens when it fails; and Layer 5 is your application, which never knows or cares which provider actually served the response.
The key insight from the OSS ecosystem: these layers must be composable, not monolithic. LiteLLM treats each provider as a pluggable adapter. Portkey separates routing rules from provider connectors. The Vercel AI SDK abstracts the streaming protocol per provider. Building all five layers into one opaque blob is the most common architectural mistake.
3. Provider Abstraction: The Normalization Contract
Every LLM provider exposes a slightly different HTTP surface. OpenAI uses /v1/chat/completions with a model field. Anthropic uses /v1/messages with system as a top-level parameter. Together uses a different auth header scheme. Google's Gemini API nests parameters differently. A gateway must normalize all of these into a single internal schema.
The adapter pattern makes this concrete. Define a provider-agnostic request and response type:
// The internal, provider-agnostic contract
interface ChatRequest {
model: string; // logical model alias, e.g. "fast-7b" or "reasoning-large"
messages: Message[];
temperature?: number;
maxTokens?: number;
responseFormat?: "text" | "json" | "structured";
metadata?: Record<string, unknown>; // caller-supplied, opaque to the gateway
}
interface ChatResponse {
id: string; // provider-unique response ID
model: string; // the *actual* model that served the request
content: string;
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
cost: { usd: number; currency: string };
provider: string; // which provider actually served it
latencyMs: number;
fallbackDepth: number; // 0 = primary, 1 = first fallback, etc.
}
// Provider adapter interface
interface ProviderAdapter {
readonly name: string;
chat(req: NormalizedRequest, ctx: RequestContext): AsyncIterable<StreamChunk> | Promise<NormalizedResponse>;
embeddings(req: EmbeddingRequest): Promise<NormalizedEmbeddingResponse>;
isHealthy(): Promise<boolean>;
}
Each adapter (OpenAIAdapter, AnthropicAdapter, TogetherAdapter, OllamaAdapter, etc.) translates between the normalized types and the provider's native wire format. The critical detail the OSS tools get right: streaming must be pass-through, not buffered. You translate SSE chunks on the fly. Buffering an entire LLM response before sending it defeats the purpose of streaming and adds latency.
class OpenAIAdapter implements ProviderAdapter {
readonly name = "openai";
async chat(req: NormalizedRequest, ctx: RequestContext): AsyncIterable<StreamChunk> {
const resp = await this.client.chat.completions.create({
model: req.physicalModel, // resolved alias → physical model
messages: req.messages,
temperature: req.temperature,
max_tokens: req.maxTokens,
stream: true,
});
// Translate OpenAI SSE chunks into the normalized StreamChunk format
for await (const chunk of resp) {
yield {
contentDelta: chunk.choices[0]?.delta?.content ?? "",
finishReason: chunk.choices[0]?.finish_reason ?? null,
usage: chunk.usage, // OpenAI now streams usage in the final chunk
};
}
}
}
The model field in ChatRequest is a logical alias, not a physical model name. Your app requests "fast-7b"; the gateway resolves that to "meta-llama/Llama-3-8B-Instruct" on Together, or "gpt-4o-mini" on OpenAI, depending on routing decisions made in Layer 3. This alias indirection is the single most important design decision, and it is the pattern that LiteLLM's model field and Portkey's model mapping layer both implement.
4. RBAC for AI Workloads
Traditional RBAC (roles, resources, actions) does not map cleanly onto LLM workloads. A "read" on a database row has no analogue to "request a 4096-token completion from a 70B reasoning model at temperature 0.1." You need a more granular policy model.
The pattern that has emerged across Portkey, LiteLLM's team-key system, and enterprise API gateways is a multi-dimensional permission tuple:
permission = (team, model_alias, action, budget_cap, rate_limit)
| Dimension | Examples |
|---|---|
| Team / Service | payments-ml, customer-support, internal-dev-tools |
| Model alias | fast-7b, reasoning-large, embeddings-v3 |
| Action | chat, embed, stream, structured-output |
| Budget cap | $50/day per team, $500/month per model |
| Rate limit | 60 req/min per team, 10 req/min for structured-output |
The policy engine evaluates this tuple on every request, before routing. A JSON policy document (or, for larger orgs, an OPA/Rego policy) looks like:
{
"team": "customer-support",
"grants": [
{
"model": "fast-7b",
"actions": ["chat", "stream"],
"budgetUSD": { "daily": 25, "monthly": 400 },
"rateLimit": { "requestsPerMin": 30 }
},
{
"model": "reasoning-large",
"actions": ["chat"],
"budgetUSD": { "daily": 10, "monthly": 150 },
"rateLimit": { "requestsPerMin": 5 }
}
]
}
Two critical implementation details from the OSS ecosystem:
- Evaluate budgets at the gateway, not the provider. The gateway maintains a sliding-window counter (Redis
INCR+EXPIRE, or an in-memory LRU for low-traffic deployments). When a team exceeds its cap, the gateway returns a429 Budget Exceededwith aretry-afterheader, rather than letting the request hit the provider and incur the charge. - Structured output and vision are distinct actions. A team that is allowed plain
chaton a model should not automatically getstructured-output(which often uses a more expensive decoding path) orvision(which costs more per token). Model capabilities must be part of the policy, not an implicit grant.
5. Cost-Aware Routing
When you have three providers that can all serve "fast-7b", the routing decision is not just "which is available" — it is "which is the cheapest that meets the quality floor."
The cost router maintains a per-model price table (updated from provider pricing pages or your own observed invoice data) and a quality score (from A/B evals, user feedback, or a static ranking). The routing function is a weighted multi-objective optimization:
route(model_alias, ctx) →
candidates = providers supporting model_alias
filtered = candidates.filter(c => c.isHealthy() && c.hasCapacity(ctx.rateLimit))
scored = filtered.map(c =>
w_cost * normalizedCost(c, model_alias) +
w_quality * (1 - qualityScore(c, model_alias)) +
w_latency * normalizedP99Latency(c)
)
return argmin(scored)
In practice, the weights are tuned per use case. A customer-facing support bot weights latency heavily (w_latency = 0.4). An offline batch summarization job weights cost heavily (w_cost = 0.7). The gateway exposes these as routing strategies that teams can select:
lowest-cost: minimizew_cost, ignore latency (for batch jobs).lowest-latency: minimizew_latency, allow higher cost (for user-facing).balanced: default equal weights.stickiness: pin a team to a provider for session consistency (important for multi-turn conversations where the provider may maintain server-side state or KV cache).
A subtlety the OSS tools handle well: cost is not just per-token. OpenAI charges differently for cached input tokens (prompt caching). Anthropic's pricing differs for 8k vs. 200k context windows. Together charges a flat per-token rate. The cost model must account for context-length tiering, or your "cost-aware" router will make wrong decisions for long-context requests.
function estimateCost(provider: string, model: string, promptTokens: number, completionTokens: number): number {
const pricing = PRICING_TABLE[provider][model]; // { inputPerM, outputPerM, cacheReadPerM }
let inputCost = (promptTokens / 1_000_000) * pricing.inputPerM;
// Anthropic-style context-length tiering
if (provider === "anthropic" && promptTokens > 100_000) {
inputCost = (promptTokens / 1_000_000) * pricing.inputPerM * 2.0; // 200k tier
}
// Prompt caching discount
const cachedTokens = ctx.cachedPromptTokens ?? 0;
inputCost -= (cachedTokens / 1_000_000) * pricing.cacheReadPerM;
const outputCost = (completionTokens / 1_000_000) * pricing.outputPerM;
return inputCost + outputCost;
}
6. Fallback Chains and Resilience Semantics
A fallback chain is an ordered list of providers/models to try when the primary fails. But "fail" is not a single event — it is a taxonomy:
| Failure Type | Example | Fallback Behavior |
|---|---|---|
| HTTP 4xx (auth, rate limit, model not found) | 401, 403, 429, 404 | Do NOT retry same provider; jump to next in chain |
| HTTP 5xx (provider internal error) | 500, 503 | Retry same provider once, then cascade |
| Timeout | No response within timeoutMs | Treat as 503; cascade |
| Content-level failure | Model returns a refusal, empty string, or invalid JSON for structured output | Retry with higher temperature, or cascade to a different model |
| Budget exceeded | Gateway's own budget cap hit | Return 429 to caller; do NOT cascade (cascading would just shift the budget violation to another provider) |
The fallback chain is defined per logical model alias:
{
"alias": "fast-7b",
"fallbackChain": [
{ "provider": "together", "model": "meta-llama/Llama-3-8B-Instruct", "weight": 0.7 },
{ "provider": "openai", "model": "gpt-4o-mini", "weight": 0.2 },
{ "provider": "ollama-local", "model": "llama3:8b", "weight": 0.1 }
],
"circuitBreaker": {
"failureThreshold": 5,
"openDurationSec": 60,
"halfOpenMaxRequests": 2
}
}
The circuit breaker is the key resilience primitive. Each provider in the chain maintains a state machine: CLOSED → OPEN → HALF-OPEN → CLOSED. When the failure count in a sliding window exceeds failureThreshold, the breaker opens and all subsequent requests skip that provider for openDurationSec. After that, halfOpenMaxRequests probe requests are allowed through; if they succeed, the breaker closes.
A critical lesson from the OSS ecosystem: do not cascade on 4xx errors for the same provider. If Together returns a 404 for a model you requested, retrying Together with the same model will return 404 again. The circuit breaker should treat 4xx as a configuration error, not a transient failure, and skip to the next provider in the chain without retrying the same one. Only 5xx, timeouts, and connection errors warrant a same-provider retry before cascading.
The fallbackDepth field in the response (shown in Section 3) is your audit trail. A request with fallbackDepth: 2 means the primary and first fallback both failed. This metric is your earliest warning that a provider is degrading, and it should feed back into the circuit breaker and routing weights automatically.
7. Observability: What You Must Log
Every request through the gateway must produce a structured log entry. The minimum viable schema, derived from what Helicone, LangSmith, and Portkey all log:
{
"requestId": "uuid",
"teamId": "customer-support",
"modelAlias": "fast-7b",
"provider": "together",
"actualModel": "meta-llama/Llama-3-8B-Instruct",
"promptTokens": 1247,
"completionTokens": 312,
"totalTokens": 1559,
"costUSD": 0.0034,
"latencyMs": 2340,
"fallbackDepth": 0,
"status": "success",
"timestamp": "2025-01-15T09:32:17Z",
"routingStrategy": "balanced",
"requestId": "uuid"
}
Aggregate this into three dashboards:
- Cost dashboard: spend per team, per model, per day. Budget burn rate. Comparison of estimated vs. actual cost.
- Resilience dashboard: fallback depth distribution, circuit breaker state per provider, error rate by failure type.
- Latency dashboard: P50/P95/P99 per provider and model. Streaming first-token latency (TTFT) vs. total latency.
The OSS tools converge on one principle: the gateway is the single source of truth for cost and usage. Your application should not track its own token counts. It should trust the gateway's usage and cost fields in the response. This avoids the classic bug where the app logs a different token count than what the provider actually billed.
8. Lessons from the OSS Ecosystem
Studying the codebases and architecture decisions of the most-starred AI infrastructure tools yields several concrete lessons:
LiteLLM (model-agnostic proxy)
LiteLLM's central contribution is the model alias indirection and the proxy_server mode. It wraps 100+ provider SDKs behind a single OpenAI-compatible endpoint. The key architectural lesson: it treats the gateway as an API translation layer first and a routing layer second. The provider adapter is the primary abstraction; routing is a policy that sits on top. This ordering matters. If you make routing the primary abstraction, your adapters become tangled with business logic.
Portkey Gateway
Portkey's open-source gateway emphasizes configuration-as-code routing rules. You define routing logic in a declarative YAML/JSON file, not in imperative code. The lesson: routing policies should be data, not code. A team should be able to change "route fast-7b to Together instead of OpenAI" by editing a config file and restarting, without deploying a new binary.
Vercel AI SDK
The Vercel AI SDK's contribution is the streaming protocol abstraction. It normalizes SSE, chunked transfer encoding, and WebSocket streaming into a single ReadableStream<Chunk> that the consumer can iterate over regardless of provider. The lesson: streaming is the hardest part of the adapter layer, and it must be first-class, not an afterthought. If your gateway buffers streaming responses, it has broken the user experience.
LangChain / LangSmith
The broader LangChain ecosystem's lesson is about evaluation-embedded routing. The most sophisticated cost-aware routing is not a static price table; it is a feedback loop where A/B test results (user satisfaction, task completion rate) feed back into the quality score used by the router. The gateway should expose hooks for eval results to adjust routing weights over time.
Helicone (observability layer)
Helicone's lesson: the observability layer should be a middleware, not a separate system. The gateway intercepts every request, attaches tracing context (OpenTelemetry spans), and ships logs. If observability is a separate service that the application must call explicitly, teams will skip it in production, and you lose the data you need for cost and resilience decisions.
A cross-cutting observation: every one of these tools, despite different primary focuses, converges on the same five-layer architecture. The differences are in emphasis. LiteLLM emphasizes Layer 1 (adapters). Portkey emphasizes Layer 3 (routing as config). Vercel emphasizes the streaming protocol within Layer 1. Helicone emphasizes Layer 7 (observability). A production gateway needs all five.
9. Reference Implementation
A minimal but complete gateway skeleton, combining all layers:
import { ProviderAdapter, ChatRequest, ChatResponse } from "./types";
import { OpenAIAdapter } from "./adapters/openai";
import { AnthropicAdapter } from "./adapters/anthropic";
import { TogetherAdapter } from "./adapters/together";
import { PolicyEngine } from "./rbac/policy-engine";
import { CostRouter } from "./routing/cost-router";
import { FallbackChain, CircuitBreaker } from "./resilience/fallback";
const adapters: Record<string, ProviderAdapter> = {
openai: new OpenAIAdapter(),
anthropic: new AnthropicAdapter(),
together: new TogetherAdapter(),
};
const policyEngine = new PolicyEngine(); // loads RBAC policies from DB/config
const costRouter = new CostRouter(adapters, PRICING_TABLE);
const fallback = new FallbackChain(config.fallbackChains);
export async function handleChat(req: ChatRequest, auth: AuthContext): Promise<ChatResponse> {
// LAYER 2: RBAC
const permission = policyEngine.evaluate(auth.teamId, req.model, req.metadata?.action ?? "chat");
if (!permission.allowed) {
throw new GatewayError("403", `Team '${auth.teamId}' not authorized for model '${req.model}'`);
}
if (permission.budgetExceeded) {
throw new GatewayError("429", "Team budget exceeded for this model", { retryAfter: permission.nextBudgetReset });
}
// LAYER 3: Cost-aware routing
const route = costRouter.route(req.model, auth.teamId, {
promptTokensEstimate: estimateTokens(req.messages),
strategy: permission.routingStrategy ?? "balanced",
});
// LAYER 4: Fallback chain with circuit breakers
let depth = 0;
for (const target of route.chain) {
const breaker = fallback.getBreaker(target.provider, target.model);
if (breaker.state === "open") { depth++; continue; }
try {
const adapter = adapters[target.provider];
const response = await adapter.chat(
{ ...req, physicalModel: target.model },
{ teamId: auth.teamId, circuitBreaker: breaker }
);
breaker.recordSuccess();
return { ...response, provider: target.provider, fallbackDepth: depth, cost: { usd: response.usage.totalTokens * target.costPerToken, currency: "USD" } };
} catch (err) {
const failureType = classifyFailure(err); // "4xx" | "5xx" | "timeout" | "content"
if (failureType === "4xx" && !(err instanceof BudgetExceededError)) {
depth++; // skip to next provider, do NOT retry same
continue;
}
breaker.recordFailure();
if (failureType === "5xx" && depth === 0 && route.chain.length > 1) {
// retry same provider once before cascading
depth++;
continue;
}
depth++;
}
}
throw new GatewayError("502", "All providers in fallback chain failed");
}
This is a skeleton. A production version adds: Redis-backed budget counters, OpenTelemetry spans, streaming passthrough (the adapter.chat method returns an AsyncIterable<StreamChunk> when stream: true), a model-alias resolution table, and a config-reload mechanism for routing rules.
10. Production Hardening
Several hardening measures separate a demo gateway from a production one:
-
Connection pooling per provider. Each provider adapter should maintain a pooled HTTP client (e.g.,
undicipool for Node,aiohttpconnector for Python) rather than creating a new connection per request. LLM endpoints are long-lived; connection churn adds 50–200 ms latency per call. -
Request deduplication for batch jobs. If 200 background jobs all call
embed(text="quarterly report Q3")at the same time, the gateway should deduplicate identical embedding requests within a 2-second window and share the upstream call. This is a simpleMap<hash, Promise>with a TTL. -
Graceful degradation on partial failures. If a provider returns a truncated response (hit
max_tokens), the gateway should surface that in the response metadata (truncated: true,finishReason: "length") so the caller can decide whether to retry with a higher limit or handle the truncation. -
Provider credential rotation. API keys should be stored in a secrets manager (Vault, AWS Secrets Manager, HashiCorp) and rotated without gateway restart. Each adapter holds a reference to the secrets manager, not a static string.
-
Canary routing. Before promoting a new model version, route 5% of traffic to it, compare output quality against the incumbent, and promote or roll back. The OSS tools that include canary features (Portkey, LiteLLM in newer versions) treat this as a routing strategy, not a separate A/B framework.
11. Frequently Asked Questions
Q: Should the gateway be a sidecar, a mesh, or a centralized service? For most teams, a centralized gateway service (a single horizontal Pod Deployment behind a load balancer) is the right default. It centralizes RBAC, budget enforcement, and cost tracking in one place. A sidecar per microservice multiplies the state (circuit breakers, budget counters) and makes cross-service budgeting impossible. A service mesh (e.g., Istio) can handle L4/TLS termination but not the L7 logic (model routing, token accounting, RBAC) that an AI gateway requires. You can combine both: mesh for mTLS and mesh observability, dedicated AI gateway for the LLM-specific layers.
Q: How do I handle model-specific features (function calling, vision, structured output) in the abstraction layer?
The normalized ChatRequest schema should include an optional capabilities field: { functionCalling: true, vision: true, structuredOutput: { schema: {...} } }. The provider adapter translates this into its native API (OpenAI's tools array, Anthropic's tool_use, Gemini's function_declarations). The RBAC policy must gate these capabilities individually. A team that is authorized for plain chat is not automatically authorized for functionCalling, which may execute code or access external tools.
Q: Is it worth building this in-house vs. adopting LiteLLM or Portkey? If your provider count is ≤ 3 and your routing logic is "cheapest healthy provider," LiteLLM's proxy mode or Portkey's open-source gateway will save you weeks. If you need custom RBAC tied to your existing identity system (Okta, Azure AD, internal SSO), budget enforcement tied to your financial planning tools, or a custom evaluation-feedback loop that adjusts routing weights, building on top of an OSS adapter library (LiteLLM's provider SDKs) with your own routing, RBAC, and observability layers is the better investment. The adapter layer is the most reusable piece; the routing and policy layers are where your business logic lives.
For deeper explorations of AI infrastructure patterns, gateway design, and LLM observability, see Tamiz's Insights for curated deep-dives, or explore the broader collection at tamiz.pro.
Appendix A: Complete Runnable Gateway Skeleton
The following is a self-contained reference implementation you can adapt for a production gateway. It uses Python 3.11+, httpx for async HTTP, and a lightweight in-memory store for the demo. In production, swap the store for PostgreSQL/Redis.
# gateway/app.py
import time
import json
import hashlib
import httpx
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel
class Provider(Enum):
OPENAI = "openai"
ANTHROPIC = "anthropic"
MISTRAL = "mistral"
LOCAL_LLAMA = "local_llama"
class Role(Enum):
SUPERVISOR = "supervisor"
DEVELOPER = "developer"
GUEST = "guest"
# ─── RBAC ───────────────────────────────────────────────────────────
RBAC_MATRIX: dict[Role, list[str]] = {
Role.SUPERVISOR: ["*"],
Role.DEVELOPER: [
"providers:openai:invoke",
"providers:anthropic:invoke",
"providers:mistral:invoke",
"providers:local_llama:invoke",
"cost_budget:read",
"audit_log:read",
],
Role.GUEST: [
"providers:openai:invoke",
"providers:local_llama:invoke",
],
}
def permission_granted(role: Role, action: str) -> bool:
perms = RBAC_MATRIX.get(role, [])
return "*" in perms or action in perms
# ─── Cost routing ──────────────────────────────────────────────────
@dataclass
class CostProfile:
input_per_1k: float
output_per_1k: float
rate_limit_rpm: int
burst_window_s: int
COST_TABLE: dict[Provider, CostProfile] = {
Provider.OPENAI: CostProfile(0.005, 0.015, 500, 60),
Provider.ANTHROPIC: CostProfile(0.003, 0.015, 300, 60),
Provider.MISTRAL: CostProfile(0.002, 0.006, 400, 60),
Provider.LOCAL_LLAMA: CostProfile(0.000, 0.000, 9999, 60),
}
def estimate_cost(provider: Provider, in_tokens: int, out_tokens: int) -> float:
p = COST_TABLE[provider]
return (in_tokens / 1000) * p.input_per_1k + (out_tokens / 1000) * p.output_per_1k
def cheapest_provider(budget_per_call: float, in_tokens: int, out_tokens: int) -> Provider:
candidates = []
for prov, profile in COST_TABLE.items():
est = estimate_cost(prov, in_tokens, out_tokens)
if est <= budget_per_call:
candidates.append((est, prov))
if not candidates:
raise HTTPException(402, "All providers exceed per-call budget")
candidates.sort(key=lambda x: x[0])
return candidates[0][1]
# ─── Fallback chain ────────────────────────────────────────────────
@dataclass
class FallbackChain:
ordered_providers: list[Provider]
max_retries_per_node: int = 2
circuit_breaker_threshold: int = 5
circuit_breaker_cooldown_s: int = 30
DEFAULT_CHAIN = FallbackChain(
ordered_providers=[Provider.OPENAI, Provider.ANTHROPIC, Provider.MISTRAL, Provider.LOCAL_LLAMA]
)
class CircuitBreaker:
def __init__(self, threshold: int, cooldown: int):
self.failures = 0
self.threshold = threshold
self.cooldown = cooldown
self.opened_at: Optional[float] = None
def record_success(self):
self.failures = 0
self.opened_at = None
def record_failure(self):
self.failures += 1
if self.failures >= self.threshold:
self.opened_at = time.time()
def is_open(self) -> bool:
if self.opened_at is None:
return False
if time.time() - self.opened_at < self.cooldown:
return True
self.opened_at = None # half-open → allow one probe
self.failures = self.threshold - 1
return False
# ─── API surface ───────────────────────────────────────────────────
app = FastAPI(title="Multi-Provider AI Gateway")
class InvokeRequest(BaseModel):
model_hint: str = "chat"
prompt: str
est_in_tokens: int = 200
est_out_tokens: int = 300
budget_per_call: float = 0.05
force_provider: Optional[Provider] = None
class InvokeResponse(BaseModel):
provider: str
cost_estimate: float
response: str
latency_ms: int
fallback_triggered: bool = False
breakers: dict[Provider, CircuitBreaker] = {
p: CircuitBreaker(DEFAULT_CHAIN.circuit_breaker_threshold,
DEFAULT_CHAIN.circuit_breaker_cooldown_s)
for p in Provider
}
@app.post("/v1/invoke", response_model=InvokeResponse)
async def invoke(
body: InvokeRequest,
x_api_key: str = Header(...),
x_role: str = Header(default=Role.DEVELOPER.value),
):
role = Role(x_role)
# 1. RBAC gate
for p in DEFAULT_CHAIN.ordered_providers:
action = f"providers:{p.value}:invoke"
if not permission_granted(role, action):
# filter chain to only allowed providers
DEFAULT_CHAIN.ordered_providers = [
pp for pp in DEFAULT_CHAIN.ordered_providers
if permission_granted(role, f"providers:{pp.value}:invoke")
]
break
if not DEFAULT_CHAIN.ordered_providers:
raise HTTPException(403, "No providers permitted for your role")
# 2. Cost routing (unless a provider is forced)
if body.force_provider:
target = body.force_provider
else:
target = cheapest_provider(body.budget_per_call, body.est_in_tokens, body.est_out_tokens)
# 3. Fallback chain with circuit breakers
chain = [target] + [p for p in DEFAULT_CHAIN.ordered_providers if p != target]
fallback_triggered = False
for i, prov in enumerate(chain):
breaker = breakers[prov]
if breaker.is_open():
continue
start = time.perf_counter()
try:
# In production: call the actual provider SDK
response_text = await _call_provider(prov, body)
breaker.record_success()
latency = int((time.perf_counter() - start) * 1000)
return InvokeResponse(
provider=prov.value,
cost_estimate=round(estimate_cost(prov, body.est_in_tokens, body.est_out_tokens), 6),
response=response_text,
latency_ms=latency,
fallback_triggered=fallback_triggered,
)
except Exception as exc:
breaker.record_failure()
if i < len(chain) - 1:
fallback_triggered = True
continue
raise HTTPException(502, f"All providers failed. Last error: {exc}")
async def _call_provider(prov: Provider, body: InvokeRequest) -> str:
"""Placeholder – replace with real SDK calls (openai, anthropic, etc.)"""
import random
if random.random() < 0.1:
raise ConnectionError(f"{prov.value} simulated timeout")
return f"[{prov.value}] Echo: {body.prompt[:80]}"
@app.get("/v1/cost/estimate")
def cost_estimate(provider: Provider, in_tokens: int = 1000, out_tokens: int = 1000):
return {"cost_usd": estimate_cost(provider, in_tokens, out_tokens)}
Run locally:
uvicorn gateway.app:app --port 8443 --workers 4
curl -X POST http://localhost:8443/v1/invoke \
-H "Content-Type: application/json" \
-H "x-api-key: sk-test-abc" \
-H "x-role: developer" \
-d '{"prompt": "Explain circuit breakers", "est_in_tokens": 50, "est_out_tokens": 200, "budget_per_call": 0.01}'
Appendix B: Cost-Router Decision Tree in Depth
A flat "pick the cheapest" strategy ignores quality tiers. In production we layer a scoring function:
score(provider) = α · (1 / est_cost) # cost efficiency
+ β · quality_tier(provider) # 1.0–0.4 by model class
+ γ · (1 - circuit_open) # health signal
+ δ · rate_headroom(provider) # remaining RPM fraction
Where typical defaults for a cost-sensitive team: α=0.4, β=0.3, γ=0.2, δ=0.1.
Implementing the weighted scorer:
QUALITY_TIER: dict[Provider, float] = {
Provider.OPENAI: 1.0,
Provider.ANTHROPIC: 0.95,
Provider.MISTRAL: 0.85,
Provider.LOCAL_LLAMA: 0.70,
}
def weighted_router(
in_tokens: int, out_tokens: int,
budget: float,
alphas: tuple[float, float, float, float] = (0.4, 0.3, 0.2, 0.1),
) -> Provider:
α, β, γ, δ = alphas
best, best_score = None, -1.0
for prov in Provider:
est = estimate_cost(prov, in_tokens, out_tokens)
if est > budget:
continue
cost_score = α / max(est, 0.001)
quality = β * QUALITY_TIER[prov]
health = γ * (0.0 if breakers[prov].is_open() else 1.0)
# rate_headroom: approximate as 1 - (current_rpm / limit)
headroom = δ * 0.8 # simplified; use Redis counters in prod
total = cost_score + quality + health + headroom
if total > best_score:
best, best_score = prov, total
if best is None:
raise HTTPException(402, "No provider within budget")
return best
Key lesson from 40k-star projects (LiteLLM, OpenRouter, Helicone): never hard-code a single provider as "the fallback." The scorer above lets you dynamically demote a provider after repeated 429s without touching config files.
Appendix C: RBAC Token Design & Audit Trail
A practical JWT-embedded RBAC claim set:
{
"sub": "user:8f3a",
"role": "developer",
"team": "ml-platform",
"permissions": [
"providers:openai:invoke",
"providers:mistral:invoke",
"cost_budget:read"
],
"budget_monthly_usd": 50.00,
"i_at": 1717000000,
"exp": 1717003600
}
The gateway middleware (FastAPI dependency):
from jose import jwt, JWTError
def require_role(
authorization: str = Header(...),
):
token = authorization.replace("Bearer ", "")
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except JWTError:
raise HTTPException(401, "Invalid token")
return Role(payload["role"])
Audit log entry (written to an append-only table, never mutated):
INSERT INTO gateway_audit (ts, api_key_hash, role, provider, in_tok, out_tok, est_cost, status)
VALUES (now(), sha256(:key), :role, :prov, :in, :out, :cost, 'ok');
In the 40k-star ecosystem, HashiCorp Vault and Authz from the Kubernetes world are common integrations; the pattern is always token in → scoped permission check → per-provider allow/deny.
Appendix D: Fallback Chain – Production Hardening
The simple for-loop in Appendix A is insufficient for four reasons. Here's what battle-tested gateways add:
| Concern | Mitigation |
|---|---|
| Thundering herd on a dead provider | Exponential backoff + jitter: sleep = min(2^n + random(0,1), 30) |
| Partial responses streaming cut off | Treat any IncompleteRead as a failure, not a success |
| Provider-specific schema differences | Normalisation layer: convert every response into a UnifiedToken stream |
| Cold-start latency on the 3rd/4th fallback | Pre-warm local model (Llama-3-8B) on a sidecar GPU |
async def execute_with_backoff(prov: Provider, body: InvokeRequest, max_retries: int = 2):
for attempt in range(max_retries):
try:
return await _call_provider(prov, body)
except (httpx.ReadError, httpx.ConnectTimeout):
if attempt == max_retries - 1:
raise
wait = min(2 ** attempt + random.random(), 30)
await asyncio.sleep(wait)
raise
The normalisation layer is where most teams get stuck. A minimal interface:
class UnifiedStream:
def __aiter__(self) -> AsyncIterator[str]:
...
def to_unified(provider: Provider, raw_response) -> UnifiedStream:
match provider:
case Provider.OPENAI:
return OpenAIStream(raw_response) # iterates choices[0].delta.content
case Provider.ANTHROPIC:
return AnthropicStream(raw_response) # iterates message deltas
case Provider.MISTRAL:
return MistralStream(raw_response)
case Provider.LOCAL_LLAMA:
return LocalStream(raw_response) # Ollama / vLLM NDJSON
Once every provider speaks UnifiedStream, the fallback loop becomes provider-agnostic.
Appendix E: Observability – The Metrics You Actually Need
Fourteen Prometheus metrics that the top OSS gateways (LiteLLM, OpenRouter, Helicone, Portkey) all track:
# metrics.yaml (Grafana data source)
- gateway_request_total{provider, model, role, status}
- gateway_latency_seconds{provider, status}
- gateway_cost_usd_total{provider, team}
- gateway_fallback_triggered_total{from_provider, to_provider}
- gateway_circuit_breaker_state{provider} # 0=closed, 1=open, 2=half-open
- gateway_budget_exhausted_total{team}
- gateway_rate_limit_429_total{provider}
- gateway_token_in_total{provider}
- gateway_token_out_total{provider}
- gateway_stream_chunks_total{provider}
- gateway_stream_first_token_ms{provider}
- gateway_rbac_denied_total{role, provider}
- gateway_cache_hit_ratio{provider} # semantic cache
- gateway_tls_handshake_failures{provider}
Grafana alert rule (cost anomaly):
- alert: TeamBudgetRun
expr: sum by (team) (increase(gateway_cost_usd_total[24h])) > 0.8 * team_budget
for: 5m
labels:
severity: warning
annotations:
summary: "{{ $labels.team }} at 80% of 24h budget"
Appendix F: Semantic Cache – Cuts 30–60% of Spend
import numpy as np
from sentence_transformers import SentenceTransformer
_model = SentenceTransformer("all-MiniLM-L6-v2")
def semantic_cache_lookup(prompt: str, threshold: float = 0.92) -> Optional[str]:
embedding = _model.encode(prompt, normalize_embeddings=True)
best_score, best_val = 0.0, None
for cached_emb, cached_val in _redis_zset("prompt_cache"):
score = float(np.dot(embedding, cached_emb))
if score > best_score:
best_score, best_val = score, cached_val
return best_val if best_score >= threshold else None
def semantic_cache_store(prompt: str, response: str):
embedding = _model.encode(prompt, normalize_embeddings=True)
_redis_zadd("prompt_cache", embedding.tolist(), response, ttl=3600)
In the fallback loop, check the cache before hitting any provider. This is the single highest-ROI optimisation in the entire pipeline – the 40k-star tools report 30–60 % cost reduction on repetitive workloads (RAG pipelines, eval harnesses, synthetic data generation).
Concluding Thoughts
Building a multi-provider AI gateway is less about any single provider's API and more about treating LLM calls as a distributed-systems problem: circuit breakers, weighted scoring, RBAC scoping, cost budgets, and observability are the same primitives you'd use for a Kubernetes service mesh, just applied to tokens instead of bytes.
The 40k-star open-source gateways – LiteLLM, OpenRouter, Helicone, Portkey, LLM Gateway (Azure), Unify – converge on the same architecture:
- Provider abstraction layer (normalisation).
- Policy engine (RBAC + budget + rate limits).
- Routing / fallback chain with health-aware weighting.
- Observability plane (traces, cost, token accounting).
- Caching plane (exact + semantic).
What separates a weekend project from a production system is the depth of the fallback chain and the breadth of the RBAC matrix. A two-provider gateway that hard-codes "try OpenAI, then retry" will break the moment OpenAI's region degrades. A chain of four providers with circuit breakers, weighted scoring, and a local-model safety net gives you 99.95 % availability without a single custom SLA negotiation.
The lesson the 40k-star community keeps rediscovering: the gateway is not a proxy. It is a control plane. Once you internalise that, every design decision – from JWT claims to circuit-breaker cooldowns to which model sits on the local GPU sidecar – falls into place as a natural extension of the same distributed-systems toolkit you already use for microservices.
Ship the gateway. Instrument it. Let the fallback chains do the boring work so your developers can focus on the prompts, not the 502s.
For deeper explorations of AI infrastructure patterns, gateway design, and LLM observability, see Tamiz's Insights for curated deep-dives, or explore the broader collection at tamiz.pro.