DEV Community

Cover image for Envoy AI Gateway: A Hands-On Tour You Can Run Before Touching Kubernetes
kt
kt

Posted on

Envoy AI Gateway: A Hands-On Tour You Can Run Before Touching Kubernetes

The day we ended up with three places to call an LLM

It started with OpenAI. Then someone pointed out that Bedrock had a cheaper model for one of our workloads, so Bedrock got added. Then a team wanted to use the vLLM instance running on our own GPUs, and that made three.

At that point the codebase looked like this:

  • Service A called the OpenAI SDK directly
  • Service B hit bedrock-runtime through boto3
  • Service C talked to vLLM with a hand-rolled HTTP client
  • Three API keys lived in three separate Secrets, each with its own rotation procedure
  • Nobody could answer "how many tokens did each team burn last month?"

Every one of those code paths does the same thing: send text, get text back. And yet we had three SDKs, three retry policies, three auth schemes, three sets of instrumentation. That is not an application problem. It is the absence of a front door. The HTTP world solved this fifteen years ago with API gateways, and the same thing is happening again with LLM traffic.

Envoy AI Gateway is the project filling that gap, and it hit v1.0 GA on June 23, 2026.

This article builds up in order: start with local execution that uses no Kubernetes whatsoever, then look at real schema translation, fallback, MCP, and only at the end move to Kubernetes. Read it top to bottom and you can follow along with your hands on the keyboard.

Every command and every output below came from an actual run. The environment was macOS on arm64, Go 1.26.5, aigw v1.0.0, kind v0.32.0, Envoy Gateway v1.8.1, and Envoy AI Gateway v1.0.0.


0. How to read this

Sections Content What you need
1 - 2 What an AI gateway is, and where it sits in the Envoy family Nothing
3 - 4 Architecture and the CRDs Nothing
5 Get it running Go or Docker
6 Watch schema translation happen Same
7 Fallback and model name virtualization Same
8 MCP gateway Same, plus Node.js
9 Move it to Kubernetes kind / kubectl / helm
10 - 12 Token limits, production features, when to adopt Nothing

Sections 5 through 8 need neither Kubernetes nor a cloud API key. Everything happens against mock servers running on localhost. If you are short on time, those four sections alone are worth it.


1. What makes an "AI gateway" different from a normal API gateway

"Can't you just put nginx or Envoy in front of the LLM?" is a reasonable instinct, and it is about 70 percent right. The other 30 percent is why a dedicated product exists.

1-1. The same operation has three different shapes

OpenAI, Anthropic, and Bedrock all support "one round trip of chat," and all three want a different path, a different body, and return a different response shape.

OpenAI      POST /v1/chat/completions   {"model":..., "messages":[...], "max_tokens":N}
Anthropic   POST /v1/messages           {"model":..., "messages":[...], "max_tokens":N, "system":"..."}
Bedrock     POST /model/<id>/converse   {"messages":[{"role":..,"content":[{"text":..}]}],
                                         "system":[{"text":..}],
                                         "inferenceConfig":{"maxTokens":N}}
Enter fullscreen mode Exit fullscreen mode

max_tokens becomes inferenceConfig.maxTokens. The system prompt gets pulled out of messages and turned into its own top-level array. A normal reverse proxy can rewrite paths and headers, but restructuring a JSON body is outside its job description.

This is the least glamorous and most valuable thing an AI gateway does. Section 6 shows it happening for real.

1-2. Requests are the wrong unit for billing

For a normal API, "100 requests per second" is a fine limit. For an LLM it barely means anything. A 10-token request and a 100,000-token request both count as 1. The cost differs by four orders of magnitude.

Worse, you do not know the consumption until the response comes back. At request time the output token count does not exist yet. You need a pay-later model: approve first, bill afterward. That is a different premise than any rate limiter was built on.

1-3. Responses stream for a long time

An SSE connection stays open for tens of seconds to several minutes. Default timeouts, buffer sizes, whether a retry is even safe. All of it drifts from HTTP API common sense.

Here are those three problems in one picture. On the left, every application carries its own translation, key management, and instrumentation. On the right, that moves into the gateway.

Before and after putting a gateway in front

The reduction in lines matters less than the fact that capabilities disappeared from the application boxes. Getting API keys out of applications is the single biggest operational win.


2. Where this sits in the Envoy family

Three products with confusingly similar names stack on top of each other, so let me sort that out first.

The three layers of the Envoy family

  • Envoy Proxy carries the data. On its own it needs either a config file or xDS.
  • Envoy Gateway is the management layer that reads Kubernetes Gateway API resources and turns them into Envoy config.
  • Envoy AI Gateway adds AI-specific CRDs on top and reaches into xDS through an Envoy Gateway extension point called the extension server.

Envoy AI Gateway does not replace Envoy Gateway. It sits on top. If you already run Envoy Gateway you can bolt this on, but the flip side is that Envoy Gateway concepts become prerequisites.

If you have never touched Gateway API, these four are enough to follow along.

Resource Role
GatewayClass Which implementation to use. The ingressClass equivalent
Gateway Which port to listen on
HTTPRoute How to route
Backend Target hostname and port. An Envoy Gateway specific CRD

Think of it as the standard Kubernetes API group that succeeded Ingress.


3. Architecture

3-1. Control plane and data plane

Control plane and data plane

None of the AI-specific logic lives inside Envoy itself. All of it is pushed out into an external process called ExtProc.

Since that term just appeared for the first time, here is what it means. ExtProc is a stock Envoy HTTP filter that streams request and response headers and bodies to an external process over gRPC, and then applies whatever that external process rewrote back into Envoy. Envoy AI Gateway implements its side in Go. That means no C++ to write, and no coupling to Envoy's own release cycle.

3-2. Why ExtProc is wired in twice

ExtProc sits in two places: in front of the router filter and at the upstream filter position. This is the design decision that pays off the most, so it is worth explaining before you see the effect.

Later in this article you will configure "if the primary provider fails, fall back to a different one." In Envoy, that retry happens after the router filter, at the upstream level. Here is the problem: if the primary is OpenAI and the fallback is Bedrock, then the moment you fail over, both the body translation and the auth header have to change. Translating once, before the router, would be too early.

