Routing & Endpoints
Everything below is the native host’s route table, and the Workers host mounts
the same one. gproxy-protocol deliberately declares no paths — which URL
serves an operation is an HTTP convention owned by the ingress layer — so this
page is that layer’s table.
The Gateway’s Own Routes
Section titled “The Gateway’s Own Routes”Four, and everything else falls through to the data plane. That is the opposite of the usual arrangement and it is deliberate: the gateway’s own routes are a short, known list, and everything else is somebody else’s API that this instance forwards. A new upstream surface must not require a new route here.
| Method | Path | What it is |
|---|---|---|
GET | /healthz | liveness and the published config revision; unauthenticated |
GET | /publications/{id} | a published body. The id is the credential, so no key is asked for |
| — | /admin/api/… | the operator surface |
| — | /portal/api/… | the end user’s own surface |
| — | everything else | the ingress fallback |
curl -s http://127.0.0.1:8787/healthz{"revision":2,"status":"ok"}/healthz reads the published snapshot and touches neither the database nor
the cache, so a load balancer polling it cannot itself become the load that
fails it.
The Order of Ingress
Section titled “The Order of Ingress”- CORS and the client address. A preflight is answered here and never forwarded — it asks this instance what it will accept, and the upstream has no opinion about that. The client address is resolved under the trusted-proxy rule before anything can log it.
- The mount — which slice of the path is this gateway’s.
- The OAuth issuer, at
{mount}/v1/oauth/…, before the data plane because these paths are this instance’s and answer in a different error envelope. - A channel’s vendor service route — Codex’s
/backend-api/…, Claude Code’s/api/…. Only on a provider or namespace mount: matching one needs a channel, and the aggregated mount names none. - The data plane, below.
- The console, with an SPA fallback, behind the configuration switch.
Anything that reaches the end is a 404.
The Data Plane
Section titled “The Data Plane”A request body is held to settings.maxRequestBodyBytes (50 MiB by default;
maxUploadBodyBytes, 512 MiB, for a file upload) before anything else happens,
authentication included.
Content generation
Section titled “Content generation”| Method | Path | Operation / dialect |
|---|---|---|
POST | /v1/responses | generate_content / openai |
POST | /v1/chat/completions | generate_content / openai_chat |
POST | /v1/messages | generate_content / claude |
POST | /v1/messages/count_tokens | count_tokens / claude |
POST | /v1beta/models/{model}:generateContent | generate_content / gemini |
POST | /v1beta/models/{model}:streamGenerateContent | stream_generate_content / gemini |
POST | /v1beta/models/{model}:countTokens | count_tokens / gemini |
POST | /v1beta/models/{model}:embedContent | create_embedding / gemini |
POST | /v1beta/models/{model}:batchEmbedContents | batch_create_embedding / gemini |
The first three become stream_generate_content when the body says
"stream": true. They are separate operations — separate rules, separate
settlement — so the flag is read at ingress rather than left for a channel to
notice. Gemini says it in the path instead, which is why that dialect has two
rows.
Models
Section titled “Models”| Method | Path | Operation / dialect |
|---|---|---|
GET | /v1/models | list_models / openai, then claude |
GET | /v1/models/{id} | get_model / openai, then claude |
GET | /v1beta/models | list_models / gemini |
GET | /v1beta/models/{id} | get_model / gemini |
Everything else
Section titled “Everything else”| Method | Path | Operation / dialect |
|---|---|---|
POST | /v1/responses/compact | compact_content / openai |
POST | /v1/embeddings | create_embedding / openai |
POST | /v1/moderations | create_moderation / openai |
POST | /v1/rerank | rerank / openai |
POST | /v1/conversations | create_conversation / openai |
POST | /v1/images/generations | create_image / openai |
POST | /v1/images/edits | edit_image / openai |
POST | /v1/audio/speech | create_speech / openai |
POST | /v1/audio/transcriptions | create_transcription / openai |
POST | /v1/audio/translations | create_translation / openai |
| Method | Path | Operation / dialect |
|---|---|---|
GET POST | /v1/files | list_files / create_file, openai then claude |
GET DELETE | /v1/files/{id} | retrieve_file / delete_file |
GET | /v1/files/{id}/content | retrieve_file_content |
GET POST | /v1beta/files | the Gemini spellings |
POST | /upload/v1beta/files | create_file / gemini |
GET DELETE | /v1beta/files/{id} | retrieve_file / delete_file, gemini |
GET | /v1beta/files/{id}:download | retrieve_file_content / gemini |
| Method | Path | Operation |
|---|---|---|
GET POST | /v1/videos | list_videos / create_video |
GET DELETE | /v1/videos/{id} | retrieve_video / delete_video |
GET | /v1/videos/{id}/content | download_video_content |
Sora-only operations — remix, edit, extend, characters — are deliberately absent. No other vendor offers them, and they come back when a second one does.
Who can reach a file or video
Section titled “Who can reach a file or video”File and video ids are the upstream’s own, and one upstream credential is usually shared by many callers, so the gateway keeps its own record of who created what. A successful upload or video creation is recorded against the caller (every key and console session of one user counts as one caller; each OAuth grant is a caller of its own) and the credential that served it.
- Retrieving, downloading, polling or deleting an id answers 404 unless that caller created it through the gateway — the same answer whether the id belongs to someone else or does not exist. The request is sent on the credential that holds the resource.
- A list shows only the caller’s own items. Paging cursors (
has_more,last_id,nextPageToken) are passed through unchanged, so a page can hold fewer items thanlimit, or none while more pages remain; keep paging until the upstream says there are no more. - Files and videos created before this check existed, or directly at the upstream, are not reachable through the gateway.
- A Gemini resumable upload (what the google-genai SDKs use by default)
works as usual: the
x-goog-upload-urlthe start answers with is a gateway URL bound to the caller, the bytes are relayed to the upstream session on the same credential, and the finished file is the caller’s. Another caller presenting that URL gets 404. Behind a proxy, setpublic_base_url(or forwardHostand a trustedX-Forwarded-Proto) so the URL names the address clients reach. - The same rule covers files referenced inside other requests: a
file_idin a chat, Responses or Claude message (including a code-interpreter container’sfile_ids), a GeminifileData.fileUrinaming a Files API file, a videoinput_reference. A file the caller did not upload answers 404 before anything is sent, and the request runs on the credential holding the files; files uploaded on two different credentials cannot be used in one request (400). Multipart forms are checked the same way (file_id,file_ids[],input_reference[file_id]fields). - On a Responses WebSocket, a new response opens its upstream connection on
the credential holding the files it references; a continuation
(
previous_response_id) stays on its account, so files uploaded elsewhere are refused with anerrorevent — start a new response to use them. - On a Realtime or Gemini Live socket, a client message naming a file the
caller does not own on the socket’s credential is not forwarded: Realtime
answers with an
errorevent (file_not_found), Gemini Live closes with code1008.
Realtime and sockets
Section titled “Realtime and sockets”| Method | Path | Operation / dialect |
|---|---|---|
POST | /v1/realtime/client_secrets | create_realtime_client_secret / openai |
POST | /v1/realtime/calls/{call_id}/hangup | hangup_realtime_call / openai |
POST | /v1/audio/voices | create_voice / openai |
POST | /v1/live/sessions | create_live_session / openai |
GET | /v1/live/sessions | connect_live / openai — upgrade, model in session.start |
GET | /v1/live/sessions/{session_id}/attach | connect_live / openai — bound sideband |
POST | /v1/live/sessions/{session_id}/hangup | hangup_live_session / openai |
POST | /v1/realtime/calls | create_realtime_call / openai |
POST | /v1/live | create_realtime_call / openai |
GET | /v1/realtime | connect_realtime / openai — upgrade |
GET | /v1/live | legacy GPT-Live/AVAS WebSocket |
GET | /v1/live/{call_id} | legacy Live sideband continuation |
GET | /v1/responses | generate_content / openai_responses_websocket |
GET | /ws/v1beta/BidiGenerateContent | connect_realtime / gemini — Gemini Live |
Standard Realtime and GPT-Live use different protocols and account entitlements. The legacy
/v1/live routes remain for compatibility. New Live routes and custom voice creation are
OpenAI API Key capabilities; Codex supports standard Realtime, client secrets and explicit
hangup. A 201 session bootstrap alone does not prove that the session can run.
Client secrets are upstream bearer credentials for direct connections. Their bodies are
never captured, and requests made directly upstream bypass gateway admission and metering.
Explicit hangup and sidebands use the creating caller’s scope and original credential.
Their bound model is recovered before admission, including current model permission checks.
Responses uses the same /v1/responses path for HTTP POST and WebSocket GET.
Its handshake authenticates the caller; each response.create then supplies
the model for permissions, routing and generation limits. Independent
stream_id lanes can run concurrently. A new response chain can choose a new
target; previous_response_id continuations retain their original target.
Idle Responses connections do not hold generation concurrency permits.
Native Responses sockets forward upstream controls. HTTP bridges to Responses,
Chat, Claude and Gemini support local context warmup, interruption, steering
successors and injected inputs between HTTP generation segments. Local warmup
does not prefill an upstream cache. store:false history lasts only for the
client connection; stored continuations retain scoped history and target
bindings. Started or uncertain generations are not replayed on another target.
POST /v1/realtime/calls is an HTTP multipart request carrying an SDP offer,
and the handshake that continues it carries no body at all. What survives
the upgrade is the call id — in the path, or in ?call_id= — which the engine
looks up to pin the socket to the credential that answered the offer. The query
is forwarded intact for exactly that reason.
A realtime session is never converted: only same-dialect passthrough, because a client that can keep talking mid-response has no equivalent in a half-duplex dialect.
Realtime is admitted before upgrading. Authentication, then the
handshake shape, then admission, then the upstream handshake; the 101 is
written last. Every refusal is therefore an HTTP answer a client can read — a
socket that is accepted and immediately closed carries no status, no body and
no code. A refused upstream handshake is relayed verbatim: a vendor’s
429 {"error":{"code":"insufficient_quota"}} is worth more than any 502 this
gateway could invent.
A realtime socket holds its concurrency lease until it closes, not until the 101
was written. A session that runs for an hour holds its slot for that hour.
Codex-only routes
Section titled “Codex-only routes”Private Codex operations require a provider mount using the Codex channel.
For a provider named codex, use POST /codex/v1/memories/trace_summarize.
These use normal operation admission, model routing and settlement; the
aggregate /v1 surface does not expose them. Standard /v1/moderations uses
its own create_moderation JSON contract.
Account services are also available under /codex/v1, including
usage/thread_usage/query_v2 (POST), usage/plan_limit_history, and daily
Token, credit, workspace, plugin and skill reports (GET). Native aliases under
/codex/api/codex/**, /codex/backend-api/**, and /codex/ps/** remain available.
Admins can read a selected account’s upstream reports with
x-gproxy-view: credential:<id>. Caller and Pool views explicitly mark missing
historical allowances and vendor-credit accounting as unavailable, with null
amounts, rather than borrowing account data or reporting fabricated zeros.
The ambiguous paths
Section titled “The ambiguous paths”/v1/models, /v1/models/{id} and /v1/files are spelled identically by
OpenAI, Claude and Gemini’s v1 surface, and the dialect decides which wire
types a conversion is asked for — so guessing it wrong is a converted body the
client cannot parse.
The tiebreak is the client’s own authentication header —
x-goog-api-key is Gemini’s, anthropic-version is Claude’s — which is
evidence the client supplied about itself rather than a default this gateway
invented. With no evidence the first row wins, and the table is ordered so that
is OpenAI.
The Mount Grammar
Section titled “The Mount Grammar”| Path | Mount | Narrows to |
|---|---|---|
/v1/messages | aggregated | nothing |
/acme/v1/messages | namespace acme | the exposed names under acme/ |
/openai-prod/v1/messages | provider openai-prod | that provider |
A namespace is the first segment of a slash-bearing exposed model name. A
provider mount names a provider’s name — the operator’s label, not the
row id, because nobody types a machine-minted id into a client’s base URL. A
namespace wins over a provider of the same name.
A prefix is only stripped when what is left is also a declared surface.
Every simplification of that rule is wrong: a provider named backend-api
would otherwise eat /backend-api/codex/responses, which is a real Codex path,
and turn a data-plane call into a mount that does not exist.
An ambiguous path therefore resolves towards the aggregated mount. When the first segment names something but the remainder is not a surface this gateway serves, the path is left whole. Losing a mount is a 404 an operator can see; taking one that was not meant is a request sent to the wrong upstream.
A mount narrows by prefixing the model name, which is the resolver’s own grammar rather than a second rule. A name that already carries the prefix is left alone.
The OAuth Issuer
Section titled “The OAuth Issuer”Relative to a mount prefix ("", /acme, /openai-prod):
GET {prefix}/v1/oauth/authorize consent handoff (302 to `/console/authorize` for a browser)POST {prefix}/v1/oauth/authorize the person's decisionPOST {prefix}/v1/oauth/token code, refresh and device grantsPOST {prefix}/v1/oauth/device/codePOST {prefix}/v1/oauth/revokeGET {prefix}/v1/.well-known/oauth-authorization-serverGET /.well-known/oauth-authorization-server{prefix}/v1 (RFC 8414 §3.1)The issuer identifier is {origin}{prefix}/v1, which is why the aggregated
mount is https://host/v1 and not https://host: one rule then produces all
three mounts. RFC 8414 requires the identifier to be exactly the one a client
fetched the document from, and only the host knows which mount a request
arrived on, so it is passed in rather than computed.
v3 served these at the root. Moving them under /v1 made the prefix rule the
same one /v1/messages follows, and the move was cheap because v3 never
implemented the discovery endpoint at all — clients were hard-coding the
address anyway.
Management Routes
Section titled “Management Routes”Every family has the same six routes, generated from one declaration so a family cannot accidentally have five of them:
GET /admin/api/{family} list (page and filters in the query)POST /admin/api/{family} createGET /admin/api/{family}/{id} getPATCH /admin/api/{family}/{id} updateDELETE /admin/api/{family}/{id} delete → 204POST /admin/api/{family}/batch batch [{"create": …}, {"update": {"id": …, "patch": …}}, {"delete": "id"}]A batch is one revision commit however many rows it names: either every item is durable or none is.
Identity families: users, api-keys, organizations, teams,
permissions, rate-limits. Organization and team memberships have their own endpoints. oauth-clients has
POST …/{id}/retire instead of a delete — the grants a client issued still
name it. In its batch a delete retires, and a batch that retires carries only
deletes: retirement is its own transaction, so it cannot share a commit with
creates or patches. A retired client_id can be
registered again; the new registration reuses the row, and the grants
retirement revoked stay revoked. The api-keys batch refuses a create,
because a mint answers with a plaintext a batch has nowhere to return. A
users batch that would leave no enabled administrator is refused as a whole.
Configuration families:
providers, credentials, models, provider-models, routes,
route-members, connection-profiles, rule-sets, rules,
provider-rule-sets, operation-rules, operation-endpoints, quotas,
price-rules, price-rates, price-tiers.
Beyond the six: settings, the credential operations (reveal, status,
refresh, quota, quota-probe, quota-reset, health-reset, limits),
models/{discover,discover/apply,test}, rule-sets/{id}/rules,
rule-sets/{id}/rule-presets/{preset},
providers/{id}/routing-defaults/reset, quotas/status,
quotas/{id}/{reset,limit-reset}, export, import, connectivity/test,
channels, tls-presets, rule-presets, default-model-catalog,
tokenizer-vocabs, tokenizer-auth, session, sessions and audit.
Management payloads and behavior have changed in v4; check older scripts against the current API. See Usage, logs, and audit for instance-wide history endpoints.
Portal Routes
Section titled “Portal Routes”POST /portal/api/login name + password → session cookie and tokenPOST /portal/api/logoutGET /portal/api/context the caller, their memberships, their featuresGET /portal/api/models every exposed name, each marked `permitted`GET /portal/api/usage their own spendGET /portal/api/quota their own budget windowsGET /portal/api/requests their recent requests, if enabledGET /portal/api/sessionsGET /portal/api/keys (+ POST, DELETE …/{id}, POST …/{id}/rotate, GET …/{id}/secret)GET /portal/api/oauth-sessions (+ DELETE …/{id})POST /portal/api/passwordGET /portal/api/oauth/device a pending device code (?userCode=), for `/console/device`POST /portal/api/oauth/device approve or deny it; a session or a management keyThe guard requires an authenticated caller and nothing more. There is no role check because there is nothing to check against: every portal operation is scoped to the caller it was built from and none of them takes a user id. A check can be forgotten; a missing parameter cannot.
login and logout sit outside the guard — the first runs before there is a
caller, and the second has to work for a session that has already expired, or a
browser is left holding a cookie it can never discard.
Resolution and the Failover Budget
Section titled “Resolution and the Failover Budget”See Models, Routes & Exposed Names for the four name forms and the ordering. The budget:
- it is the route’s own
maxAttempts, capped bysettings.maxAttempts; - it is shared across targets: each target is granted at most as many attempts as it has credentials, and never more than what is left, so a plan cannot cost more upstream calls than its budget;
- a failure moves to the next provider only if another provider could serve it — no usable credential, a dead credential, a continuation pinned elsewhere, any channel or transport error, or a 401, 403, 429 or 5xx answer;
- a spent budget, a forbidden request, a cancellation, a bad conversion and a store or cache failure stop the call where they are;
- with no target left, the last answer is returned as it stands. A 429 from the final provider is the caller’s 429, not a synthesized error.
The request body is buffered once so it can be replayed. A streaming body over
maxRequestBodyBytes stays a stream and the plan is cut to a single target:
a large upload is not worth reading into memory for the sake of failover.