Architecture Reference — verified against live cluster · RHOAI 3.5

Models as a Service on OpenShift

How MaaS serves internal vLLM models and external providers behind one Envoy gateway with API-key authentication, token-based rate limiting, and body-based model routing on RHOAI 3.5

rsaijp  ·  OpenShift 4.20.32  ·  RHOAI 3.5.0  ·  RHCL 1.4.2  ·  H100 MIG 2×3g.40gb (node scaled down)
01 — The Big Picture

Request Flow

Every inference request passes the same Envoy filter chain. On RHOAI 3.5 two external processors bracket the Kuadrant policy shim: ipp-pre reads the model name out of the body before auth, so OpenAI-standard POST /v1/chat/completions works with no path prefix; ipp runs after policy and rewrites the request for the backend. Traffic never leaves Envoy for a detour — Authorino and Limitador are gRPC callouts.

openshift-ingress
kuadrant-system
swongpai-vllm
external provider
Client
Bearer sk-oai-…
{"model":"gpt-5.6-luna"}
any OpenAI SDK
Envoy Gateway
maas-default-gateway
istio-proxy · TLS :443
AWS NLB in front
openshift-ingress
ipp-pre
payload-pre-processing
ext_proc :9004 · fail-open
body.model → header
openshift-ingress
Authorino
ext_authz gRPC :50051
maas-gateway-auth · OPA
allow-map of models
kuadrant-system
Limitador
ratelimit gRPC :8081
tokens per user per
subscription window
kuadrant-system
ipp
payload-processing
ext_proc :9004 · fail-closed
rewrite · key inject · usage
openshift-ingress
HTTPRoute
header match (body-based)
or path prefix (legacy)
one route per model
swongpai-vllm
vLLM Pod
LLMInferenceService
:8000 · needs GPU
0 running today
api.openai.com
ExternalModel gpt-5.6-luna
via ExternalName svc
openai-prod · TLS origination
swongpai-vllm
internal (GPU) or external (provider) — same policy path
02 — On the Wire

Step-by-Step Request Journey

What happens, hop by hop, when a client sends POST /v1/chat/completions with {"model":"gpt-5.6-luna"} — the body-based form that any OpenAI SDK produces unmodified. The legacy path form POST /swongpai-vllm/gpt-5.6-luna/v1/chat/completions takes the same route; only the identity lookup in step 4 differs. Filter order below was read from the gateway's Envoy config_dump, not from CRs.

