Model Gateway, Routing & Data Residency
Route all AI traffic through one provider-neutral gateway with ACL-enforced model access, quotas, latency SLOs, private endpoints, and declared residency requirements.
On this page
TL;DR: Configured clients send AI traffic through one provider-neutral
gateway, on /v1/messages (Anthropic Messages), /v1/chat/completions
(OpenAI Chat) and /v1/responses (OpenAI Responses). Routes map a requested
model pattern onto a provider. Claude is served from Google Vertex AI and
fails over to Anthropic's own API, and every request records which provider
actually answered. A model that no route or catalog entry names is refused.
Every route is currently open to every signed-in role. The residency and
no-retain requirements are a working mechanism, but no route declares one
today.
One gateway, any provider
The gateway accepts three client wire formats and sends to each provider in that provider's own format:
| Endpoint | Client format | Typical client |
|---|---|---|
POST /v1/messages |
Anthropic Messages | Claude Code, the Anthropic SDKs |
POST /v1/chat/completions |
OpenAI Chat Completions | OpenCode, OpenAI-compatible tools |
POST /v1/responses |
OpenAI Responses | Responses-API clients |
The providers this instance declares speak three upstream formats:
anthropic (Anthropic and Claude on Vertex), gemini (Gemini by API key and
on Vertex) and openai-chat (Vertex Model-as-a-Service). A route matches on
model pattern, and first match wins. A route can rewrite the model with
upstream_model, so the backend can change without any change to the
client. Request and wire details are in the Gateway API.
What this instance serves
The catalog (services/ai/providers.yaml) and the routes
(services/ai/gateway.yaml) ship inside the image, so every environment
boots the same set.
| Requested id | Provider | Models |
|---|---|---|
claude-* |
vertex-anthropic (Vertex AI, global), failing over to anthropic |
Opus 5.5, Opus 5, Sonnet 5, Fable 5.1, Fable 5, Haiku 4.5 |
anthropic-claude-* |
anthropic (Anthropic's API) |
the same Claude models, hidden from every model list |
vertex-gemini-* |
vertex (Vertex AI, global) |
Gemini 3.8 Flash, 3.5 Flash, 3.5 Flash-Lite |
gemini-* |
gemini (public API, by key) |
Gemini 3.8 Flash; also used by the classifier job |
qwen.*, zai.*, moonshotai.*, deepseek.*, minimax.*, openai.gpt-oss-* |
vertex-maas (Vertex Model-as-a-Service) |
the open models |
deepseek.r1-* |
vertex-maas-us-central1 |
DeepSeek R1, served only from us-central1 |
The Claude models, at Vertex's global rates:
| Model | Input / output per million | Context | Max output |
|---|---|---|---|
claude-opus-5-5 |
$4.00 / $20.00 | 1M | 128k |
claude-opus-5 |
$5.00 / $25.00 | 1M | 128k |
claude-sonnet-5 |
$2.00 / $10.00 | 1M | 128k |
claude-fable-5-1 |
$10.00 / $50.00 | 1M | 128k |
claude-fable-5 |
$10.00 / $50.00 | 1M | 128k |
claude-haiku-4-5 |
$1.00 / $5.00 | 200k | 64k |
gateway.default_model is claude-sonnet-5[1m]. The [1m] suffix is Claude
Code's 1M-context marker: the bridge seeds it into Claude Code, and the
gateway strips it before routing, so the request reaches Vertex as
claude-sonnet-5. Without the marker, Claude Code budgets 200k for every
gateway model. claude-haiku-4-5 also answers to its dated id,
claude-haiku-4-5-20251001. The Claude 4.x Opus and Sonnet models were
removed on 2026-09-24. With allow_unlisted_models: false, a request for a
retired id is refused rather than forwarded.
Retirement dates
| Date | What stops |
|---|---|
| 2026-10-15 | Vertex guarantees claude-haiku-4-5 only until this date |
| 2026-10-21 | Google turns off every Qwen, Kimi, DeepSeek and MiniMax Model-as-a-Service endpoint, plus zai.glm-5, zai.glm-4.7 and openai.gpt-oss-20b. After this date, core hides the retired entries from every model list |
openai.gpt-oss-120b and zai.glm-5.2 are not retiring. zai.glm-5.2 is a
preview, guaranteed until at least 2026-10-08.
Claude on Vertex AI
The vertex-anthropic provider uses the same Google service account (the
vertex secret) as every other Vertex provider:
providers:
- name: vertex-anthropic
wire: anthropic
surface: anthropic
endpoint: https://aiplatform.googleapis.com/v1/projects/{project}/locations/global/publishers/anthropic
api_key_secret: vertex
Clients call it exactly as they would call Anthropic. The gateway sends the Anthropic Messages format unchanged; only the envelope around it differs:
- The gateway mints an OAuth token from the service account and fills
{project}from its key. - Requests go to the model's own
:rawPredictor:streamRawPredictpath. - The API version travels in the body as
anthropic_version, not in a header. - Only the
anthropic-betaflags listed in the provider'saccepted_betasare forwarded, and a body field a dropped flag gates is removed with it (context-management-*gatescontext_management), so Vertex never sees the field without its flag. A flag or field Vertex still refuses is learned at runtime and dropped, and the request is re-sent once. Clients need no setting for this:CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETASis not required, and setting it turns those betas off on every route, including the ones that serve them.
Whether a provider is hosted on Vertex is read from the endpoint host, so the catalog cannot claim one platform while pointing at another.
Provider failover
A route may list fallbacks: deployments tried in order after the
primary, each a provider and optionally an upstream_model when that
deployment's model id differs. The shipped claude-* route fails over from
Vertex AI's global endpoint to a second Vertex deployment,
vertex-anthropic-eu, on the europe-west1 regional endpoint under the same
vertex service account. Traffic never leaves Vertex:
gateway:
routes:
- id: claude-star-4203d1
name: Claude (Vertex AI)
model_pattern: claude-*
provider: vertex-anthropic
fallbacks:
- provider: vertex-anthropic-eu
The older fallback_provider / fallback_upstream_model keys are refused at
boot; move them into a one-entry fallbacks list.
Anthropic's API is in no failover chain. A provider serves a route's
traffic only when the route (or one of its chains) names it, and no claude-*
chain names anthropic. The regional deployment serves each Claude model under
a hidden vertex-eu-claude-* alias, priced at Vertex's 10% regional premium.
The project's Claude quota in europe-west1 must be requested from Google in
advance, or the fallback answers 429 too. The separate anthropic-claude-* and
claude-native-tools routes name Anthropic as their primary and are unaffected.
Per-project chains. by_scope gives requests attributed to a value in
one scope dimension their own chain, for example a project's own Vertex
project or region. A value the route does not list is refused unless
unmapped: shared sends it down the route's own chain:
by_scope:
dimension: project
unmapped: shared
chains:
storefront:
provider: vertex-anthropic
fallbacks:
- provider: vertex-anthropic-eu
The project is the one the gateway attributed the request to: the
x-systemprompt-scope-project header, the API key's bound project, or the
person's primary project. The audit row's route match records
scope:project=<id>.
When it triggers. The primary keeps its bounded retry budget for transient answers (429, 503). Failover happens when that budget is spent, when the primary answers any other 5xx, or when the connection fails (DNS, TLS, timeout). The same governed request is then re-sent to the next deployment in the chain, under that deployment's own retry budget, and so on down the list. A 4xx other than 429 never fails over, because the fault is in the request, not the provider. A per-provider circuit breaker skips a primary that keeps failing and tries the fallback first; when every deployment is tripped, all are tried anyway, so a request is never left unsent.
What is re-sent. The request that governance and the safety scanners already judged is rebuilt in the fallback's wire format. Neither plane runs again, because the content has not changed.
What the audit shows. The ai_requests row keeps provider as the
route's primary and records the provider that answered in
served_provider. The request is repriced at that provider's rate, and
the route-match descriptor gains a failover:<primary>-><fallback> segment.
systemprompt infra logs audit <request-id> prints both.
route_match contains |
Meaning |
|---|---|
failover:vertex-anthropic->vertex-anthropic-eu |
the primary failed and the next deployment answered (a->b->c for a longer chain) |
scope:project=<id> |
the request was attributed to that project and served by its by_scope chain (or the shared chain under unmapped: shared) |
| The Prometheus | |
counter gateway_upstream_failovers_total{from,to,reason} (reason is |
|
status_<code>, transport or circuit_open) is on /metrics. |
What boot checks. The fallback must exist, differ from the primary, serve
a priced model, and satisfy the route's requires: block. If a fallback
cannot be used at request time, the client gets the primary's own error, as
it would with no fallback declared.
Selection strategy
A route, and each by_scope chain, can set strategy to choose which
deployment a request tries first. Failover after that first attempt works
as described above.
strategy |
First attempt |
|---|---|
ordered (default) |
The chain order: provider, then fallbacks. |
weighted |
Drawn by each deployment's weight (default 1) among deployments whose circuit breaker is closed. The rest follow in descending weight. |
least_busy |
The healthy deployment with the fewest requests in flight on this server process. |
weight sits on the route for the primary and on each fallbacks entry.
weight: 0 is refused at load. A weight under ordered is ignored. A
non-ordered strategy adds strategy:<name> to the request's route_match,
and gateway_deployment_selected_total{route,provider,strategy} counts every
selection. This instance's routes keep the default ordered.
Context-window check
Before any upstream call, the gateway estimates the request's input tokens
from its text and compares the estimate with the selected model's
context_window in the provider catalog. Images and tool inputs are not
counted, so the estimate errs low and never refuses a request that fits.
- It fits. The request is sent as normal. Failover deployments whose declared window is too small are skipped.
- It does not fit, and the route lists
context_fallbacks. The request goes to the first context fallback whose window fits.route_matchrecordscontext_window:a->b. - It does not fit, and no fallback fits. The request is refused with a
400 in the caller's API format, with
error_key: context_window_exceedednaming the estimate, the limit and the model. Nothing is sent upstream, so nothing is billed.
A model with no declared window is never refused. An oversized max_tokens
is clamped to the model's output ceiling rather than refused.
Model access control
Model access is an access-control entity, gateway_route/*, declared in
services/access-control/rules.yaml and administered at /admin/access-control.
The mechanism grants or denies routes per role, group or project, and a
request for a route the caller is not entitled to gets HTTP 403
(authz denied [<policy>]: <reason>), and the denial is audited.
As configured, every route is open to every signed-in role. Usage is
shaped by governance and quota windows, not by entitlement. To restrict a
model, declare a rule for its route with an allow band and a reason. Route
ids are generated (slug + fnv1a6), so the console names each route by the
name: its declaration carries.
/admin/gateway has four tabs: Overview (dispatch order and provider
health), Providers (each upstream with its models and routes),
Routes (the ordered dispatch table) and Settings. Policies is a
fifth tab that opens its own page. The route editor there writes back to
gateway.yaml, and a saved route is dispatched after the next restart.
Shadow-AI posture
The gateway runs with allow_unlisted_models: false, so a model that is
not configured cannot be called by anyone. Traffic that never reaches the
gateway, such as someone calling a provider directly from a laptop, is a
network-control matter for corporate IT. The usual control is an egress
allow-list that lets only the gateway reach provider APIs.
Latency SLOs
The analytics Overview tab reports p50 and p95 latency and the share
of requests over an SLO threshold, per model. The threshold is a view setting
(?slo_ms=, 5 s by default, clamped to 500 ms–60 s) and is not stored
anywhere. There is no SLO alerting. Per-use-case SLO targets and error-budget
alerts are on the roadmap.
Quota windows
Quota windows are warning thresholds on this instance (quota_mode: warn).
They record breaches and never refuse. See
Cost Management and
Governance Modes.
Private and self-hosted endpoints
A private endpoint that speaks the OpenAI or Anthropic format is a
configuration entry, not a code change. A provider declared with the
backend surface serves routes without appearing in any model list, and
every route is validated at boot. None is configured on this instance.
Data classification & residency
Each provider, and optionally each model, carries a governance: block
with two booleans: european (data stays in the EU) and no_retain (the
provider contractually does not retain prompts or completions). A route
states what it requires with a requires: block. The following is an
illustration, not the shipped configuration:
gateway:
routes:
- model_pattern: claude-*
provider: vertex-anthropic
requires:
no_retain: true
Enforcement happens twice:
- At boot: the server refuses to start a route whose provider or model
does not satisfy its
requires:block. - At dispatch: a request that would reach a provider that does not satisfy the block is denied and audited, with the failed condition named in the route-match descriptor.
What is declared today:
- Every provider is
no_retain: trueandeuropean: false. - Every Vertex endpoint uses the
globallocation (orus-central1for DeepSeek R1). - No route declares
requires:, so neither flag is enforced at dispatch, and there is no EU route.
To enforce no-retain, add requires: {no_retain: true} to a route. An EU
guarantee needs an EU-located provider entry first. The flags are
declarations, so keep them in step with the provider agreements; they do not
prove physical residency on their own. Classifying the data itself, rather
than the route, is on the roadmap.
Check it yourself
/admin/gateway?tab=routes: the dispatch order, with each route's provider and fallback./admin/gateway?tab=providers: each provider's endpoint, wire and models./admin/analytics: the Overview tab's latency and SLO breach share, and the Models tab./admin/requests: open a request to seeproviderandserved_provider.systemprompt infra logs request list --since 1hsystemprompt admin config catalog discoveryshows what the last Vertex discovery pass found.