ExtProc at the router stage and the upstream stage

Splitting it in two exists to preserve an obvious ordering rule: translate only after the destination is known.

3-3. The life of a request

The life of a request

Two things to take away.

First, x-ai-eg-model is not a header the client sets. The gateway extracts it from the model field in the body and attaches it itself. Your routing rules match on that header.

Second, dynamic metadata. This is a named scratch area Envoy keeps per request, used to pass values between filters. ExtProc writes token counts there, and the rate limiter downstream reads them. Section 10 puts it to work.


4. The CRDs worth memorizing

How the CRDs reference each other

Resource Role
AIGatewayRoute The unified API as clients see it. Picks a backend by header match. Generates an HTTPRoute with the same name
AIServiceBackend One upstream. schema declares what format that upstream speaks, which decides the translation target
BackendSecurityPolicy Upstream auth. API keys, plus short-lived tokens fetched from AWS STS, Entra ID, or GCP STS
Backend Hostname and port. An Envoy Gateway resource, not AI-specific
MCPRoute Aggregates MCP servers. Section 8
QuotaPolicy Cumulative token budgets. Section 10
GatewayConfig Per-Gateway ExtProc settings

One thing trips people up here. In v1.0, AIGatewayRoute has no schema field. The input schema is determined by the request path: /v1/chat/completions means OpenAI format, /anthropic/v1/messages means Anthropic format. The only thing you declare is the egress side, AIServiceBackend.schema.

At v1.0 GA, AIGatewayRoute, AIServiceBackend, BackendSecurityPolicy, GatewayConfig, and MCPRoute all became stable at v1beta1. The maintainers state plainly that they will not break these APIs short of a critical security issue. QuotaPolicy is still v1alpha1 and sits outside that promise.


5. Hands-on: run it without Kubernetes

Envoy AI Gateway ships a CLI called aigw that stands up a gateway locally with neither Kubernetes nor Docker, eating exactly the same CRD YAML. It is good for learning and for validating config before you ship it. Linux and macOS only.

5-1. Install aigw

If you have Go, this is the fastest path.

go install github.com/envoyproxy/ai-gateway/cmd/aigw@v1.0.0
export PATH=$PATH:$(go env GOPATH)/bin
Enter fullscreen mode Exit fullscreen mode

Docker works too.

docker run --rm -p 1975:1975 -e OPENAI_API_KEY=$OPENAI_API_KEY \
  envoyproxy/ai-gateway-cli run
Enter fullscreen mode Exit fullscreen mode

Prebuilt binaries are on GitHub Releases. The whole subcommand surface is this:

  version                Show version.
  run [<path>]           Run the AI Gateway locally for given configuration.
  healthcheck            Docker HEALTHCHECK command.
  download-envoy         Download Envoy binary for the Envoy Gateway default version.
Enter fullscreen mode Exit fullscreen mode

On first start, aigw run fetches the Envoy binary for you. On my machine that put Envoy 1.38.1 under ~/.local/share/aigw/, about 97MB. Later runs reuse it.

One gotcha: if you installed via go install, aigw version prints dev because the build info never got stamped in. Use a release binary or the Docker image if you need to confirm the version.

5-2. Stand up a mock LLM to talk to

Burning real OpenAI credit for this would be wasteful, so here is a server that returns an OpenAI-compatible response and nothing else, written against the Python standard library. Zero dependencies.

#!/usr/bin/env python3
"""Minimal OpenAI-compatible mock upstream, for testing aigw locally."""
import json
import time
from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        print("upstream <- %s %s" % (self.command, self.path), flush=True)

    def do_POST(self):
        n = int(self.headers.get("content-length", 0))
        body = json.loads(self.rfile.read(n) or b"{}")
        # observe what the gateway injected on the way in
        print("authorization=%r" % self.headers.get("authorization"), flush=True)
        print("model=%r" % body.get("model"), flush=True)
        self._json({
            "id": "chatcmpl-mock",
            "object": "chat.completion",
            "created": int(time.time()),
            "model": body.get("model", "mock-gpt"),
            "choices": [{"index": 0, "finish_reason": "stop",
                         "message": {"role": "assistant", "content": "I'll be back."}}],
            "usage": {"prompt_tokens": 11, "completion_tokens": 5, "total_tokens": 16},
        })

    def _json(self, obj, code=200):
        raw = json.dumps(obj).encode()
        self.send_response(code)
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)


