{"id":291,"date":"2026-09-22T17:46:54","date_gmt":"2026-09-22T09:46:54","guid":{"rendered":"https:\/\/wp.qoraapi.com\/self-hosted-ai-gateway\/"},"modified":"2026-09-22T17:49:23","modified_gmt":"2026-09-22T09:49:23","slug":"self-hosted-ai-gateway","status":"publish","type":"post","link":"https:\/\/qoraapi.com\/blog\/self-hosted-ai-gateway\/","title":{"rendered":"Self-Hosting an AI Gateway: Architecture, Scaling, and Ops"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Self-hosting an AI gateway means running the proxy layer that sits between your applications and model providers on infrastructure you control. It buys you credential isolation, routing logic you can change in a single deploy, and per-request cost attribution that a black-box endpoint will never give you. It costs an on-call rotation, a provider schema-drift treadmill, and a stateful data plane that must not fall over. Below a few hundred million tokens a month, a managed gateway is usually the better trade; above that, or under a jurisdiction constraint you cannot negotiate away, self-hosting starts to win.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What a managed gateway gives you, and what it structurally cannot<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A managed gateway &#8211; <a href=\"https:\/\/qoraapi.com\/\">qoraapi.com<\/a> being one example &#8211; collapses a lot of undifferentiated work into a single OpenAI-compatible endpoint: credentials handled by someone else, failover already wired, one invoice, one rate-limit surface, one place to look when a provider degrades. That is not marketing. If your routing policy is &#8220;use model X, and if it 429s use model Y,&#8221; rebuilding it buys you nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The gaps appear when requirements stop being generic. A managed gateway cannot guarantee that a prompt body never leaves a specific region, because the payload is by definition in someone else&#8217;s process. It cannot route on your tenant taxonomy &#8211; enterprise on EU inference, free tier on the cheapest healthy provider, anything flagged medical pinned to a zero-retention endpoint. It cannot redact PII before egress or cache on your own key taxonomy, and it cannot let you survive a provider relationship ending without an application release.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Those are the four reasons teams self-host: <strong>data control<\/strong>, <strong>bespoke routing<\/strong>, <strong>cost transparency<\/strong>, and <strong>provider independence<\/strong>. Everything else is a proxy with extra steps.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The real cost of ownership<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The infrastructure is the cheap part. The expensive part is that a gateway is a Tier-0 dependency for every LLM feature you ship, and it changes underneath you continuously: providers add parameters, deprecate parameters, change defaults, and quietly alter streaming chunk shapes. Adapter code churns constantly even when your own product is frozen.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Steady state for a moderately complex gateway is a quarter to a half of an engineer&#8217;s time, permanently &#8211; incident response, provider migrations, credential rotation, capacity work, config reviews. The first quarter costs more, and any provider migration costs more again. Teams that budget for the build and not the maintenance end up with a half-maintained gateway, which is worse than a managed one: it fails in ways nobody on the current team understands.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The component map<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Every gateway decomposes into the same ten parts. Knowing which ones you are skipping is the difference between a working proxy and an outage.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Credential vault.<\/strong> Provider keys must never live in application config. Store them with envelope encryption under a KMS key, scoped per environment, rotated on a schedule you have rehearsed. The important property is asymmetry: applications hold a <em>gateway<\/em> key, the gateway holds <em>provider<\/em> keys, so a leaked application key is revoked without touching provider credentials. See <a href=\"https:\/\/qoraapi.com\/blog\/ai-api-security\/\">the AI API security checklist<\/a>.<\/li>\n<li><strong>Provider adapters.<\/strong> One module per provider, translating your canonical request into its native shape. This is the highest-churn code in the system, so test it hardest.<\/li>\n<li><strong>Request normalisation.<\/strong> A canonical schema. OpenAI&#8217;s chat completions shape is the de facto lingua franca, with well-known leaks around tool calls and multimodal content.<\/li>\n<li><strong>Routing policy.<\/strong> Rules over model alias, tenant, region, cost ceiling and provider health.<\/li>\n<li><strong>Rate limiting.<\/strong> Per key, per tenant, per model. Token-aware, not just request-count-aware, or one long-context request bypasses your protection.<\/li>\n<li><strong>Retry and failover.<\/strong> Error classification, retry budgets, jitter, and a circuit breaker per provider.<\/li>\n<li><strong>Response cache.<\/strong> Exact-match at minimum; semantic if the workload justifies it.<\/li>\n<li><strong>Usage metering.<\/strong> Token counts, cost and latency attributed to a tenant and a feature.<\/li>\n<li><strong>Observability.<\/strong> Per-request traces with prompt and response redaction, plus aggregate metrics.<\/li>\n<li><strong>Admin API.<\/strong> Keys, budgets, config versions, model catalogue. The control plane.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The split that matters most is control plane versus data plane. The control plane can be down for an hour and nothing breaks, provided the data plane serves from its last-known-good configuration. Build that property in on day one; retrofitting it during an incident is not possible.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Deployment topologies compared<\/h2>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\"><thead><tr><th>Topology<\/th><th>Added latency<\/th><th>Blast radius<\/th><th>Upgrade path<\/th><th>Where it fits<\/th><\/tr><\/thead><tbody><tr><td>Central cluster<\/td><td>One in-region network hop plus TLS and proxy work<\/td><td>Everything that talks to the gateway<\/td><td>One deploy changes behaviour fleet-wide, instantly<\/td><td>The default. Fix a provider outage once for all consumers.<\/td><\/tr><tr><td>Sidecar per pod<\/td><td>Loopback only<\/td><td>One pod, but a bad config needs every pod rolled to fix<\/td><td>Roll every workload; config distribution becomes the hard problem<\/td><td>Strong per-tenant network isolation requirements in a Kubernetes-heavy shop<\/td><\/tr><tr><td>In-process library or SDK<\/td><td>None<\/td><td>One process<\/td><td>Every application redeploys, in every language<\/td><td>Single-language monoculture, one owning team, no cross-team consumers<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Default to a central cluster. It is the only topology where a routing fix, a credential rotation or a provider failover lands everywhere at once. Sidecars sound appealing until you realise per-pod connection pools multiply your provider connection count by your replica count &#8211; exactly the thing providers rate-limit you on. In-process libraries work for a single-language shop, but you reimplement config, metering and observability once per language, and you lose failover without an application release &#8211; the main reason you built a gateway.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Stateless versus stateful: keeping counters out of the request path<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The request path should be stateless wherever it can be. Four things genuinely cannot be, and each needs a deliberate failure policy rather than a default.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\"><thead><tr><th>Stateful component<\/th><th>Consistency needed<\/th><th>Sensible failure policy<\/th><\/tr><\/thead><tbody><tr><td>Rate-limit counters<\/td><td>Strong per key, approximate globally<\/td><td>Fail open, with alerting. Rejecting all traffic because Redis blipped is worse than a short burst of overspend.<\/td><\/tr><tr><td>Budget and spend counters<\/td><td>Strong if the cap is contractual, eventual if it is advisory<\/td><td>Fail closed only when the number is a hard quota. Batch increments for advisory counters and accept bounded overshoot.<\/td><\/tr><tr><td>Response cache<\/td><td>None<\/td><td>A miss is the normal path. A cache outage must degrade to full origin traffic, never to 5xx.<\/td><\/tr><tr><td>Circuit-breaker health<\/td><td>Per instance is enough<\/td><td>Local state, converges in seconds. Never make it a shared dependency.<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The discipline is the same in all four cases: bound the round trip. A rate-limit check that can block for two seconds is worse than no rate limiter, because it converts a load problem into a fleet-wide latency problem. Give the counter a hard timeout in the tens of milliseconds and decide in advance what happens when it expires.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The check must be one atomic round trip: read-modify-write from application code races under concurrency and lets bursts through.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- token bucket, atomic in one round trip, no read-modify-write race\n-- KEYS[1] = bucket key   ARGV = capacity, refill_per_sec, now_ms, cost\nlocal capacity = tonumber(ARGV[1])\nlocal refill   = tonumber(ARGV[2])\nlocal now      = tonumber(ARGV[3])\nlocal cost     = tonumber(ARGV[4])\n\nlocal b      = redis.call(\"HMGET\", KEYS[1], \"tokens\", \"ts\")\nlocal tokens = tonumber(b[1]) or capacity\nlocal ts     = tonumber(b[2]) or now\n\ntokens = math.min(capacity, tokens + (now - ts) \/ 1000 * refill)\n\nif tokens &lt; cost then\n  redis.call(\"HMSET\", KEYS[1], \"tokens\", tokens, \"ts\", now)\n  redis.call(\"PEXPIRE\", KEYS[1], math.ceil(capacity \/ refill * 1000))\n  -- second return value is the retry-after hint in milliseconds\n  return { 0, math.ceil((cost - tokens) \/ refill * 1000) }\nend\n\nredis.call(\"HMSET\", KEYS[1], \"tokens\", tokens - cost, \"ts\", now)\nredis.call(\"PEXPIRE\", KEYS[1], math.ceil(capacity \/ refill * 1000))\nreturn { 1, 0 }<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">A routing policy you can actually run<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Policy belongs in declarative config, versioned and validated in CI, not in code branches. A workable shape:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># gateway\/policies\/chat-default.yaml\nversion: 3\nroute: chat-default\n\nmatch:\n  model_alias: [ \"chat-fast\", \"chat-balanced\" ]\n\nproviders:\n  - id: openai-primary\n    adapter: openai\n    model: gpt-4.1-mini\n    weight: 100\n    timeout:\n      connect_ms: 400\n      first_byte_ms: 2500\n      total_ms: 30000\n\n  - id: anthropic-fallback\n    adapter: anthropic\n    model: claude-sonnet-4\n    weight: 0                 # failover target only, never load-balanced\n    timeout:\n      connect_ms: 400\n      first_byte_ms: 3000\n      total_ms: 45000\n\n  - id: bedrock-eu\n    adapter: bedrock\n    region: eu-central-1\n    model: meta.llama3-70b\n    weight: 0\n    timeout:\n      connect_ms: 600\n      first_byte_ms: 4000\n      total_ms: 60000\n\nretry:\n  max_attempts: 3\n  budget_ratio: 0.10          # retries may never exceed 10% of total traffic\n  backoff: exponential_jitter\n  retry_on: [ \"connect_timeout\", \"first_byte_timeout\", \"http_429\", \"http_5xx\" ]\n  never_retry_on: [ \"http_400\", \"http_401\", \"http_403\", \"content_filter\" ]\n\ncircuit_breaker:\n  error_rate_threshold: 0.50\n  min_requests: 20\n  open_seconds: 15\n\ncache:\n  exact_match: true\n  ttl_seconds: 3600\n  key: [ model_alias, messages, temperature, tenant_id ]\n\nlimits:\n  tenant_rpm: 600\n  tenant_tpm: 400000<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two details carry most of the value. First, <code>budget_ratio<\/code>: without it, retries are unbounded amplification, as documented in <a href=\"https:\/\/qoraapi.com\/blog\/ai-api-failover-multi-provider\/\">the multi-provider failover playbook<\/a>. Second, <code>weight: 0<\/code> on the fallbacks &#8211; a fallback receiving steady traffic is not a fallback but a second primary with worse cost characteristics and no warm-up guarantee.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Scaling the data plane<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Connection pooling is the whole game.<\/strong> Keepalive pools must be sized to peak concurrency, not to instance count, and each provider needs its own pool because their latency and rate-limit profiles differ. HTTP\/2 multiplexing helps, but a single connection carries a bounded number of concurrent streams &#8211; typically around a hundred &#8211; and beyond that requests queue invisibly. An undersized pool shows up as p99 latency that looks like provider slowness but is local queueing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Streaming changes the capacity model.<\/strong> An SSE response holds a connection for the entire generation. An instance serving five hundred concurrent streams is nearly idle on CPU and bounded by memory buffers and file descriptors, not compute. Request-per-second is therefore the wrong autoscaling signal; scale on in-flight streams. Disable response buffering at every hop &#8211; <code>proxy_buffering off<\/code> in nginx, <code>X-Accel-Buffering: no<\/code> from the proxy &#8211; or your streaming endpoint will deliver the whole answer in one burst after a ten-second stall. Never compress an event stream.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import asyncio, time, httpx\nfrom fastapi import FastAPI, Request\nfrom fastapi.responses import StreamingResponse\n\nUPSTREAM = {\n    \"openai\":    \"https:\/\/api.openai.com\/v1\/chat\/completions\",\n    \"anthropic\": \"https:\/\/api.anthropic.com\/v1\/messages\",\n}\n\n# One pool per provider. pool=0.1 is the admission-control valve: if no\n# connection is free within 100ms we shed load instead of queueing forever.\nPOOLS = {\n    \"openai\": httpx.AsyncClient(\n        limits=httpx.Limits(max_connections=256, max_keepalive_connections=128),\n        timeout=httpx.Timeout(connect=0.4, read=30.0, write=5.0, pool=0.1),\n    ),\n    \"anthropic\": httpx.AsyncClient(\n        limits=httpx.Limits(max_connections=128, max_keepalive_connections=64),\n        timeout=httpx.Timeout(connect=0.4, read=45.0, write=5.0, pool=0.1),\n    ),\n}\n\napp = FastAPI()\n\n@app.post(\"\/v1\/chat\/completions\")\nasync def chat(request: Request):\n    body     = await request.json()\n    tenant   = request.headers[\"x-tenant-id\"]\n    provider = route(body)          # policy lookup, no I\/O\n\n    if not body.get(\"stream\"):\n        return await collect(provider, body, tenant)\n\n    async def relay():\n        started = time.monotonic()\n        client  = POOLS[provider]\n        async with client.stream(\n            \"POST\", UPSTREAM[provider], json=body,\n            headers={\"Authorization\": \"Bearer \" + await keyring.get(provider)},\n        ) as up:\n            up.raise_for_status()\n            async for chunk in up.aiter_raw():\n                if await request.is_disconnected():\n                    break            # stop paying for tokens nobody will read\n                yield chunk\n        # fire-and-forget: metering must never sit on the response path\n        asyncio.create_task(emit_usage(tenant, provider, time.monotonic() - started))\n\n    return StreamingResponse(\n        relay(),\n        media_type=\"text\/event-stream\",\n        headers={\"cache-control\": \"no-store\", \"x-accel-buffering\": \"no\"},\n    )<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The <code>is_disconnected<\/code> check is not an optimisation. Without it, a user closing a tab leaves the upstream generation running, and you pay for every token of it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>The sticky-session trap.<\/strong> The temptation with long-lived streams is to pin a client to an instance by cookie or source IP. Do not. Stickiness destroys load balancing, strands in-flight streams on instances you are draining, and turns every deploy into a rolling brownout. The alternative is a stateless router plus graceful shutdown: on SIGTERM stop accepting new connections, let in-flight streams finish up to a deadline, then exit. Set the load balancer idle timeout above your maximum generation time, or long streams get severed mid-sentence by a healthy-looking proxy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Backpressure has three layers<\/strong>, and you need all three: connection limits at the load balancer, a concurrency semaphore per instance, and a per-tenant concurrency cap. When saturated, reject with 429 and a <code>Retry-After<\/code> rather than queueing: queueing converts a capacity problem into a timeout storm, and timeouts consume capacity while producing nothing. The <code>pool=0.1<\/code> timeout above is the mechanism &#8211; deliberate load shedding, not a misconfiguration.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Failure modes, in the order they will bite you<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Config rollout that breaks all traffic.<\/strong> The most common self-inflicted gateway outage by a wide margin: one inverted condition, one typo in a provider ID, or a schema change that silently drops a field, and 100% of traffic fails at once. Validate config against a strict schema in CI and reject unknown fields; roll out 1% then 10% then 100% with automatic rollback on error-rate delta; and keep last-known-good config in the data plane so a control-plane failure cannot take down serving.<\/li>\n<li><strong>Retry storms.<\/strong> A provider starts 429ing, every instance retries, the retries multiply load threefold, the provider pushes back harder, and a transient degradation becomes a sustained outage. Fixes: a retry budget as a fraction of total traffic, full-jitter backoff, a per-provider circuit breaker, and a hard rule never to retry on 400, 401, 403 or content-filter responses &#8211; those are deterministic and will fail identically three times.<\/li>\n<li><strong>Cache stampede.<\/strong> A popular prompt&#8217;s entry expires, two hundred concurrent requests miss simultaneously, and all two hundred hit the provider with an identical payload. Use single-flight: the first request fills the entry, the rest await the same future. Add TTL jitter so entries do not expire in lockstep, and a stale-while-revalidate window so a refresh never blocks a reader.<\/li>\n<li><strong>Hot partitions in the rate limiter.<\/strong> One tenant with a shared bucket saturates a Redis shard and adds latency to every other tenant on it, because a single global counter is a single hot shard. Shard by tenant, give any tenant large enough to saturate one shard its own bucket with a longer refill window, and monitor per-shard latency rather than the aggregate.<\/li>\n<li><strong>Provider schema drift.<\/strong> A provider adds a response field, changes a default, or deprecates a parameter, and your adapter silently drops it &#8211; behaviour changes without an error. Use contract tests against recorded fixtures per provider version, count unrecognised response fields and alert when the counter moves, and run strict mode in staging that errors on unknown fields.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Notice what is absent from this list: provider downtime. Providers are more reliable than the code you write on top of them, and the outages you remember will be your own.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A worked TCO example<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">An illustrative calculation with visible arithmetic, not a benchmark or a quote. Substitute your own numbers; the shape of the result is what matters.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Volume.<\/strong> 5,000,000 requests\/day. Mean request payload 4 KB, mean response payload 8 KB, so 12 KB per request.<\/li>\n<li><strong>Peak concurrency.<\/strong> Mean rate is 5,000,000 \/ 86,400 = 58 requests\/second. With a 3x diurnal peak that is 174 requests\/second. If 30% of traffic streams for a mean 8 seconds, peak concurrent streams are 174 x 0.30 x 8 = 418.<\/li>\n<li><strong>Compute.<\/strong> At 250 concurrent streams per instance for headroom, 418 \/ 250 = 1.7, so 2 instances for peak plus 1 for AZ and deploy redundancy = 3 instances. A 4 vCPU \/ 8 GB instance at $0.19\/hour is 0.19 x 730 = $139\/month. Three of them: <strong>$417\/month<\/strong>.<\/li>\n<li><strong>Egress.<\/strong> 5,000,000 x 12 KB = 60 GB\/day, or 1,800 GB\/month. At $0.09\/GB: <strong>$162\/month<\/strong>.<\/li>\n<li><strong>Load balancer and NAT data processing:<\/strong> <strong>$65\/month<\/strong>.<\/li>\n<li><strong>State store.<\/strong> A managed Redis with a replica: <strong>$80\/month<\/strong>.<\/li>\n<li><strong>Observability.<\/strong> Full trace capture would be 5,000,000 x 1.5 KB = 7.5 GB\/day. Sample 10% instead: 750 MB\/day, about 22 GB\/month, roughly $7 at $0.30\/GB ingest, plus $60 for metrics and logs. Call it <strong>$70\/month<\/strong>.<\/li>\n<li><strong>Infrastructure subtotal:<\/strong> 417 + 162 + 65 + 80 + 70 = <strong>$794\/month<\/strong>.<\/li>\n<li><strong>Engineering.<\/strong> 0.35 FTE at $200,000 fully loaded = $70,000\/year = <strong>$5,833\/month<\/strong>.<\/li>\n<li><strong>Total:<\/strong> $794 + $5,833 = <strong>$6,627\/month<\/strong>, or $0.044 per 1,000 requests at 150,000,000 requests\/month.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The ratio is the point: infrastructure is roughly one seventh of the cost and people are six sevenths. Two consequences follow. First, &#8220;it is cheaper&#8221; is not a valid argument for self-hosting &#8211; the dominant term barely moves between 5M and 50M requests\/day, so per-request economics improve sharply with volume while absolute cost hardly changes. Second, compare the fully loaded $6,627 against what a managed gateway costs at this volume. If the managed fee lands in the same range, self-hosting is a bad trade on cash terms alone.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">When self-hosting is worth it, and when it is not<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Self-host when<\/strong> a regulator or customer contract requires payloads to stay inside a specific VPC or jurisdiction and no vendor will commit to that contractually. When routing must be genuinely bespoke &#8211; cost-aware tiering, per-tenant provider pinning, pre-egress redaction, region-pinned inference for a subset of traffic. When you need to switch providers without an application release, which is an architectural property, not a cost one.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do not self-host when<\/strong> you are pre-product-market-fit and routing is &#8220;one provider with occasional failover.&#8221; When nobody will be paged for a proxy at 3am. When you cannot commit a quarter of an engineer&#8217;s time permanently, because a half-maintained gateway is worse than a managed one &#8211; it fails in ways nobody currently employed understands. And when the only motivation is cost, which the arithmetic above shows rarely survives contact with the staffing number.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The strongest position is not to pick one. Keep your application speaking a single canonical, OpenAI-compatible schema, put credentials and routing behind an interface you own, and treat the gateway as a swappable component. Start managed, and migrate when a concrete requirement &#8211; not a vibe &#8211; forces you. Teams that self-hosted for a compliance obligation are happy with the decision; teams that self-hosted to save money usually are not. For a wider survey of the managed option, see <a href=\"https:\/\/qoraapi.com\/blog\/ai-api-gateway-guide\/\">the AI API gateway guide<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">How much latency does a self-hosted gateway actually add?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">One in-region network hop plus TLS termination and proxy work. Provider time-to-first-token dominates the end-to-end number, so if proxy overhead is more than a small fraction of total TTFB, the cause is almost always a state lookup on the request path: an unbounded Redis call, a synchronous config fetch, or uncached DNS resolution. Instrument the proxy span separately from the upstream span; if you cannot separate them, you have an observability problem before a latency problem.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Do I need three instances, or is two enough?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Two instances survive a single failure but run at 50% capacity each at peak, so you pay for redundancy you can never use. Three runs each at 67% and absorbs both an AZ loss and a rolling deploy. If your gateway fronts a chat feature, two is defensible; anything with a revenue SLA needs three.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Should the response cache live in the gateway or the application?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Exact-match caching belongs in the gateway: the key is derivable from the request and the benefit is shared across consumers. Semantic caching is a product decision &#8211; the embedding model, similarity threshold and staleness window depend on your workload &#8211; so it belongs where those can be tuned per feature. The mechanics are covered in <a href=\"https:\/\/qoraapi.com\/blog\/semantic-caching-ai-api\/\">the semantic caching writeup<\/a>. Either way, a cache miss must be an ordinary code path, never an error.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I handle provider-specific parameters the canonical schema does not have?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">An explicit, allowlisted passthrough bag &#8211; something like <code>provider_options<\/code> &#8211; scoped to a single request. Never a blanket passthrough of arbitrary fields: it lets application code set parameters that violate your routing invariants, and it is how you end up with a request pinning a model the router thought it was choosing. The allowlist is small and grows by review, which is the point.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What should I instrument first?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Four things, in order: a per-request trace carrying provider, model, attempt number and outcome; upstream time-to-first-byte and total duration as separate histograms; prompt and completion token counts attributed to tenant; and a retry counter broken down by reason. Those answer almost every question you will have during an incident. Prompt and response bodies are useful in staging and dangerous in production &#8211; see <a href=\"https:\/\/qoraapi.com\/blog\/llm-observability\/\">the observability guide<\/a> for keeping the useful parts while keeping payloads out of your log store.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Self-hosting an AI gateway is a straightforward engineering problem wrapped in a hard organisational one. The engineering is well understood: normalise requests, isolate credentials, keep the request path stateless, bound every state lookup, control retries with a budget, and make the control plane incapable of taking down the data plane. The hard part is that you are signing up for permanent ownership of a Tier-0 service whose dependencies change every few weeks.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Decide on requirements, not on cost. If a jurisdiction constraint, a bespoke routing rule or a genuine provider-independence requirement forces your hand, self-host and staff it properly &#8211; a quarter of an engineer, permanently, not a sprint. If none apply, run managed, keep your client interface canonical, and preserve your ability to move later. That optionality is worth more than the infrastructure savings, and it is the one decision you cannot retrofit cheaply once application code has learned a vendor&#8217;s quirks.<\/p>\n\n\n\n\n<h3 class=\"wp-block-heading\">Related reading<\/h3>\n\n\n<ul class=\"wp-block-list\"><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-api-gateway-guide\/\">What Is an AI API Gateway? A Practical Guide for Developers<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/best-ai-api-gateway-2026-guide\/\">Ultimate Guide: How to Choose the Best AI API Gateway in 2026<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-gateway-vs-api-gateway\/\">AI Gateway vs API Gateway: Key Differences and When to Use Each<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-api-failover-multi-provider\/\">How to Build a Multi-Provider AI Failover Layer for 99.9% Uptime<\/a><\/li><\/ul>\n\n","protected":false},"excerpt":{"rendered":"<p>When self-hosting an AI gateway beats a managed one: the component map, three deployment topologies, stateful components, routing policy, scaling traps and a worked TCO example.<\/p>\n","protected":false},"author":1,"featured_media":290,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[5,6,9,7],"class_list":["post-291","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai-api","tag-ai-api","tag-api-gateway","tag-developer-tools","tag-developers"],"_links":{"self":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/291","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/comments?post=291"}],"version-history":[{"count":1,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/291\/revisions"}],"predecessor-version":[{"id":306,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/291\/revisions\/306"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media\/290"}],"wp:attachment":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media?parent=291"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/categories?post=291"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/tags?post=291"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}