1
Client → AWS NLB
HTTPS 443
Client resolves swongpai-maas.apps.rosa.rsaijp.na2y.p3.openshiftapps.com (Route53 → NLB a2d5644f…elb.amazonaws.com) and sends the request with Authorization: Bearer sk-oai-…. The NLB forwards raw TCP — no TLS termination here.
2
NLB → Envoy Gateway
TLS terminate
Pod maas-default-gateway-openshift-default (an istio-proxy Envoy programmed by istiod-openshift-gateway — OpenShift 4.20's built-in Gateway API, no Service Mesh operator) terminates TLS with maas-gateway-tls, a copy of the ROSA wildcard cert (expires 2026-11-09, rotated by CronJob). The HTTP filter chain that follows is: ext_proc.ipp-pre → wasm (Kuadrant) → ext_proc.ipp → router.
3
Envoy → payload-pre-processing
gRPC ext_proc :9004
ipp-pre streams the request body (FULL_DUPLEX_STREAMED), reads .model and promotes it to a header: X-Gateway-Model-Name: gpt-5.6-luna, plus x-ipp-selected-provider: openai-prod for external models. It then clears Envoy's route cache so routing re-evaluates with the new headers. Configured failure_mode_allow: true — if it dies, body-based routing breaks but path-based requests keep flowing. This is the piece that makes step 4 possible before policy, closing RHAISTRAT-1540.
4
Envoy wasm → Authorino
gRPC ext_authz :50051
The Kuadrant shim sends a CheckRequest. On 3.5 there is one gateway-scoped AuthPolicy, maas-gateway-auth, not one per model. Authentication: keys matching ^Bearer sk-oai-.* (priority 0) are validated by calling maas-api:8443/internal/v1/api-keys/validate in redhat-ai-gateway-infra; anything else goes to Kubernetes TokenReview (priority 2) — so oc whoami -t works on every path, not just /v1/models. Authorization is three OPA/Rego checks: auth-valid, require-group-membership (derives a model identityns/name from the path if it has two segments, else the X-Gateway-Model-Name header — and looks it up in a generated allow-map; here gpt-5.6-luna → cluster-admins), and deny-client-identity-headers (rejects client-supplied x-maas-username / x-maas-group). Verdicts cached 60s. Failure → 401 / 403. A bare Access denied almost always means the identity string is not an allow-map key — a naming mismatch.
5
Authorino → Envoy
identity metadata
On success Authorino returns OK plus identity metadata: userid, groups, and selected_subscription_key — e.g. models-as-a-service/gpt-5-6-luna-subscription@swongpai-vllm/gpt-5.6-luna. The key was minted against one subscription; that string is what the rate limiter matches on in the next step.
6
Envoy wasm → Limitador
gRPC ratelimit :8081
Does auth.identity.userid still have budget? The route's TokenRateLimitPolicy maas-trlp-gpt-5.6-luna carries one limit per subscription that includes the model: 100 000 tokens / 1h for gpt-5-6-luna-subscription, 90 000 / 1s for sa-premium (/v1/models exempt). A gateway-wide gateway-default-deny (limit 0) covers any path that is not a model route or /maas-api, /v1/models, /v1/subscriptions, /v1/api-keys. Over budget → 429.
7
Envoy → payload-processing
gRPC ext_proc :9004
ipp runs after policy and is failure_mode_allow: falseif this pod is down, every gateway request fails. Its stages: maas-headers-guard (strip MaaS identity headers), stream-usage-enforcer (force stream_options.include_usage on streamed responses), model-provider-resolver, api-translation (rewrite :path to /v1/chat/completions; for Anthropic providers also the body), and apikey-injection (replace the client's Authorization with the provider key from Secret openai-api-key, label inference.llm-d.ai/ipp-managed). For an internal vLLM model these stages are no-ops except the header guard.
8
Envoy: HTTPRoute match
router
External: HTTPRoute/gpt-5.6-luna (owned by the ExternalModel) matches X-Gateway-Model-Name: gpt-5.6-luna + x-ipp-selected-provider: openai-prod on PathPrefix /, sets Host: api.openai.com, and forwards to Service/openai-prod — an ExternalName with a ServiceEntry (MESH_EXTERNAL) and a DestinationRule (TLS SIMPLE) named after the provider, shared by every model on it. Timeout 300s.
Internal: …-kserve-route (owned by the LLMInferenceService) matches /swongpai-vllm/<model>/v1/{completions,chat/completions,responses,messages} and the newer /publishers/swongpai-vllm/models/<model>/… form, URL-rewrites the prefix away, and sends to the …-kserve-workload-svc:8000 with no timeout (streaming). No InferencePool on these three models — router.scheduler is unset.
9
Response → payload-processing
ext_proc response path
ipp also processes the response (response_body_mode: FULL_DUPLEX_STREAMED): it parses the OpenAI-format body — or the SSE stream's final usage chunk — for usage.total_tokens and reports the count to Limitador, which debits the user's counter. This is how the limit is tokens, not requests. The body reaching the client is unchanged.
10
Envoy → Client
HTTPS 200
The response returns to the client. In parallel the TelemetryPolicy/maas-telemetry tries to label Envoy's Prometheus metrics with model (from the response body), user and subscription. On RHCL 1.4.2 the two auth.identity.* selectors fail to resolve (CelError::Resolve, every request) — the label is missing, the request is unaffected. Traces sample at 0.1 into Tempo.
03 — Before the First Request

Where API Keys Come From

Inference needs a key. Keys are minted by maas-api — which on 3.5 lives in its own namespace, redhat-ai-gateway-infra — scoped to a subscription, and stored in PostgreSQL. The subscription, not the key, decides which models you reach and how many tokens you get. Max lifetime 90 days; expired keys are swept every 15 minutes.

User
Must be in a group named
in MaaSSubscription.owner
(cluster-admins here)
RHOAI Dashboard
maas-ui module, or direct
POST /maas-api/v1/api-keys
with OpenShift token
redhat-ods-applications
maas-api
HTTPRoute maas-api-route
on the MaaS gateway ·
generates sk-oai-… key
redhat-ai-gateway-infra
PostgreSQL
CNPG postgres-ha-app
PG 18.4 · 3 instances (2 up)
key hash stored
maas-postgres
API Key
Returned once. Scoped to
gpt-5-6-luna-subscription 100k/1h
or sa-premium 90k/1s
04 — Topology

Control Plane by Namespace

Resources grouped by namespace, as they exist on 3.5. Two namespaces are new since 3.4: redhat-ai-gateway-infra (maas-api moved here) and ai-tenants (the AITenant that binds a tenant namespace to a Gateway).

redhat-ods-applications
redhat-ai-gateway-infra
models-as-a-service · ai-tenants
swongpai-vllm
kuadrant-system
openshift-ingress
redhat-ods-monitoring
redhat-ods-applications
  • Deploymentmaas-controller
    Reconciles Config / AITenant / MaaS CRs → maas-api, gateway AuthPolicy, TRLPs, EnvoyFilter
  • Deploymentai-gateway-operator
    New in 3.5 · AI Gateway platform operator
  • Deploymentllmisvc-controller-manager
    KServe llm-d controller · reconciles LLMInferenceService
  • Deploymentmaas-ui · model-serving-api
    Dashboard MaaS module (:8243) · serving API for the dashboard
  • Deploymentogx-k8s-operator · mcp-lifecycle-operator
    GenAI playground (OGX) and MCP lifecycle — both Managed in the DSC
  • OdhDashboardConfigodh-dashboard-config
    vLLMDeploymentOnMaaS · genAiStudio · observabilityDashboard = true
  • Secret · CronJobmaas-db-config · maas-api-key-cleanup
    3.4 leftovers — no consumer; db-secret-sync still targets this copy
redhat-ai-gateway-infra — new in 3.5
  • Deploymentmaas-api
    REST :8443 · key CRUD · validation backend for Authorino · owned by Config/default
  • Secretmaas-db-config
    migrated-from: redhat-ods-applications · postgres-ha-app-rw · sslmode=require
  • HTTPRoutemaas-api-route
    /v1/models · /v1/subscriptions · /v1/api-keys · /maas-api → maas-api:8443
  • CronJobmaas-api-key-cleanup
    */15 * * * * · purges expired keys (max 90 days)
  • Secretmaas-api-serving-cert
    service-ca TLS for :8443
models-as-a-service · ai-tenants
  • Configdefault
    Root of the 3.5 chain · usageLogging: false · limitadorScrapeInterval: 30s
  • AITenantmodels-as-a-service (ai-tenants)
    Binds tenant ns → Gateway maas-default-gateway · owned by Config
  • MaasTenantConfigdefault-tenant
    apiKeys / telemetry knobs · infraNamespace: redhat-ai-gateway-infra
  • Tenantdefault-tenant
    deprecated-by MaasTenantConfig — spec ignored, left in place
  • MaaSAuthPolicy ×2sa-premium-policy · gpt-5-6-luna-access
    cluster-admins → 3 internal models / 1 external model
  • MaaSSubscription ×2sa-premium · gpt-5-6-luna-subscription
    90k tok/1s (Degraded — GPU down) · 100k tok/1h (Active)

Intent layer. maas-controller compiles the two policies into one gateway AuthPolicy allow-map and one TokenRateLimitPolicy per model route.

swongpai-vllm
  • LLMInferenceService ×3muse-glimmer-llmisvc-nightly · qwen38-27b-018 · redhataiqwen3-coder-next-nvfp4
    1 GPU each · router gateway+route (no scheduler) · all down: no GPU node
  • ExternalProvideropenai-prod
    openai · api.openai.com · apikey Secret openai-api-key → Service/ServiceEntry/DestinationRule
  • ExternalModelgpt-5.6-luna
    inference.opendatahub.io · targetModel gpt-5.6-luna · owns HTTPRoute gpt-5.6-luna
  • MaaSModelRef ×4gpt-5.6-luna (Ready) · 3× internal (Pending)
    Registers model with MaaS · Pending until governance pairs and runtime is healthy
  • HTTPRoute ×2gpt-5.6-luna · muse-…-kserve-route
    Stopped LLMISvc render no route
  • TokenRateLimitPolicy ×2maas-trlp-gpt-5.6-luna · maas-trlp-muse-…
    Created by MaaSSubscription · targets the model HTTPRoute
  • OGXServer · MCPServerlsd-genai-playground · rh-demo-mcp-ocp
    GenAI Studio backend (replaces LlamaStackDistribution) · MCP server (not yet on the gateway)
kuadrant-system
  • Kuadrantkuadrant
    observability.enable: true · ArgoCD-owned (openshift-ai-operator-set)
  • Authorinoauthorino
    clusterWide · ext_authz gRPC :50051 · TLS via authorino-server-cert (service-ca)
  • Limitadorlimitador
    ratelimit gRPC :8081, HTTP :8080 · limits compiled from every TRLP
  • CronJobauthorino-tls-setup
    weekly · re-asserts the serving-cert annotation + Authorino TLS spec
openshift-ingress
  • Gatewaymaas-default-gateway
    HTTPS :443 · openshift-default class · AWS NLB · 3 routes · ArgoCD-owned
  • Podmaas-default-gateway-openshift-default
    istio-proxy (Envoy) — the data plane
  • Podistiod-openshift-gateway
    OpenShift-managed Istio control plane (no OSSM operator)
  • Deployment ×2payload-pre-processing · payload-processing
    ext_proc :9004 · ipp-pre (fail-open) and ipp (fail-closed) · same image
  • EnvoyFilterpayload-processing
    Config-owned · inserts both ext_proc filters around the Kuadrant wasm · priority 10
  • AuthPolicymaas-gateway-auth
    Gateway-scoped · Enforced · OPA allow-map of all models
  • AuthPolicy · TRLPmaas-default-gateway-authn · gateway-default-deny
    Both Accepted, Enforced=False (overridden) — deny-by-default backstops
  • TelemetryPolicymaas-telemetry
    model / user / subscription labels (user+subscription unresolved on RHCL 1.4.2)
  • Secretmaas-gateway-tls
    ROSA wildcard copy · CronJob tls-cert-rotator 1st & 15th
redhat-ods-monitoring
  • MonitoringStackdata-science-monitoringstack
    Prometheus ×1 + Alertmanager ×2 · 5Gi · 90d · thanos-querier
  • TempoMonolithicdata-science-tempomonolithic
    Tempo 2.10.5 · PV · 2160h retention · sampleRatio 0.1
  • OpenTelemetryCollectordata-science-collector
    0.152.1 · statefulset 2/2 + target allocator
  • Perses + 8 dashboardsdata-science-perses
    cluster · model · maas-usage · llm-d traffic / utilization / performance · tempo-traces
  • ServiceMonitorlimitador-metrics
    Config-owned · app=limitador · 30s
05 — Reference

Component Catalog

Every component in the request path and control plane, by layer — with live images, ports, and role. Pulled from the running 3.5 cluster on 2026-09-04.

Layer 1 — Edge & Gateway
ComponentPortsRoleImage
AWS NLB + Route53443 Cloud entry point. swongpai-maas.apps.… → NLB → gateway pod. Created by OpenShift when the Gateway was applied.
maas-default-gateway
(istio-proxy)
443 TLS15021 health The Envoy data plane. Terminates TLS, runs ipp-pre → Kuadrant wasm → ipp → router, matches HTTPRoutes. Everything flows through this pod. openshift-service-mesh/istio-proxyv2-rhel9
istiod-openshift-gateway150101501215014 OpenShift 4.20's built-in Gateway API control plane. Translates Gateway/HTTPRoute/EnvoyFilter into xDS. No OpenShift Service Mesh operator is installed. openshift-service-mesh/istio-pilot-rhel9
Layer 2 — Policy Enforcement (Kuadrant / RHCL 1.4.2) & Payload Processing
ComponentPortsRoleImage
payload-pre-processing
(ext_proc ipp-pre)
9004 grpc New in 3.5. Runs before policy. Extracts .model from the request body into X-Gateway-Model-Name, resolves the provider header, clears the route cache. Fail-open. Enables body-based routing. rhoai/odh-ai-gateway-payload-processing-rhel9
authorino50051 grpc5001 http External authorization for the gateway-scoped maas-gateway-auth. Validates sk-oai-* keys via maas-api, does k8s TokenReview for everything else, runs three OPA/Rego checks, caches 60s. rhcl-1/authorino-rhel9 (1.4.2)
limitador-limitador8081 grpc8080 http Rate-limit service. One token counter per userid per subscription window, compiled from every TokenRateLimitPolicy. Debited post-response by real usage. rhcl-1/limitador-rhel9 (1.4.1)
payload-processing
(ext_proc ipp)
9004 grpc Runs after policy. Request side: header guard, stream-usage enforcer, provider resolution, API translation, provider-key injection. Response side: extracts usage.total_tokens for Limitador. Fail-closed — outage stops all gateway traffic. rhoai/odh-ai-gateway-payload-processing-rhel9
Layer 3 — MaaS Control Plane
ComponentPortsRoleImage
maas-controller Operator. Bootstraps Config → AITenant → MaasTenantConfig, deploys maas-api into --infra-namespace, compiles MaaSAuthPolicy/MaaSSubscription/MaaSModelRef into the gateway AuthPolicy allow-map and per-route TRLPs, owns the payload-processing EnvoyFilter and the Limitador ServiceMonitor. rhoai/odh-maas-controller-rhel9
ai-gateway-operator New in 3.5. Platform operator for the AI Gateway layer (payload processing images, gateway integration). Runs in redhat-ods-applications alongside maas-controller. rhoai/odh-ai-gateway-operator-rhel9
maas-api
(redhat-ai-gateway-infra)
8443 https9090 metrics REST API for key lifecycle and the model catalog (/v1/models). Checks subscription owner groups before minting. Authorino's key-validation backend. Ships SSL_CERT_DIR for the service-ca — the 3.4 env patch is gone. Reached through HTTPRoute/maas-api-route on the MaaS gateway. rhoai/odh-maas-api-rhel9
postgres-ha-app (CNPG)5432 3-instance PostgreSQL 18.4 in maas-postgres; 2 healthy today (instance 2's PV is in AZ 1c, both nodes are in 1a). Reached via postgres-ha-app-rw, sslmode=require. cloudnative-pg/postgresql:18.4 · CNPG 1.30.0
maas-ui (dashboard module)8243 MaaS panel in the RHOAI dashboard — self-service keys and catalog. Separate Deployment owned by Dashboard/default-dashboard on 3.5. rhoai/odh-mod-arch-maas-rhel9
Layer 4 — Model Serving (KServe llm-d, internal) & External Providers
ComponentPortsRoleImage
llmisvc-controller-manager KServe's LLMInferenceService controller. Expands one CR into vLLM Deployment, Services, HTTPRoute (and EPP + InferencePool when router.scheduler is set — not on the current three). 3.5 adds v3-5-0-* presets incl. single/multi-node and prefill/decode templates. rhoai/odh-kserve-llmisvc-controller-rhel9
vLLM pods (×3 defined, 0 running)8000 http muse-glimmer-llmisvc-nightly (pvc, Muse-Glimmer-30B-NVFP4), qwen38-27b-018 (oci modelcar-catalog), redhataiqwen3-coder-next-nvfp4 (oci registry.redhat.io/rhai). 1 GPU each; two are annotated serving.kserve.io/stop. Need the H100 node. rhaii/vllm-cuda-rhel9 + modelcar
ExternalProvider openai-prod
+ Service / ServiceEntry / DestinationRule
→ 443 New in 3.5. inference.opendatahub.io CRD. One per upstream: ExternalName Service to api.openai.com, ServiceEntry (MESH_EXTERNAL, DNS), DestinationRule (TLS SIMPLE). Shared by every ExternalModel on that provider. — (CRD)
ExternalModel gpt-5.6-luna Object name = modelName = targetModel = upstream id — the one naming rule. Owns HTTPRoute/gpt-5.6-luna (4 rules: path-based and header-based, each with and without the provider header). Body is forwarded to OpenAI untouched. — (CRD)
OGXServer lsd-genai-playground8321 GenAI Studio backend. Replaces the 3.4 LlamaStackDistribution (DSC llamastackoperator: Removed, ogx: Managed). VLLM_TLS_VERIFY=false for intra-namespace vLLM; pgvector for RAG. rhoai/odh-ogx-core-rhel9
MCPServer rh-demo-mcp-ocp8080 /mcp OpenShift MCP server (Tech Preview) managed by mcp-gateway 0.7.1 + DSC mcplifecycleoperator. Not registered on the MaaS gateway yet. openshift-mcp-tech-preview/openshift-mcp-server-rhel9:0.4
Layer 5 — Observability (COO 1.5.2 · Tempo 0.21.0-3 · OTel 0.152.0-2)
ComponentPortsRoleImage
Prometheus (MonitoringStack)9090 Scrapes gateway / Limitador (Config-owned ServiceMonitor, 30s) / vLLM metrics with the TelemetryPolicy's labels. 5Gi PV, 90-day retention, Thanos sidecar. cluster-observability-operator/prometheus-rhel9
OpenTelemetry Collector Statefulset 2/2 + target allocator (0.152.1). Metrics and traces from RHOAI components → Prometheus / Tempo. rhosdt/opentelemetry-collector-rhel9
Tempo (monolithic) Tracing backend, Tempo 2.10.5. PV storage, 2160h retention, 0.1 sample ratio from the DSCI. rhosdt/tempo-rhel9
Perses Renders 8 dashboards in the RHOAI console, including dashboard-3-maas-usage-admin (shipped by maas-controller) and the llm-d traffic / utilization / performance set. Tech Preview. cluster-observability-operator/perses-rhel9
Alertmanager + Thanos Querier Alert routing (2 replicas) and unified cross-shard queries for the dashboard proxies. cluster-observability-operator/alertmanager-rhel9, thanos-rhel9
06 — The Brain of the Data Plane

llm-d & the Endpoint Picker

llm-d is a Kubernetes-native distributed inference framework (Red Hat, Google, CoreWeave, IBM, NVIDIA — built on vLLM and the Gateway API Inference Extension). RHOAI 3.5 ships it as the engine behind every LLMInferenceService. Its core idea: the load balancer should understand LLMs.

Provenance. This section was recorded on 2026-07-06 against redhataillama-31-8b-instruct (2 replicas, namespace swongpai-maas) when the H100 was up and the LLMInferenceService set router.scheduler. The three models defined today run router.gateway + router.route only — no EPP, no InferencePool, plain Service routing — and the GPU node is scaled down. The mechanics are unchanged on 3.5 and 3.5 adds v3-5-0-* presets for single/multi-node and prefill/decode; add spec.router.scheduler: {} to an LLMInferenceService to get the picture below.

Why Classic Load Balancing Fails for LLMs

Round-robin and least-connections assume all requests cost roughly the same and any backend can serve them equally well. Both assumptions break for LLMs:

  1. Requests are wildly unequal. A 10-token prompt and a 10,000-token prompt differ by ~1000× in prefill compute. "Number of connections" tells you nothing about actual GPU load — one pod may hold 3 trivial requests, another 1 giant one.
  2. Backends are not interchangeable — KV cache makes them stateful. When a vLLM pod processes a prompt, it stores the computed attention keys/values (KV cache) in GPU memory. A follow-up request sharing that prefix (same system prompt, same chat history) can skip recomputation only if it lands on the same pod. Blind balancing throws that away and repays full prefill cost on every hop.
  3. Generation length is unknown at admission. A request that generates 4,000 tokens occupies KV memory for its whole lifetime. Queue depth right now is a weak signal for load 30 seconds from now — you want scheduling that reads engine-level signals, not TCP-level ones.

llm-d's answer: pull the routing decision out of the proxy and give it to a model-aware sidecar service — the Endpoint Picker (EPP). Envoy still moves the bytes; the EPP decides where.

Inside a Scheduling Decision

The EPP runs as the …-kserve-router-scheduler pod (3 containers: scheduler, tokenizer, modelcar). Envoy holds each request and consults it over gRPC :9002. Here is the decision pipeline as it was configured in July:

Envoy Gateway
holds request,
asks EPP via
gRPC ext_proc :9002
EPP — Endpoint Picker (…-router-scheduler pod)
tokenizer
sidecar tokenizes prompt using the model's own tokenizer files (via modelcar symlink)
queue-scorer
weight 2 — reads per-pod queue depth from vLLM metrics; shorter = higher score
prefix-cache-scorer
weight 3 — looks up which pod's KV cache holds the longest matching token prefix
max-score-picker
weighted sum per pod → route to highest total
continuous feed — ZMQ :5557 vLLM pods publish KV-cache events (blocks created / evicted) → EPP maintains a live prefix-cache index without polling
vLLM Pod 1
queue: 4 deep
cache: prefix HIT
vLLM Pod 2
queue: 1 deep
cache: cold

Worked example — a user sends the 3rd turn of a chat session; the 2,400-token conversation prefix is cached on Pod 1, which is currently busier (illustrative scores, 0–1 normalized):

Podqueue-scorer (×2)prefix-cache-scorer (×3)Total
Pod 1 — busy, cache warm0.20 × 2 = 0.400.95 × 3 = 2.853.25
Pod 2 — idle, cache cold0.90 × 2 = 1.800.00 × 3 = 0.001.80

The EPP sends the request to the busier pod — deliberately. Reusing 2,400 cached tokens skips the entire prefill for that prefix, cutting time-to-first-token far more than a shorter queue would. That trade-off is exactly what the 3-vs-2 weighting encodes. If no pod has a cache hit, prefix scores are all zero and queue depth decides — graceful fallback to load-based balancing.

Full request lifecycle through the EPP:

  1. Envoy matches the HTTPRoute to the InferencePool and opens an ext_proc conversation with the EPP, streaming request headers + body.
  2. The tokenizer sidecar converts the prompt to token IDs — the same tokenization the model itself will use (its modelcar container symlinks the model files at /mnt/models).
  3. The prefix-cache index is consulted: longest-prefix match of those token IDs against each pod's known KV blocks (index kept current by ZMQ KV events, not polling).
  4. queue-scorer reads current queue depth per pod; both scorers emit 0–1 scores.
  5. max-score-picker computes Σ(score × weight) and picks the winner.
  6. EPP returns the chosen pod's address in a routing header; Envoy forwards the request to that exact pod — not the Service VIP.
  7. The pod runs inference; new KV blocks it creates are announced over ZMQ, updating the index — so the next turn of this conversation scores a hit and sticks to the same pod.

If the EPP itself dies: the InferencePool is configured failureMode: FailOpen — Envoy falls back to cache-blind balancing across the pool. Latency degrades; traffic survives.

EndpointPickerConfig — as deployedrecorded 2026-07 · GPU node up
apiVersion: inference.networking.x-k8s.io/v1alpha1
kind: EndpointPickerConfig
plugins:
- type: single-profile-handler
- type: queue-scorer
- type: prefix-cache-scorer
- type: max-score-picker
schedulingProfiles:
- name: default
  plugins:
  - pluginRef: queue-scorer
    weight: 2
  - pluginRef: prefix-cache-scorer
    weight: 3
  - pluginRef: max-score-picker
InferencePool — endpointPickerRef wiringrecorded 2026-07 · GPU node up
apiVersion: inference.networking.k8s.io/v1
kind: InferencePool
metadata:
  name: redhataillama-31-8b-instruct-inference-pool
  namespace: swongpai-maas
spec:
  endpointPickerRef:
    failureMode: FailOpen        # if EPP dies, fall back to normal routing
    kind: Service
    name: redhataillama-31-8b-instruct-epp-service
    port:
      number: 9002
  selector:
    matchLabels:
      app.kubernetes.io/name: redhataillama-31-8b-instruct
      kserve.io/component: workload
  targetPorts:
  - number: 8000

Beyond This Cluster — the llm-d Ladder

llm-d defines three "well-lit paths" — proven deployment profiles in increasing order of scale. This cluster runs the first. Evidence the higher rungs are already wired in: the LLMInferenceService status carries preset annotations for config-llm-prefill-template and config-llm-decode-worker-data-parallel — unused at replicas: 2, but one spec change away.

this cluster

1 · Intelligent Inference Scheduling

Identical replicas + EPP with queue & prefix-cache scorers. Lower TTFT and higher goodput than any cache-blind balancer, with no change to the model pods themselves. Fits single-node and modest multi-GPU setups.

next rung

2 · Prefill / Decode Disaggregation

Split the two phases onto different pods: prefill is compute-bound (long prompts), decode is memory-bandwidth-bound (token streaming). Specialized pods each run at their bottleneck; computed KV tensors are transferred prefill→decode over a fast interconnect. Pays off for long-context, high-QPS serving.

large scale

3 · Wide Expert Parallelism

For giant Mixture-of-Experts models (DeepSeek-R1 class): experts sharded across many GPUs/nodes with data-parallel attention, deployed as multi-node leader/worker groups. The EPP's role grows to routing across heterogeneous multi-node serving groups.

Mode 1 — This Cluster

Unified Inference (Intelligent Scheduling)

Every pod runs both prefill and decode. The EPP scores replicas by queue depth and prefix-cache state, routing each request to the optimal pod. This is the default when your LLMInferenceService has no spec.prefill and no spec.worker — just spec.replicas and spec.template.

Unified Mode — Request Flow
Client
POST /v1/chat/completions
Gateway
Envoy + Auth
+ Rate Limit
EPP Scheduler
queue-scorer ×2
prefix-cache ×3
→ pick best pod
vLLM Pod 1 · GPU Slice 0
PREFILL
DECODE
Both phases in same process
→ prefill blocks decode
vLLM Pod 2 · GPU Slice 1
PREFILL
DECODE
EPP prefers this if it has
cached the user's prefix

Core trade-off: Simplicity and resilience vs. potential prefill interference. When a pod is busy with a long prefill (processing a 10k-token prompt), its in-flight decode requests stall — every user sharing that pod feels the latency spike. For small models and moderate QPS, this rarely matters. For long-context, high-throughput workloads, it becomes the bottleneck.

ProsCons
Simplest config — just set replicasPrefill interference: long prompts stall decode on the same pod
Every pod is identical — any can serve any requestCannot optimize hardware separately for compute-heavy prefill vs. bandwidth-heavy decode
EPP prefix-cache routing already reduces redundant prefillAt high QPS, tail latency grows as prefill contention increases
FailOpen — survives EPP death gracefullyKV cache limited to local GPU memory per pod
Works on single-node / small GPU counts
spec — unified mode (historical config)recorded 2026-07 · GPU node up
apiVersion: serving.kserve.io/v1alpha2
kind: LLMInferenceService
metadata:
  name: redhataillama-31-8b-instruct
  namespace: swongpai-maas
spec:
  model:
    uri: oci://registry.redhat.io/rhelai1/modelcar-llama-3-1-8b-instruct:1.5
  replicas: 2                              # both do prefill + decode
  # no spec.prefill → unified mode
  # no spec.worker  → single-node
  template:
    containers:
    - name: main
      resources:
        limits: {nvidia.com/gpu: "1", cpu: "2", memory: "4Gi"}
  router:
    gateway:
      refs: [{name: maas-default-gateway, namespace: openshift-ingress}]
    route: {}
    scheduler: {}

Best for: Models ≤ 70B, moderate QPS (<50 req/s), short-to-medium context (<8k tokens), single GPU node, getting started fast.

Variant — no scheduler at all: omit router.scheduler from the spec and no EPP pod is deployed; requests load-balance through a plain Kubernetes Service (kube-proxy). Lowest overhead, but loses prefix-cache routing, queue awareness, and any P/D capability. Fine for dev/testing; production should keep the scheduler.

FeatureNo SchedulerDefault Scheduler (this cluster)Prefill/Decode
Routing logick8s ServiceEPP load balancingEPP + P/D separation
Prefix-cache routing
KV cache transfer✓ (NIXL/RDMA)
Resource overheadLowestLowMedium
Use caseDev / simpleProduction (basic)Production (advanced)
Mode 2 — Next Rung

Prefill / Decode Disaggregation

Adding spec.prefill tells the controller to split inference into two specialized pod pools. Prefill pods crunch the full prompt (compute-bound — all tokens processed in parallel), then transfer the computed KV tensors to a decode pod via the NIXL v2 connector (llm-d-routing-sidecar with --kv-connector=nixlv2). The decode pod skips prefill entirely and begins streaming tokens immediately.

Prefill / Decode Disaggregation — KV Dataflow (request path detailed below)
Client
Prompt in
Gateway
Auth + Rate
EPP
picks decode pod +
prefill worker
(x-prefiller header)
Prefill Pool
Prefill Pod 1
Crunches full prompt
Builds KV tensors
GPU: compute-optimized
Prefill Pod 2
Scales independently
--enable-chunked-prefill
KV Cache Transfer
NIXL v2 connector
RDMA ~100μs / TCP ~1-5ms
Decode Pool
Decode Pod 1
Receives KV tensors
Skips prefill entirely
Streams tokens out
Decode Pod 2
GPU: latency-optimized
Stable ITL, no interference
Client
Tokens stream
Actual request path (important)
The diagram above shows the KV dataflow. The HTTP request itself takes a different route: Envoy delivers it to the decode pod's routing sidecar first. The sidecar reads the EPP's prefill-worker header, forwards the request to that prefill pod (which computes the KV tensors and NIXL-transfers them to the decode pod's vLLM), then feeds the request to its local vLLM (:8001), which decodes using the received KV — token streaming starts immediately.
Inside the Decode Pod
llm-d-routing-sidecar (pd-sidecar)
:8000 proxy · orchestrates prefill call,
then local decode · --kv-connector=nixlv2
vLLM engine
:8001 · receives KV via NIXL
starts decode immediately

Core trade-off: Eliminates prefill-decode interference completely, but adds KV transfer latency and operational complexity. You now have two pools to scale, two sets of resources to right-size, and a dependency on the KV transfer channel. The decode template on your cluster already bundles the llm-d-routing-sidecar init container with NIXL v2 — it just activates when spec.prefill appears.

ProsCons
Zero prefill interference — decode latency is stable regardless of prompt lengthKV transfer adds latency (RDMA ~100μs, TCP ~1-5ms per transfer)
Each pool optimized for its bottleneck: prefill=compute, decode=memory bandwidthTwo deployments to manage, scale, and monitor
Independent scaling — scale prefill for prompt-heavy, decode for streaming-heavyNeeds RDMA/RoCE for best performance (TCP fallback exists but loses the speed advantage)
Better GPU utilization — prefill pods can use chunked-prefill (--enable-chunked-prefill)Minimum 2 GPUs (1 prefill + 1 decode) — no replica redundancy without 3+
Lower inter-token latency (ITL) at high QPSDebug surface grows: sidecar, NIXL channel, two pools of pods

Not all-or-nothing — the threshold parameter: the scheduler config decides per request whether to use the prefill pool. threshold: 0 = every request goes through P/D separation; threshold: N = only prompts with estimated tokens > N take the prefill-pool detour — short prompts are prefilled and decoded entirely on the decode pod, skipping the KV transfer cost. Routing rule: new request without cached KV → prefill pool; continuation with KV already on a decode pod → straight to that decode pod. Pool sizes are independent — the upstream sample runs 2 prefill + 1 decode replicas (prefill was the bottleneck).

RDMA prerequisites (for real KV-transfer performance): a RoCE NetworkAttachmentDefinition, an rdma/roce_gdr resource on each pod, KSERVE_INFER_ROCE=true (the decode template on this cluster already ships the RoCE auto-discovery script that sets NCCL_IB_HCA / UCX_NET_DEVICES), and VLLM_NIXL_SIDE_CHANNEL_HOST pointing at the pod IP. Note: if prefill and decode pods land on the same node, KV transfer rides NVLink/local paths instead of RDMA — pod anti-affinity forces cross-node RDMA when that's what you're benchmarking.

spec — disaggregated mode (capacity example; not deployed)example
apiVersion: serving.kserve.io/v1alpha2
kind: LLMInferenceService
metadata:
  name: redhataillama-31-8b-instruct
  namespace: swongpai-maas
spec:
  model:
    uri: oci://registry.redhat.io/rhelai1/modelcar-llama-3-1-8b-instruct:1.5

  # ── Decode pods (token generation — latency-sensitive)
  replicas: 1
  template:
    containers:
    - name: main
      args: ["--enforce-eager"]
      resources:
        limits: {nvidia.com/gpu: "1", cpu: "2", memory: "4Gi"}

  # ── Prefill pods (prompt processing — compute-heavy)
  # Presence of spec.prefill triggers disaggregated mode
  prefill:
    replicas: 1
    template:
      containers:
      - name: main
        args: ["--enable-chunked-prefill"]
        resources:
          limits: {nvidia.com/gpu: "1", cpu: "2", memory: "4Gi"}

  router:
    gateway:
      refs: [{name: maas-default-gateway, namespace: openshift-ingress}]
    route: {}
    scheduler: {}

  # Controller auto-injects from v3-4-2-kserve-config-llm-decode-template:
  #   llm-d-routing-sidecar initContainer with --kv-connector=nixlv2
  #   Separate InferencePool per pool type
Decode template — auto-injected NIXL sidecarlive cluster template
# From LLMInferenceServiceConfig v3-4-2-kserve-config-llm-decode-template
# Controller injects this into decode pods when spec.prefill is present:
initContainers:
- name: llm-d-routing-sidecar
  image: registry.redhat.io/rhoai/odh-llm-d-routing-sidecar-rhel9@sha256:18dbf82a…
  command: [/app/pd-sidecar]
  args:
  - --port=8000           # proxy port (replaces vLLM's external port)
  - --vllm-port=8001      # vLLM actual port (moved behind sidecar)
  - --kv-connector=nixlv2 # NVIDIA NIXL v2 for KV cache transfer
  - --enable-ssrf-protection=true
  - --pool-group=inference.networking.x-k8s.io
  # TLS args injected when GlobalConfig.EnableTLS is true

Best for: Models 13B+, high QPS (>50 req/s), long context (8k–128k tokens), latency-sensitive decode (chatbots, streaming), clusters with 4+ GPUs and RDMA networking.

Mode 3 — Large Scale

Wide Expert Parallelism (Multi-Node)

For massive Mixture-of-Experts (MoE) models like DeepSeek-R1 (671B, 256 experts). The model is sharded across multiple GPUs/nodes using spec.parallelism (tensor, pipeline, data, expert) and spec.worker (which creates a LeaderWorkerSet — leader + N worker pods that form one serving group). Can be combined with spec.prefill for disaggregated multi-node serving.

Wide Expert Parallelism — Multi-Node Architecture
Client
Request
Gateway
+ EPP
EPP picks a
serving group
Serving Group 1 — tensor=4
Leader Pod (Node 1)
GPU 0-3 · Experts 0-63
Coordinates all-reduce
NVLink / InfiniBand
Worker Pod 1 (Node 2)
GPU 0-3 · Experts 64-127
NVLink / InfiniBand
Worker Pod 2 (Node 3)
GPU 0-3 · Experts 128-191
NVLink / InfiniBand
Worker Pod 3 (Node 4)
GPU 0-3 · Experts 192-255
LeaderWorkerSet · 16 GPUs total
data parallel
replica
Serving Group 2 — tensor=4
Leader Pod (Node 5)
GPU 0-3 · Experts 0-63
NVLink / InfiniBand
Worker Pod 1 (Node 6)
GPU 0-3 · Experts 64-127
NVLink / InfiniBand
Worker Pod 2 (Node 7)
GPU 0-3 · Experts 128-191
NVLink / InfiniBand
Worker Pod 3 (Node 8)
GPU 0-3 · Experts 192-255
LeaderWorkerSet · 16 GPUs total
spec.parallelism
tensor: 4 — shard layers across 4 GPUs within a node
pipeline: 1 — no pipeline stages (optional for very deep models)
data: 2 — 2 data-parallel serving groups
expert: true — enable MoE expert sharding
spec.worker
Full PodSpec for worker pods in the LeaderWorkerSet.
Leader = first pod (coordinates). Workers = remainder.
All must share a high-speed interconnect (NVLink, IB, RoCE).
Combinable with P/D
Add spec.prefill with its own parallelism to get
multi-node prefill pool + multi-node decode pool.
Each pool has independent LeaderWorkerSets.

Core trade-off: The only way to serve models that don't fit on a single GPU, but it requires multi-node coordination, high-bandwidth interconnects (NVLink/NVSwitch/InfiniBand), and significant operational expertise. The EPP's role grows from "pick a pod" to "pick a serving group" across heterogeneous multi-node clusters.

ProsCons
Only way to serve models that exceed single-GPU memoryRequires NVLink/InfiniBand — bandwidth between GPUs becomes the bottleneck
Expert parallelism with data-parallel attention — efficient for MoE architecturesPod scheduling is complex: leader/worker pods must be co-located with RDMA
Can combine with P/D disaggregation for maximum throughputMinimum hardware: 8+ GPUs across multiple nodes
Horizontal scaling of truly massive models (200B+)Significant operational complexity — LeaderWorkerSet, network topology, failure domains
spec — multi-node with tensor parallelism (conceptual)example
apiVersion: serving.kserve.io/v1alpha2
kind: LLMInferenceService
metadata:
  name: deepseek-r1
spec:
  model:
    uri: oci://…/deepseek-r1-671b:<pinned-tag>

  replicas: 1
  parallelism:
    tensor: 8             # shard across 8 GPUs
    data: 1               # data-parallel replicas
    expert: true          # enable expert parallelism for MoE
  worker:                 # LeaderWorkerSet: 1 leader + N workers
    # full PodSpec — co-located worker pods
    containers:
    - name: main
      resources:
        limits: {nvidia.com/gpu: "8"}

  # Optionally combine with P/D disaggregation:
  # prefill:
  #   replicas: 1
  #   parallelism:
  #     tensor: 8
  #   template: …

Best for: MoE models (DeepSeek, Mixtral 8×22B+), dense 70B+ models that need tensor parallelism, multi-node GPU clusters with high-speed interconnects.

Can You Mix Modes?

Mixed-Mode Deployments

Yes — across models. No — within one model.

Each LLMInferenceService is an independent deployment with its own mode. On the same cluster you can run:

  • Llama 8B in unified mode (2 replicas, simple, fast cold-start) — for lightweight tasks
  • Llama 70B in disaggregated mode (prefill pool + decode pool) — for long-context, latency-sensitive workloads
  • DeepSeek-R1 in multi-node expert mode — for research/batch workloads needing the largest models

Each gets its own HTTPRoute and MaaS policies. Models with router.scheduler also get their own InferencePool and EPP; models without it use plain Service routing. They share the Gateway and MaaS control plane (maas-api, Authorino, Limitador). Subscriptions and rate limits are per-model — fully independent quota management.

What you cannot do: hybrid replica roles within a single model — e.g., "some replicas do both prefill+decode, some specialize." The CRD is binary at the pod level: spec.prefill present → separate prefill and decode pools. Absent → all unified.

But per-request hybrid exists: in a disaggregated deployment, the scheduler's threshold parameter routes only long prompts (estimated tokens > N) through the prefill pool — short prompts are handled entirely by the decode pod, skipping the KV-transfer round trip. So a P/D deployment with threshold > 0 behaves like unified mode for small requests and disaggregated mode for large ones — the closest thing to "mixed" within one model.

What Fits When the H100 Returns? (2 × 3g.40gb MIG slices)

The machinepool is at zero today. After respin and MIG relabeling, the node exposes exactly 2 GPU slots:

ConfigurationSlot 1Slot 2ModeViable?
Historical July: Llama 8B × 2 unified vLLM replica 1vLLM replica 2Unified ✓ Fit verified in July; not running today
Llama 8B disaggregated Prefill podDecode podP/D ⚠ Works, but no redundancy and no RDMA benefit (same node)
Two different models unified Model A × 1Model B × 1Mixed unified ✓ Both ≤ 40GB VRAM each
One model unified + one disaggregated Not enough slots — disaggregation needs ≥ 2 GPUs for one model ✗ Need 3+ GPUs
Any multi-node / expert mode Requires multiple nodes with high-speed interconnect ✗ Single node

Practical recommendation for this cluster: With only two slices after respin, use unified serving unless a measured workload justifies spending both slices on one disaggregated model. Two one-GPU models can coexist as independent LLMInferenceServices behind the same Gateway. Add router.scheduler only when EPP scheduling is needed.

07 — Deep Dive

How It Works

The mechanics behind the diagrams — with the actual 3.5 configuration from the live cluster, collapsible below each explanation.

Three Chains of Command

The platform chain (operator → infra): Setting kserve.modelsAsService: Managed in the DSC makes maas-controller bootstrap a Config/default, which owns an AITenant (tenant namespace ↔ Gateway), a MaasTenantConfig, the maas-api Deployment in redhat-ai-gateway-infra, its HTTPRoute, the payload-processing EnvoyFilter and a Limitador ServiceMonitor. The 3.4 Tenant is still there, annotated deprecated, and ignored.

The intent chain (humans → policy): Admins write MaaSAuthPolicy (who may reach which models), MaaSSubscription (how many tokens), MaaSModelRef (which model). maas-controller compiles all auth policies into one gateway-scoped Kuadrant AuthPolicy with an OPA allow-map, and each subscription into a TokenRateLimitPolicy on the model's HTTPRoute.

The serving chain (model → backend): Internal — an LLMInferenceService is expanded by llmisvc-controller into vLLM Deployment, Services and HTTPRoute. External — an ExternalProvider + ExternalModel pair yields an ExternalName Service, ServiceEntry, DestinationRule and HTTPRoute. Either way the chains meet only at the HTTPRoute.

Deny by Default

Three gateway-wide policies enforce a zero-trust posture; on 3.5 only the first is what actually decides:

  1. maas-gateway-authEnforced. Authenticates API keys or Kubernetes tokens, then authorizes by looking the request's model identity up in the allow-map. A model with no MaaSAuthPolicy has no key in the map → Access denied.
  2. gateway-default-deny — a TokenRateLimitPolicy with limit: 0 for any path that isn't a maas-api path. Reports Enforced=False (overridden): every model route carries its own TRLP that takes precedence. It only bites on routes with no subscription.
  3. maas-default-gateway-authn — the 3.4-era k8s TokenReview + SubjectAccessReview policy. Also Enforced=False; its job moved into maas-gateway-auth's openshift-identities rule.

Adding a model therefore adds exactly two keys to one allow-map (<name> and <ns>/<name>) and one TRLP — everything else stays shut. Deleting a model does not prune its allow-map key.

Routing — Two Shapes of Route

An external model's route matches on headers that ipp-pre injected, so the client never needs a path prefix. An internal model's route is the classic prefix-and-rewrite. Both attach to the same Gateway and both are policy targets.

HTTPRoute gpt-5.6-luna — external, 4 ruleslive cluster
parentRefs:
- {kind: Gateway, name: maas-default-gateway, namespace: openshift-ingress}
rules:
# body-based: header set by ipp-pre, provider resolved
- matches:
  - path: {type: PathPrefix, value: /}
    headers:
    - {name: X-Gateway-Model-Name,      type: Exact, value: gpt-5.6-luna}
    - {name: x-ipp-selected-provider,   type: Exact, value: openai-prod}
  filters:
  - type: RequestHeaderModifier
    requestHeaderModifier: {set: [{name: Host, value: api.openai.com}]}
  backendRefs:
  - {kind: Service, name: openai-prod, port: 443}      # ExternalName → api.openai.com
  timeouts: {request: 300s}

# path-based (legacy): /swongpai-vllm/gpt-5.6-luna, with and without the provider header
- matches:
  - path: {type: PathPrefix, value: /swongpai-vllm/gpt-5.6-luna}
    headers: [{name: x-ipp-selected-provider, type: Exact, value: openai-prod}]
  # … same filters / backend / timeout …
# + the two header-less fallbacks of each shape (4 rules total). The path is rewritten
#   to /v1/chat/completions by ipp's api-translation stage, not by the route.
HTTPRoute muse-glimmer-llmisvc-nightly-kserve-route — internal, 10 ruleslive cluster
rules:
# four OpenAI-style APIs × two path forms, each rewritten to the bare API path
- matches: [{path: {type: PathPrefix, value: /swongpai-vllm/muse-glimmer-llmisvc-nightly/v1/chat/completions}}]
  filters: [{type: URLRewrite, urlRewrite: {path: {type: ReplacePrefixMatch, replacePrefixMatch: /v1/chat/completions}}}]
  backendRefs: [{kind: Service, name: muse-glimmer-llmisvc-nightly-kserve-workload-svc, port: 8000}]
  timeouts: {request: 0s, backendRequest: 0s}          # streaming: no timeout
# identical rules for /v1/completions, /v1/responses, /v1/messages …
# and for the 3.5 catalog form:
- matches: [{path: {type: PathPrefix, value: /publishers/swongpai-vllm/models/muse-glimmer-llmisvc-nightly/v1/chat/completions}}]
  # … same rewrite / backend …
# catch-all for both prefixes → / on the workload Service
- matches: [{path: {type: PathPrefix, value: /swongpai-vllm/muse-glimmer-llmisvc-nightly}}]
  filters: [{type: URLRewrite, urlRewrite: {path: {type: ReplacePrefixMatch, replacePrefixMatch: /}}}]
  backendRefs: [{kind: Service, name: muse-glimmer-llmisvc-nightly-kserve-workload-svc, port: 8000}]

No InferencePool appears here: the three current LLMInferenceServices set router.gateway + router.route only. Section 06 describes what changes when router.scheduler is added.

Authentication & Authorization — the Full Ruleset

One AuthPolicy on the Gateway. Two credential types: API keys matching ^Bearer sk-oai-.* (priority 0) and any other bearer token via Kubernetes TokenReview (priority 2). Three authorization rules, the middle one carrying the compiled allow-map. Cache keys include the model identity, so a verdict is per user × model for 60s.

AuthPolicy maas-gateway-auth (abridged)live cluster
targetRef: {group: gateway.networking.k8s.io, kind: Gateway, name: maas-default-gateway}
defaults:
  rules:
    authentication:
      api-keys:
        plain: {selector: request.headers.authorization}
        priority: 0
        when: [{selector: request.headers.authorization, operator: matches, value: ^Bearer sk-oai-.*}]
      openshift-identities:
        kubernetesTokenReview: {audiences: [https://oidc.op1.openshiftapps.com/…]}
        priority: 2
        when: [{predicate: '!request.headers.authorization.startsWith("Bearer sk-oai-")'}]
    authorization:
      auth-valid:                       # key was validated by maas-api, or it is not a key at all
        cache: {ttl: 60}
        opa:
          rego: |
            allow { input.auth.metadata.apiKeyValidation.valid == true }
            allow { not input.auth.metadata.apiKeyValidation }
      deny-client-identity-headers:     # clients may not forge MaaS identity
        patternMatching:
          patterns:
          - {predicate: '!("x-maas-username" in request.headers)'}
          - {predicate: '!("x-maas-group" in request.headers)'}
      require-group-membership:
        cache: {ttl: 60}
        opa:
          rego: |
            model_access := {
              "gpt-5.6-luna":                                                 {"groups": ["cluster-admins"]},
              "swongpai-vllm/gpt-5.6-luna":                                   {"groups": ["cluster-admins"]},
              "swongpai-vllm/muse-glimmer-llmisvc-nightly":                   {"groups": ["cluster-admins"]},
              "publishers/swongpai-vllm/models/muse-glimmer-llmisvc-nightly": {"groups": ["cluster-admins"]},
              "swongpai-vllm/redhataiqwen3-coder-next-nvfp4":                 {"groups": ["cluster-admins"]},
            }
            path_parts := [p | p := split(request_path, "/")[_]; p != ""]
            path_model_identity := sprintf("%s/%s", [path_parts[0], path_parts[1]]) {
              count(path_parts) >= 2; path_parts[0] != "v1"; path_parts[0] != "maas-api"
            }
            header_model_identity := object.get(request_headers, "x-gateway-model-name", "")
            model_identity := path_model_identity else header_model_identity
            # allow when the caller's groups intersect model_access[model_identity].groups
TokenRateLimitPolicy maas-trlp-gpt-5.6-luna — two subscriptions, two limitslive cluster
targetRef: {group: gateway.networking.k8s.io, kind: HTTPRoute, name: gpt-5.6-luna}
limits:
  models-as-a-service-gpt-5-6-luna-subscription-gpt-5.6-luna-tokens:
    counters: [{expression: auth.identity.userid}]
    rates:    [{limit: 100000, window: 1h}]
    when:
    - predicate: auth.identity.selected_subscription_key ==
        "models-as-a-service/gpt-5-6-luna-subscription@swongpai-vllm/gpt-5.6-luna"
        && !request.path.endsWith("/v1/models")
  models-as-a-service-sa-premium-gpt-5.6-luna-tokens:
    counters: [{expression: auth.identity.userid}]
    rates:    [{limit: 90000, window: 1s}]              # mint external keys against the other subscription
    when:
    - predicate: auth.identity.selected_subscription_key ==
        "models-as-a-service/sa-premium@swongpai-vllm/gpt-5.6-luna"
        && !request.path.endsWith("/v1/models")
Gateway — listener & TLSlive cluster
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: maas-default-gateway
  namespace: openshift-ingress
  annotations:
    security.opendatahub.io/authorino-tls-bootstrap: "true"
    argocd.argoproj.io/tracking-id: openshift-ai-maas-config:…   # owned by Surote/ocp-configuration
spec:
  gatewayClassName: openshift-default
  listeners:
  - name: https
    port: 443
    protocol: HTTPS
    hostname: swongpai-maas.apps.rosa.rsaijp.na2y.p3.openshiftapps.com
    tls:
      mode: Terminate
      certificateRefs: [{kind: Secret, name: maas-gateway-tls}]
    allowedRoutes:
      namespaces: {from: All}
# status: Programmed=True · attachedRoutes: 3
# address: a2d5644ffe0094200ac4bb0f2ec8f0b2-…elb.amazonaws.com (AWS NLB)
EnvoyFilter payload-processing — how the two ext_proc filters landlive cluster
# owned by Config/default · priority: 10
configPatches:
- applyTo: HTTP_FILTER
  match: {listener: {filterChain: {filter: {name: envoy.filters.network.http_connection_manager,
          subFilter: {name: extensions.istio.io/wasmplugin/openshift-ingress.kuadrant-maas-default-gateway}}}}}
  patch:
    operation: INSERT_BEFORE
    value:
      name: envoy.filters.http.ext_proc.ipp-pre
      typed_config:
        failure_mode_allow: true
        grpc_service: {envoy_grpc: {cluster_name: outbound|9004||payload-pre-processing.openshift-ingress.svc.cluster.local}}
        processing_mode: {request_header_mode: SEND, request_body_mode: FULL_DUPLEX_STREAMED,
                          response_header_mode: SKIP, response_body_mode: NONE}
- applyTo: HTTP_FILTER
  match: {… same anchor …}
  patch:
    operation: INSERT_AFTER
    value:
      name: envoy.filters.http.ext_proc.ipp
      typed_config:
        failure_mode_allow: false
        grpc_service: {envoy_grpc: {cluster_name: outbound|9004||payload-processing.openshift-ingress.svc.cluster.local}}
        processing_mode: {request_body_mode: FULL_DUPLEX_STREAMED, response_body_mode: FULL_DUPLEX_STREAMED, …}
# + duplicate patches anchored on envoy.filters.http.wasm (covers both anchor spellings — the 3.4 skew fix)
# + HTTP_ROUTE MERGE patches disabling both filters on the maas-api routes
#
# Verify it took (Istio drops a non-matching patch silently):
#   oc exec -n openshift-ingress <gateway-pod> -- pilot-agent request GET config_dump
#   → ext_proc.ipp-pre, wasm, ext_proc.ipp, router

GPU Layout — NVIDIA H100 MIG (node scaled down)

The H100 machinepool is at zero right now to save cost; only two c5.4xlarge workers remain. What persists is the plan: the GPU Operator (26.3.3, mig.strategy: single) and NFD stay installed, and on respin the node gets nvidia.com/mig.config=all-3g.40gb — two isolated 3g.40gb slices that surface as two plain nvidia.com/gpu. Each LLMInferenceService asks for one. The label is per node and must be re-applied after any machinepool replacement.

NVIDIA H100 80GB HBM3 — mig.config=all-3g.40gb — machinepool scaled to 0 (2026-09-04)
Slice 0 — 3g.40gb
candidate: muse-glimmer-llmisvc-nightly
Muse-Glimmer-30B-NVFP4 · pvc
Slice 1 — 3g.40gb
candidate: redhataiqwen3-coder-next-nvfp4
or qwen38-27b-018 · oci modelcar

Three 1-GPU models are defined for two slices — on respin, two run and one stays serving.kserve.io/stop: "true". Until then every LLMInferenceService is Stopped / MinimumReplicasUnavailable, their MaaSModelRefs Pending, and sa-premium reports Degraded. None of that is a fault.

What Breaks If a Component Dies

Component downEffect on traffic
Envoy gateway podTotal outage for the MaaS hostname until the Deployment reschedules. NLB health check (:15021) stops routing immediately.
payload-pre-processing (ipp-pre)failure_mode_allow: true — requests continue, but no X-Gateway-Model-Name is set. Body-based calls 404 (no route match); path-based calls keep working.
AuthorinoAll requests fail auth → 5xx from the wasm shim. Nothing reaches models. The 60s verdict cache does not help new connections.
LimitadorRate-limit checks fail; requests may pass unlimited or be denied depending on shim failure mode. Token accounting stops.
payload-processing (ipp)All gateway traffic failsfailure_mode_allow: false and on 3.5 the filter is genuinely in the chain (it was not on 3.4.x). Deliberate: no silent loss of token accounting or provider-key injection. This is the live single point of failure to watch.
api.openai.com / provider keyExternal model only. Bad or missing Secret → 500 credentials not found; upstream outage → 5xx passed through. Internal models unaffected.
One vLLM podIts route's Service loses the endpoint; with one replica the model is down until rescheduled. (With an EPP, traffic would fail over across replicas.)
maas-apiNo new keys, no key validation → API-key auth fails as caches expire. Kubernetes-token access keeps working (TokenReview does not touch maas-api).
PostgreSQL (all instances)maas-api loses state — key ops fail. Today 2 of 3 CNPG instances are up (AZ pinning); primary + one replica is still HA.
maas-controller / llmisvc-controllerData plane unaffected. CR changes stop reconciling — existing allow-map, TRLPs and pods keep running as-is.
Prometheus / Tempo / PersesZero traffic impact. Dashboards and usage attribution go blind until restored.