HTTPServer(("127.0.0.1", 11434), Handler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

Save it as mockllm.py and start it. Port 11434 matches Ollama's default, so if you already run Ollama you can point at that instead of the mock.

python3 mockllm.py &
Enter fullscreen mode Exit fullscreen mode

5-3. Start it with no config file

aigw run generates its own config from environment variables. If any of OPENAI_API_KEY, AZURE_OPENAI_API_KEY, or ANTHROPIC_API_KEY is set, no config file is needed. Override OPENAI_BASE_URL to aim it at the mock or at Ollama.

OPENAI_BASE_URL=http://localhost:11434/v1 OPENAI_API_KEY=unused aigw run
Enter fullscreen mode Exit fullscreen mode
AI Gateway External Processor is ready
Envoy AI Gateway listening on http://localhost:1975 (admin http://localhost:52816) after 6.3s
Enter fullscreen mode Exit fullscreen mode

First start took 6.3 seconds including the Envoy download. Later starts took 1.4 seconds.

The admin port in that log line is Envoy's own admin port, and it changes on every start. The aigw admin endpoint you will use shortly is a different thing, fixed at 1064.

5-4. Send a request

curl -s -H "Content-Type: application/json" -XPOST \
  http://localhost:1975/v1/chat/completions \
  -d '{"model":"mock-gpt","messages":[{"role":"user","content":"Say this is a test!"}]}'
Enter fullscreen mode Exit fullscreen mode
{"id": "chatcmpl-mock", "object": "chat.completion", "model": "mock-gpt", "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "I'll be back."}}], "usage": {"prompt_tokens": 11, "completion_tokens": 5, "total_tokens": 16}}
Enter fullscreen mode Exit fullscreen mode

That worked. The interesting part is on the mock side.

authorization='Bearer unused'
model='mock-gpt'
upstream <- POST /v1/chat/completions
Enter fullscreen mode Exit fullscreen mode

The curl command sent no Authorization header at all, yet the upstream received Bearer unused. The gateway injected OPENAI_API_KEY. That is "get keys out of the application" made concrete, and on Kubernetes BackendSecurityPolicy fills the same role.

5-5. Tokens are already being counted

The aigw admin port at 1064 serves Prometheus-format metrics.

curl -s http://localhost:1064/health
curl -s http://localhost:1064/metrics | grep -E 'gen_ai.*(_sum|_count)\{'
Enter fullscreen mode Exit fullscreen mode
OK
gen_ai_client_token_usage_sum{gen_ai_operation_name="chat",gen_ai_provider_name="openai",gen_ai_request_model="mock-gpt",gen_ai_token_type="input"} 11
gen_ai_client_token_usage_sum{...,gen_ai_token_type="output"} 5
gen_ai_server_request_duration_seconds_sum{...} 0.010359625
Enter fullscreen mode Exit fullscreen mode

The prompt_tokens: 11 and completion_tokens: 5 the mock returned landed straight in the metrics. Names and labels follow the OpenTelemetry GenAI semantic conventions, so dashboards can stay vendor neutral.

Note that gen_ai_client_token_usage is exported as a histogram, not a counter. Use _sum for total tokens and _count for request counts.

Metric What it tells you
gen_ai.client.token.usage Token consumption. gen_ai.token.type splits input from output
gen_ai.server.request.duration End-to-end request latency
gen_ai.server.time_to_first_token Time to the first token. Perceived responsiveness
gen_ai.server.time_per_output_token Inter-token latency. Generation speed

For LLMs, how fast something feels is usually decided by time to first token rather than total latency, so getting that out of the box matters.


6. Hands-on: watch schema translation happen

Section 1 claimed that restructuring JSON is the least glamorous and most valuable thing here. Time to see it.

The setup: stand up a mock that expects requests in Bedrock Converse format, then send it OpenAI format from the client. Print the raw body the upstream received and the gateway's work is right there on screen.

6-1. A mock that speaks Bedrock

#!/usr/bin/env python3
"""AWS Bedrock Converse format mock upstream.
   Prints whatever the gateway translated the request into."""
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        pass

    def do_POST(self):
        n = int(self.headers.get("content-length", 0))
        raw = self.rfile.read(n)
        print("=== path the upstream received ===", flush=True)
        print(self.path, flush=True)
        print("=== body the upstream received ===", flush=True)
        print(json.dumps(json.loads(raw or b"{}"), indent=2), flush=True)

        # respond in Bedrock Converse format
        resp = {
            "output": {"message": {"role": "assistant",
                                   "content": [{"text": "I'll be back."}]}},
            "stopReason": "end_turn",
            "usage": {"inputTokens": 11, "outputTokens": 5, "totalTokens": 16},
        }
        body = json.dumps(resp).encode()
        self.send_response(200)
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


HTTPServer(("127.0.0.1", 11600), Handler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

6-2. Set the egress schema to AWSBedrock

The config is short. Set schema.name on the AIServiceBackend to AWSBedrock and that is it.

apiVersion: aigateway.envoyproxy.io/v1beta1
kind: AIServiceBackend
metadata:
  name: bedrock-mock
  namespace: default
spec:
  schema:
    name: AWSBedrock
  backendRef:
    name: bedrock-mock
    kind: Backend
    group: gateway.envoyproxy.io
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: Backend
metadata:
  name: bedrock-mock
  namespace: default
spec:
  endpoints:
    - fqdn:
        hostname: localhost
        port: 11600
Enter fullscreen mode Exit fullscreen mode

Add the same GatewayClass, Gateway, and AIGatewayRoute used in section 7, then start it with aigw run translate.yaml.

6-3. Send OpenAI format

curl -s -XPOST http://localhost:1975/v1/chat/completions \
  -H 'content-type: application/json' \
  -d '{"model":"claude-mock","max_tokens":100,
       "messages":[{"role":"system","content":"You are terse."},
                   {"role":"user","content":"hi"}]}'
Enter fullscreen mode Exit fullscreen mode

This is what the upstream received.

=== path the upstream received ===
/model/claude-mock/converse
=== body the upstream received ===
{
  "inferenceConfig": {
    "maxTokens": 100
  },
  "messages": [
    {
      "content": [
        {
          "text": "hi"
        }
      ],
      "role": "user"
    }
  ],
  "system": [
    {
      "text": "You are terse."
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Three separate things happened.

OpenAI format translated into Bedrock Converse format

The third one, peeling the system prompt out of the messages array and relocating it into a standalone system array, is exactly the kind of thing you cannot express in reverse proxy configuration.

6-4. The return trip gets translated too

The mock replies in Bedrock format. This is what reached the client.

{
    "choices": [
        {
            "finish_reason": "stop",
            "index": 0,
            "message": { "content": "I'll be back.", "role": "assistant" }
        }
    ],
    "model": "claude-mock",
    "object": "chat.completion",
    "usage": { "prompt_tokens": 11, "completion_tokens": 5, "total_tokens": 16 }
}
Enter fullscreen mode Exit fullscreen mode

output.message.content[0].text became choices[0].message.content, and usage.inputTokens became usage.prompt_tokens. An OpenAI SDK will consume this unmodified.

6-5. Where I got stuck: Anthropic is not a valid translation target

My first attempt wrote the mock in Anthropic Messages format and set schema.name: Anthropic. It returned 500, with this in the log.

level=ERROR msg="error processing request message"
  error="... failed to create translator for backend ...: unsupported API schema: backend={Anthropic  v1}"
Enter fullscreen mode Exit fullscreen mode

Digging into it, the cause was not what I assumed. It is not that the translation is impossible. That combination simply is not registered in a switch. The error comes from here, in internal/endpointspec/endpointspec.go.

// ChatCompletionsEndpointSpec.GetTranslator
switch schema.Name {
case filterapi.APISchemaOpenAI:        ...
case filterapi.APISchemaAWSBedrock:    ...
case filterapi.APISchemaAWSAnthropic:  ...
case filterapi.APISchemaAzureOpenAI:   ...
case filterapi.APISchemaGCPVertexAI:   ...
case filterapi.APISchemaGCPAnthropic:  ...
default:
    return nil, fmt.Errorf("unsupported API schema: backend=%s", schema)
}
Enter fullscreen mode Exit fullscreen mode

There is no case for APISchemaAnthropic. That is the whole story.

The body translation from OpenAI format to Anthropic Messages format exists and works today. Both openai_awsanthropic.go and openai_gcpanthropic.go call the same buildAnthropicParams(openAIReq, ...), and what that returns is *anthropic.MessageNewParams, the struct from Anthropic's official Go SDK. What is missing is the branch for "when the destination is api.anthropic.com," which would differ only by setting the path to /v1/messages and skipping the anthropic_version injection.

By contrast, the switch for requests entering through /anthropic/v1/messages has all five filled in.

// MessagesEndpointSpec.GetTranslator
case APISchemaGCPAnthropic / APISchemaAWSAnthropic / APISchemaAnthropic
   / APISchemaOpenAI / APISchemaAWSBedrock
Enter fullscreen mode Exit fullscreen mode

You can accept Anthropic format and emit to an OpenAI backend. Only the reverse direction has a hole in it.

In practice this is what it means:

Where your Claude lives Usable from an OpenAI-format client?
On Bedrock (AWSAnthropic) Yes
On Vertex AI (GCPAnthropic) Yes
Direct contract with Anthropic (Anthropic) No. You have to receive on /anthropic/v1/messages

The most straightforward setup is the least convenient one. This is a v1.0.0 observation and a later version may well close the gap.

Also worth clearing up: AWSAnthropic and Anthropic are not two routes to the same place. They are the same model bought from different vendors. api.anthropic.com does not accept IAM credentials, and Bedrock does not accept an Anthropic API key. Which one you write is not a preference, it is dictated by where your Claude actually lives.

Anthropic AWSAnthropic AWSBedrock
Destination api.anthropic.com Bedrock Bedrock
Path /v1/messages /model/<id>/invoke /model/<id>/converse
Body Anthropic native Anthropic native plus anthropic_version Converse's shared format
Auth AnthropicAPIKey AWSCredentials AWSCredentials

AWSAnthropic and AWSBedrock both target Bedrock, but the former uses InvokeModel and drops the Anthropic body into a Bedrock envelope untouched, while the latter repacks everything into Converse, the format shared across all Bedrock models. Pick Converse if you want to swap between Claude and Llama with one request shape. Pick InvokeModel if you want Anthropic-specific structures to pass through intact.

So schema is decided by three things, not one: the shape of the upstream API, which vendor you bought from, and whether that pairing with your ingress path is implemented. These are the eight values defined at v1.0.0.

OpenAI  AzureOpenAI  AWSBedrock  AWSAnthropic
Anthropic  GCPVertexAI  GCPAnthropic  Cohere
Enter fullscreen mode Exit fullscreen mode

7. Hands-on: fallback and model name virtualization

What you actually want in production is "fail over to another provider when the primary dies" and "keep the model name fixed as far as the application is concerned." Here is both.

7-1. Stand up an upstream that fails

#!/usr/bin/env python3
"""An upstream that always returns 503, for testing fallback."""
from http.server import BaseHTTPRequestHandler, HTTPServer


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        print("primary(broken) <- %s %s" % (self.command, self.path), flush=True)

    def do_POST(self):
        self.rfile.read(int(self.headers.get("content-length", 0)))
        body = b'{"error":{"message":"upstream is down"}}'
        self.send_response(503)
        self.send_header("content-type", "application/json")
        self.send_header("content-length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)


HTTPServer(("127.0.0.1", 11500), Handler).serve_forever()
Enter fullscreen mode Exit fullscreen mode

Port 11500 is the broken primary. Port 11434 from section 5 is the healthy fallback.

7-2. Write the config

Save this as aigw-config.yaml. It is exactly the same CRD YAML you would use on Kubernetes, and you could kubectl apply it as is.

apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: aigw-run
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: aigw-run
  namespace: default
spec:
  gatewayClassName: aigw-run
  listeners:
    - name: http
      protocol: HTTP
      port: 1975
---
apiVersion: aigateway.envoyproxy.io/v1beta1
kind: AIGatewayRoute
metadata:
  name: aigw-run
  namespace: default
spec:
  parentRefs:
    - name: aigw-run
      kind: Gateway
      group: gateway.networking.k8s.io
  rules:
    # the client only ever knows the invented model name "team-chat"
    - matches:
        - headers:
            - type: Exact
              name: x-ai-eg-model
              value: team-chat
      backendRefs:
        - name: primary
          priority: 0
          modelNameOverride: broken-model
        - name: secondary
          priority: 1
          modelNameOverride: mock-gpt
  llmRequestCosts:
    - metadataKey: llm_input_token
      type: InputToken
    - metadataKey: llm_output_token
      type: OutputToken
---
apiVersion: aigateway.envoyproxy.io/v1beta1
kind: AIServiceBackend
metadata:
  name: primary
  namespace: default
spec:
  schema:
    name: OpenAI
  backendRef:
    name: primary
    kind: Backend
    group: gateway.envoyproxy.io
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: Backend
metadata:
  name: primary
  namespace: default
spec:
  endpoints:
    - fqdn:
        hostname: localhost
        port: 11500
---
apiVersion: aigateway.envoyproxy.io/v1beta1
kind: AIServiceBackend
metadata:
  name: secondary
  namespace: default
spec:
  schema:
    name: OpenAI
  backendRef:
    name: secondary
    kind: Backend
    group: gateway.envoyproxy.io
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: Backend
metadata:
  name: secondary
  namespace: default
spec:
  endpoints:
    - fqdn:
        hostname: localhost
        port: 11434
---
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: fallback
  namespace: default
spec:
  targetRefs:
    # an HTTPRoute is generated with the same name as the AIGatewayRoute, so aim at that
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: aigw-run
  retry:
    numRetries: 2
    # only one attempt per priority, so it drops to the next one immediately
    numAttemptsPerPriority: 1
    perRetry:
      backOff:
        baseInterval: 100ms
        maxInterval: 1s
      timeout: 30s
    retryOn:
      httpStatusCodes:
        - 503
      triggers:
        - connect-failure
        - retriable-status-codes
Enter fullscreen mode Exit fullscreen mode

Three parts are worth reading closely.

priority is lowest-wins, so 0 is the intended target and anything from 1 up is a fallback.

modelNameOverride rewrites the team-chat the client sent into a different name right before it goes upstream. The same Claude Sonnet is called anthropic.claude-sonnet-4-20250514-v1:0 on Bedrock and claude-sonnet-4@20250514 on Vertex AI. This is the feature that keeps that difference from leaking into your application.

BackendTrafficPolicy targets not the AIGatewayRoute but the HTTPRoute generated from it, which carries the same name. Section 9 shows that generated object for real.

7-3. Run it

aigw run aigw-config.yaml
Enter fullscreen mode Exit fullscreen mode
curl -s -w '\nHTTP %{http_code} in %{time_total}s\n' \
  -H "Content-Type: application/json" -XPOST \
  http://localhost:1975/v1/chat/completions \
  -d '{"model":"team-chat","messages":[{"role":"user","content":"hi"}]}'
Enter fullscreen mode Exit fullscreen mode
{"id": "chatcmpl-mock", ..., "message": {"role": "assistant", "content": "I'll be back."}}
HTTP 200 in 0.113045s
Enter fullscreen mode Exit fullscreen mode

The client got a 200. The logs on both upstreams show what happened behind it.

=== primary(broken) ===
primary(broken) <- POST /v1/chat/completions

=== secondary ===
model='mock-gpt'
upstream <- POST /v1/chat/completions
Enter fullscreen mode Exit fullscreen mode

One attempt against the primary, a 503, then a switch to the secondary. And the model name the secondary received is mock-gpt, not team-chat, so modelNameOverride is doing its job. Of the 113ms total, 100ms is the baseInterval backoff we configured.

7-4. The metrics keep a record of the virtualization

gen_ai_client_token_usage_sum{gen_ai_original_model="team-chat",gen_ai_request_model="mock-gpt",gen_ai_response_model="mock-gpt",gen_ai_token_type="input"} 11
Enter fullscreen mode Exit fullscreen mode

gen_ai_original_model and gen_ai_request_model sit side by side as separate labels. You can trace both what the application asked for and which model actually served it. Without that, investigating the blast radius of a provider migration is hopeless.

7-5. Send a model that is not in any route

curl -s -w '\nHTTP %{http_code}\n' -XPOST http://localhost:1975/v1/chat/completions \
  -H 'content-type: application/json' -d '{"model":"nope","messages":[]}'
Enter fullscreen mode Exit fullscreen mode
No matching route found. It is likely because the model specified in your request is not configured in the Gateway.
HTTP 404
Enter fullscreen mode Exit fullscreen mode

Models are allowlisted. Anything not written into an AIGatewayRoute does not get through. That prevents someone accidentally hammering an expensive model, at the cost of needing a config change to adopt a new one. Build that into your operational process.

7-6. Where I got stuck: forgetting retriable-status-codes

My first retryOn looked like this, and fallback silently did nothing.

    retryOn:
      httpStatusCodes:
        - 503
      triggers:
        - connect-failure
        - reset
Enter fullscreen mode Exit fullscreen mode

It says httpStatusCodes: [503] right there, and yet a 503 does not trigger a retry. This is Envoy behavior: unless triggers contains retriable-status-codes, the httpStatusCodes list is never evaluated at all. httpStatusCodes defines which codes count as retriable, and "retry on retriable codes" is a separate trigger you have to switch on.

This is Envoy in general rather than anything specific to Envoy AI Gateway, but it is the easiest trap to fall into when wiring up fallback.


8. Hands-on: use it as an MCP gateway

Since v0.4, Envoy AI Gateway also works as a gateway for the Model Context Protocol. It looks like a separate topic from LLM traffic, but the shape of the problem is identical. Connect an agent to five MCP servers and you have five sets of credentials scattered around and no record of which tools got called.

MCP servers aggregated behind one endpoint

Name collisions between tools are avoided with a prefix derived from the backend name.

8-1. Try it against a public MCP server that needs no auth

You can pass config inline with --mcp-json. The format is the same mcpServers shape Claude Desktop and VS Code use.

aigw run --mcp-json '{"mcpServers":{"deepwiki":{"type":"http","url":"https://mcp.deepwiki.com/mcp"}}}'
Enter fullscreen mode Exit fullscreen mode

Pull the tool list with the MCP Inspector in CLI mode.

npx --yes @modelcontextprotocol/inspector@0.16.8 \
  --cli http://localhost:1975/mcp --method tools/list
Enter fullscreen mode Exit fullscreen mode
"name": "deepwiki__ask_question",
"name": "deepwiki__read_wiki_contents",
"name": "deepwiki__read_wiki_structure",
Enter fullscreen mode Exit fullscreen mode

Upstream, DeepWiki publishes these as ask_question and so on. Through the gateway they come back with deepwiki__ prepended. Add more backends and the names still will not collide.

Now call one.

npx --yes @modelcontextprotocol/inspector@0.16.8 \
  --cli http://localhost:1975/mcp \
  --method tools/call \
  --tool-name deepwiki__read_wiki_structure \
  --tool-arg repoName=envoyproxy/ai-gateway
Enter fullscreen mode Exit fullscreen mode
Available pages for envoyproxy/ai-gateway:
- 1 Overview
  - 1.1 Key Concepts
  - 1.2 Architecture Overview
- 2 System Architecture
...
Enter fullscreen mode Exit fullscreen mode

The prefix got stripped and the call landed on the right backend.

8-2. Narrow the tool list

--mcp-config reads the same thing from a file. Headers support environment variable interpolation with ${VAR}.

{
  "mcpServers": {
    "context7": {
      "type": "http",
      "url": "https://mcp.context7.com/mcp"
    },
    "github": {
      "type": "http",
      "url": "https://api.githubcopilot.com/mcp/readonly",
      "headers": {
        "Authorization": "Bearer ${GITHUB_ACCESS_TOKEN}"
      },
      "includeTools": ["issue_read", "list_issues"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

includeTools is the useful one. You get to decide, at the gateway, which tools an agent can even see. The GitHub MCP server has write operations too, but here only two read operations are exposed. As a defense against prompt injection, simply not showing an agent the dangerous tools has an unusually good ratio of effect to implementation cost.

On Kubernetes the same thing goes in an MCPRoute.

apiVersion: aigateway.envoyproxy.io/v1beta1
kind: MCPRoute
metadata:
  name: mcp-route
  namespace: default
spec:
  parentRefs:
    - name: aigw-run
      kind: Gateway
      group: gateway.networking.k8s.io
  path: "/mcp"
  backendRefs:
    - name: github
      kind: Backend
      group: gateway.envoyproxy.io
      path: "/mcp/x/issues/readonly"
      toolSelector:
        includeRegex:
          - .*issues?.*
      securityPolicy:
        apiKey:
          secretRef:
            name: github-token
Enter fullscreen mode Exit fullscreen mode

toolSelector takes exactly one of include or includeRegex. You cannot set both. Omit it entirely and every tool is exposed.

Going further, securityPolicy.oauth terminates the MCP spec's OAuth flow at the gateway and lets you evaluate JWT claims and scopes with CEL expressions to authorize individual tools.


9. Move it to Kubernetes

Once it makes sense locally, take it to a cluster. The YAML from section 7 carries over almost unchanged.

9-1. Prerequisites

You need Kubernetes 1.32 or newer. That is the requirement most likely to disqualify an existing cluster. Beyond that, kubectl, helm, and curl.

Locally, kind is fine.

kind create cluster --name aigw
Enter fullscreen mode Exit fullscreen mode

Install Envoy Gateway with the AI Gateway values applied.

helm upgrade -i eg oci://docker.io/envoyproxy/gateway-helm \
  --version v1.8.1 \
  --namespace envoy-gateway-system \
  --create-namespace \
  -f https://raw.githubusercontent.com/envoyproxy/ai-gateway/main/manifests/envoy-gateway-values.yaml

kubectl wait --timeout=5m -n envoy-gateway-system \
  deployment/envoy-gateway --for=condition=Available
Enter fullscreen mode Exit fullscreen mode

If Envoy Gateway is already installed, the extension server will not be enabled without these values. The project recommends reinstalling from a clean slate.

9-2. AI Gateway itself

The CRDs and the controller ship as separate charts.

helm upgrade -i aieg-crd oci://docker.io/envoyproxy/ai-gateway-crds-helm \
  --version v1.0.0 --namespace envoy-ai-gateway-system --create-namespace

helm upgrade -i aieg oci://docker.io/envoyproxy/ai-gateway-helm \
  --version v1.0.0 --namespace envoy-ai-gateway-system --create-namespace

kubectl wait --timeout=5m -n envoy-ai-gateway-system \
  deployment/ai-gateway-controller --for=condition=Available
Enter fullscreen mode Exit fullscreen mode

Splitting the CRDs into their own chart started in v1.0, so upgrading from anything older requires a transfer of ownership.

helm upgrade -i aieg-crd oci://docker.io/envoyproxy/ai-gateway-crds-helm \
  --version v1.0.0 --namespace envoy-ai-gateway-system --take-ownership
Enter fullscreen mode Exit fullscreen mode

On a kind node with 2 vCPU and 4GB, everything up to this point took a little over five minutes.

NAMESPACE                 NAME                                     READY   STATUS
envoy-ai-gateway-system   ai-gateway-controller-7d76dd5b85-vqgsf   1/1     Running
envoy-gateway-system      envoy-gateway-f97c95b-5s2q9              1/1     Running
Enter fullscreen mode Exit fullscreen mode

9-3. Get traffic flowing

The official sample works as is.

kubectl apply -f https://raw.githubusercontent.com/envoyproxy/ai-gateway/main/examples/basic/basic.yaml

kubectl wait pods --timeout=4m \
  -l gateway.envoyproxy.io/owning-gateway-name=envoy-ai-gateway-basic \
  -n envoy-gateway-system --for=condition=Ready
Enter fullscreen mode Exit fullscreen mode
export ENVOY_SERVICE=$(kubectl get svc -n envoy-gateway-system \
  --selector=gateway.envoyproxy.io/owning-gateway-namespace=default,gateway.envoyproxy.io/owning-gateway-name=envoy-ai-gateway-basic \
  -o jsonpath='{.items[0].metadata.name}')

kubectl port-forward -n envoy-gateway-system svc/$ENVOY_SERVICE 8080:80
Enter fullscreen mode Exit fullscreen mode
curl -s -H "Content-Type: application/json" \
  -d '{"model":"some-cool-self-hosted-model","messages":[{"role":"system","content":"Hi."}]}' \
  http://localhost:8080/v1/chat/completions
Enter fullscreen mode Exit fullscreen mode
{"choices": [{"index": 0,"message": {"role": "assistant","content": "I'm king of the world!"},"finish_reason": "stop"}],"usage": {"prompt_tokens": 1,"completion_tokens": 100,"total_tokens": 300}}
Enter fullscreen mode Exit fullscreen mode

Traffic flows. As a side note, the official docs show the expected response as "I'll be back.", but the v1.0.0 test upstream image actually returns "I'm king of the world!". It is a hardcoded string either way, so it does not matter.

9-4. Look at what got generated

This is the part most worth inspecting on the Kubernetes side. Applying a single AIGatewayRoute grew several resources behind your back.

kubectl get httproute,httproutefilter -A
Enter fullscreen mode Exit fullscreen mode
NAMESPACE   NAME                                                    AGE
default     httproute.../envoy-ai-gateway-basic                     5m
default     httproutefilter.../ai-eg-host-rewrite-envoy-ai-gateway-basic               5m
default     httproutefilter.../ai-eg-route-not-found-response-envoy-ai-gateway-basic   5m
Enter fullscreen mode Exit fullscreen mode

There is an HTTPRoute with the same name as the AIGatewayRoute. That is what section 7 pointed BackendTrafficPolicy at. The contents tell you more.

spec:
  rules:
  - backendRefs:
    - group: gateway.envoyproxy.io
      kind: Backend
      name: envoy-ai-gateway-basic-testupstream
      weight: 1
    filters:
    - extensionRef:
        kind: HTTPRouteFilter
        name: ai-eg-host-rewrite-envoy-ai-gateway-basic
      type: ExtensionRef
    matches:
    - headers:
      - name: x-ai-eg-model
        type: Exact
        value: some-cool-self-hosted-model
      path:
        type: PathPrefix
        value: /
    timeouts:
      request: 60s
  - filters:
    - extensionRef:
        kind: HTTPRouteFilter
        name: ai-eg-route-not-found-response-envoy-ai-gateway-basic
      type: ExtensionRef
    matches:
    - path:
        type: PathPrefix
        value: /
    name: route-not-found
Enter fullscreen mode Exit fullscreen mode

Below the rule I wrote, a catch-all rule named route-not-found has been appended automatically. That is the origin of the 404 message from section 7-5.

Because of that insertion, a single AIGatewayRoute can hold at most 15 rules. Gateway API caps HTTPRoute.spec.rules at 16, and one slot is reserved for the catch-all. Need more than that and you split the AIGatewayRoute and attach both to the same Gateway.

You can also see that the default request timeout is 60 seconds. That is short for long generations, so you will end up raising it on the AIGatewayRoute.

9-5. ExtProc arrives as a native sidecar

Look at the Gateway Pod and the container layout is slightly unusual.

kubectl get pod -n envoy-gateway-system <gateway-pod> \
  -o jsonpath='{range .spec.initContainers[*]}INIT {.name} {.restartPolicy}{"\n"}{end}'
Enter fullscreen mode Exit fullscreen mode
INIT ai-gateway-extproc Always
Enter fullscreen mode Exit fullscreen mode

ExtProc is not injected into the normal containers list. It goes into initContainers with restartPolicy: Always, making it a native sidecar. That is the Kubernetes 1.29 feature where, unlike a conventional sidecar, startup ordering is guaranteed and the container comes up before the main one. If ExtProc is not there the instant Envoy starts, every request returns 500, so this shape is required.

containers:      envoy (envoyproxy/envoy:distroless-v1.38.1)
                 shutdown-manager (envoyproxy/gateway:v1.8.1)
initContainers:  ai-gateway-extproc (envoyproxy/ai-gateway-extproc:v1.0.0)  restartPolicy: Always
Enter fullscreen mode Exit fullscreen mode

9-6. Connect to the real OpenAI

curl -O https://raw.githubusercontent.com/envoyproxy/ai-gateway/main/examples/basic/openai.yaml
Enter fullscreen mode Exit fullscreen mode

Replace apiKey: OPENAI_API_KEY with your own key and apply. Two things matter.

apiVersion: aigateway.envoyproxy.io/v1beta1
kind: BackendSecurityPolicy
metadata:
  name: envoy-ai-gateway-basic-openai-apikey
  namespace: default
spec:
  targetRefs:
    - group: aigateway.envoyproxy.io
      kind: AIServiceBackend
      name: envoy-ai-gateway-basic-openai
  type: APIKey
  apiKey:
    secretRef:
      name: envoy-ai-gateway-basic-openai-apikey
---
apiVersion: gateway.networking.k8s.io/v1alpha3
kind: BackendTLSPolicy
metadata:
  name: envoy-ai-gateway-basic-openai-tls
  namespace: default
spec:
  targetRefs:
    - group: "gateway.envoyproxy.io"
      kind: Backend
      name: envoy-ai-gateway-basic-openai
  validation:
    wellKnownCACertificates: "System"
    hostname: api.openai.com
Enter fullscreen mode Exit fullscreen mode

BackendSecurityPolicy is the Kubernetes version of the key injection from section 5. Update the Secret and it takes effect within seconds, with no Pod restart.

BackendTLSPolicy is mandatory for any external HTTPS upstream. Forget it and Envoy tries to speak plaintext to port 443 and fails.

9-7. Raise the buffer limit

basic.yaml contains one unglamorous setting you will always need in production.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
  name: client-buffer-limit
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: envoy-ai-gateway-basic
  connection:
    bufferLimit: 50Mi
Enter fullscreen mode Exit fullscreen mode

Envoy Gateway's default buffer limit is 32KiB, which is nowhere near enough for LLM requests. Base64 an image into the body or send a long conversation history and it gets rejected. The sample raises it to 50MiB. It is easy to forget, so put it in from the start.

9-8. When you do not want API keys in Secrets

BackendSecurityPolicy supports automatic short-lived credential retrieval for the major clouds.

Provider Mechanism
AWS Bedrock OIDC federated with AWS STS to issue temporary credentials
Azure OpenAI Short-lived access tokens from Entra ID
GCP Vertex AI Google STS via Workload Identity Federation
OpenAI and others API key stored in a Secret

Providers without an OIDC story, OpenAI among them, cannot escape key management. But you can at least stop distributing those keys per application. Confine them to the gateway and both the blast radius of a rotation and the number of audit points collapse to one.


10. Token-based rate limiting and quotas

Time to look at the pay-later model from section 1-2 in real YAML. This part needs Redis, so it is not something aigw run can demonstrate.

10-1. How it works

Token-based rate limiting, checked outbound and billed inbound

Two ideas carry this.

Set the request-time cost to zero. With cost.request.number: 0 the gateway checks that budget remains without spending any. The real consumption is charged after the response arrives.

Dynamic metadata is the handoff point. ExtProc writes into a namespace called io.envoy.ai_gateway, and BackendTrafficPolicy reads from it. That namespace is fixed and cannot be changed. Typo it and rate limiting quietly stops working.

10-2. Configuration

First, declare on the AIGatewayRoute what to pull out of the response and which key to store it under.

  llmRequestCosts:
    - metadataKey: llm_input_token
      type: InputToken
    - metadataKey: llm_cached_input_token
      type: CachedInputToken
    - metadataKey: llm_output_token
      type: OutputToken
    - metadataKey: llm_total_token
      type: TotalToken
Enter fullscreen mode Exit fullscreen mode

Then consume those keys from a BackendTrafficPolicy.

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: token-ratelimit
  namespace: default
spec:
  targetRefs:
    - name: envoy-ai-gateway-token-ratelimit
      kind: Gateway
      group: gateway.networking.k8s.io
  rateLimit:
    type: Global
    global:
      rules:
        - clientSelectors:
            - headers:
                # a separate budget per distinct x-tenant-id value
                - name: x-tenant-id
                  type: Distinct
          limit:
            requests: 10000
            unit: Hour
          cost:
            request:
              from: Number
              number: 0
            response:
              from: Metadata
              metadata:
                namespace: io.envoy.ai_gateway
                key: llm_input_token
Enter fullscreen mode Exit fullscreen mode

The field is named limit.requests, but because cost comes from metadata this is effectively "10,000 tokens per hour." To give input, output, and total their own budgets, repeat the same rule shape with a different key.

type: Distinct means one bucket per header value, which is how you get per-tenant or per-user limits in a single line. Swap in x-ai-eg-model if you want per-model limits instead.

10-3. Rate limiting versus quotas

There is a separate CRD called QuotaPolicy that is easy to confuse with this. The API landed in v0.6 and it only started actually enforcing anything in v0.7. It is still v1alpha1 and outside the v1.0 stability guarantee.

Usage-based rate limiting QuotaPolicy
What it controls Rate. Consumption pace per unit time Cumulative budget. Total spend over a window
Typical use Stop a runaway batch from crowding everyone out "This team gets one million tokens a month"
Where you write it BackendTrafficPolicy QuotaPolicy
What it attaches to Gateway or HTTPRoute AIServiceBackend

QuotaPolicy can hold a budget per model, and CEL lets you weight token types differently.

perModelQuotas:
  - modelName: gpt-4
    quota:
      # cached input counts as one tenth, output counts sixfold
      costExpression: "input_tokens + cached_input_tokens / 10u + output_tokens * 6u"
      defaultBucket:
        limit: 10000
        duration: "1h"
Enter fullscreen mode Exit fullscreen mode

One trap. The modelName in perModelQuotas must match the modelNameOverride on the AIGatewayRoute, or the quota is silently ignored. No error, no warning. If you set modelNameOverride: mock-gpt in section 7, write mock-gpt on the quota side too.


11. Features that matter in production

An inventory, including things this article has not touched.

Feature Why you want it What implements it
Schema translation Clients only ever speak OpenAI format Request path plus AIServiceBackend.schema
Provider fallback Automatic evacuation to another provider during an outage priority plus BackendTrafficPolicy.retry
Model name virtualization Naming differences between providers never reach the app modelNameOverride
Upstream auth Keys leave the application. Short-lived tokens on the clouds BackendSecurityPolicy
Token limits and quotas Stop runaways on both rate and cumulative spend BackendTrafficPolicy / QuotaPolicy
Observability Metrics, traces, and logs following the OTel GenAI conventions Prometheus / OTLP
MCP gateway Aggregating MCP servers, filtering tools, OAuth MCPRoute
InferencePool integration Smarter routing to self-hosted vLLM on your own GPUs Gateway API Inference Extension

The set of supported endpoints has grown a lot since v0.1.

POST /v1/chat/completions             POST /v1/embeddings
POST /v1/completions                  POST /v1/images/generations
POST /v1/responses                    POST /v1/audio/transcriptions
POST /v1/responses/input_tokens       POST /v1/audio/translations
POST /anthropic/v1/messages           POST /cohere/v2/rerank
POST /anthropic/v1/messages/count_tokens
GET  /v1/models
Enter fullscreen mode Exit fullscreen mode

Because /anthropic/v1/messages is there, an application written against the Anthropic SDK can go through the gateway unchanged. The ingress is not locked to OpenAI format.

InferencePool deserves a mention. If you own GPUs and run a fleet of vLLM replicas, plain round-robin is close to the worst possible choice. Sending a request to the node whose KV cache already holds the conversation is fast, and round-robin will happily throw it at a node that misses. The Endpoint Picker from Gateway API Inference Extension routes based on which replica currently holds the conversation. Envoy AI Gateway has supported Inference Extension v1.0 since v0.4, and an AIGatewayRoute can reference an InferencePool. If you self-host, look into this.


12. Deciding whether to adopt it

Worth it

With two or more providers, translation and fallback alone pay for the thing. If you need to attribute spend, instrumenting every application by hand instead is not realistic. And if you already run Envoy Gateway, the marginal cost is mostly the operational knowledge.

Be careful

With one provider and one application, using the SDK directly is faster. A gateway is one more hop and pure liability.

If you are not on Kubernetes, aigw run works standalone, but rate limiting and quotas assume Redis and Envoy Gateway's rate limit service. The full feature set is Kubernetes-only.

If your cluster is below 1.32, this becomes a cluster upgrade conversation first.

Know the failure modes up front

Failure mode Symptom Fix
Buffer limit left at 32KiB Long prompts or image payloads get rejected Raise bufferLimit via ClientTrafficPolicy
Missing retriable-status-codes Fallback silently does nothing Always include it in triggers
Unimplemented schema pairing 500 with unsupported API schema Check the ingress path and egress schema as a pair
modelNameOverride and QuotaPolicy.modelName disagree The quota is ignored with no error Make the names match
Typo in the dynamic metadata namespace Rate limiting quietly stops working io.envoy.ai_gateway is fixed
Default 60 second timeout Long generations get cut off midway Raise timeouts on the AIGatewayRoute
Model missing from any route 404 Fold new model adoption into your process
Gateway becomes a SPOF All LLM traffic stops Multiple replicas, PDB, regional redundancy

That last one is not something to wave away. Routing every application's LLM calls through one place means that when it goes down, everything goes down. Whether the observability and governance you get back is worth that depends on the size of your organization.

Version notes

v0.6 shipped two breaking changes. AIGatewayRoute.spec.filterConfig was removed and moved to GatewayConfig, and the version-as-prefix behavior on VersionedAPISchema was dropped in favor of prefix. Copying YAML out of older blog posts will bite you.


Wrapping up

The thing I most want to leave you with is that you can try all of this with aigw run before you ever stand up Kubernetes.

Sections 5 through 8 of this article did not even use Docker. A machine with Go and the Python standard library was enough to watch key injection, schema translation, fallback, model name virtualization, and MCP aggregation all happen for real. And because the config YAML is byte-for-byte the same CRDs Kubernetes uses, it carries straight over to a cluster.

The difference in how fast things click is substantial. Write a mock server, run aigw run against it, and go from there.

Top comments (0)