Skip to content

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.

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.

MethodPathWhat it is
GET/healthzliveness 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 elsethe ingress fallback
Terminal window
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.

  1. 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.
  2. The mount — which slice of the path is this gateway’s.
  3. 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.
  4. 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.
  5. The data plane, below.
  6. The console, with an SPA fallback, behind the configuration switch.

Anything that reaches the end is a 404.

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.

MethodPathOperation / dialect
POST/v1/responsesgenerate_content / openai
POST/v1/chat/completionsgenerate_content / openai_chat
POST/v1/messagesgenerate_content / claude
POST/v1/messages/count_tokenscount_tokens / claude
POST/v1beta/models/{model}:generateContentgenerate_content / gemini
POST/v1beta/models/{model}:streamGenerateContentstream_generate_content / gemini
POST/v1beta/models/{model}:countTokenscount_tokens / gemini
POST/v1beta/models/{model}:embedContentcreate_embedding / gemini
POST/v1beta/models/{model}:batchEmbedContentsbatch_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.

MethodPathOperation / dialect
GET/v1/modelslist_models / openai, then claude
GET/v1/models/{id}get_model / openai, then claude
GET/v1beta/modelslist_models / gemini
GET/v1beta/models/{id}get_model / gemini
MethodPathOperation / dialect
POST/v1/responses/compactcompact_content / openai
POST/v1/embeddingscreate_embedding / openai
POST/v1/moderationscreate_moderation / openai
POST/v1/rerankrerank / openai
POST/v1/conversationscreate_conversation / openai
POST/v1/images/generationscreate_image / openai
POST/v1/images/editsedit_image / openai
POST/v1/audio/speechcreate_speech / openai
POST/v1/audio/transcriptionscreate_transcription / openai
POST/v1/audio/translationscreate_translation / openai
MethodPathOperation / dialect
GET POST/v1/fileslist_files / create_file, openai then claude
GET DELETE/v1/files/{id}retrieve_file / delete_file
GET/v1/files/{id}/contentretrieve_file_content
GET POST/v1beta/filesthe Gemini spellings
POST/upload/v1beta/filescreate_file / gemini
GET DELETE/v1beta/files/{id}retrieve_file / delete_file, gemini
GET/v1beta/files/{id}:downloadretrieve_file_content / gemini
MethodPathOperation
GET POST/v1/videoslist_videos / create_video
GET DELETE/v1/videos/{id}retrieve_video / delete_video
GET/v1/videos/{id}/contentdownload_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.

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 than limit, 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-url the 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, set public_base_url (or forward Host and a trusted X-Forwarded-Proto) so the URL names the address clients reach.
  • The same rule covers files referenced inside other requests: a file_id in a chat, Responses or Claude message (including a code-interpreter container’s file_ids), a Gemini fileData.fileUri naming a Files API file, a video input_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 an error event — 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 error event (file_not_found), Gemini Live closes with code 1008.
MethodPathOperation / dialect
POST/v1/realtime/client_secretscreate_realtime_client_secret / openai
POST/v1/realtime/calls/{call_id}/hanguphangup_realtime_call / openai
POST/v1/audio/voicescreate_voice / openai
POST/v1/live/sessionscreate_live_session / openai
GET/v1/live/sessionsconnect_live / openai — upgrade, model in session.start
GET/v1/live/sessions/{session_id}/attachconnect_live / openai — bound sideband
POST/v1/live/sessions/{session_id}/hanguphangup_live_session / openai
POST/v1/realtime/callscreate_realtime_call / openai
POST/v1/livecreate_realtime_call / openai
GET/v1/realtimeconnect_realtime / openai — upgrade
GET/v1/livelegacy GPT-Live/AVAS WebSocket
GET/v1/live/{call_id}legacy Live sideband continuation
GET/v1/responsesgenerate_content / openai_responses_websocket
GET/ws/v1beta/BidiGenerateContentconnect_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.

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.

/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.

PathMountNarrows to
/v1/messagesaggregatednothing
/acme/v1/messagesnamespace acmethe exposed names under acme/
/openai-prod/v1/messagesprovider openai-prodthat 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.

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 decision
POST {prefix}/v1/oauth/token code, refresh and device grants
POST {prefix}/v1/oauth/device/code
POST {prefix}/v1/oauth/revoke
GET {prefix}/v1/.well-known/oauth-authorization-server
GET /.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.

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} create
GET /admin/api/{family}/{id} get
PATCH /admin/api/{family}/{id} update
DELETE /admin/api/{family}/{id} delete → 204
POST /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.

POST /portal/api/login name + password → session cookie and token
POST /portal/api/logout
GET /portal/api/context the caller, their memberships, their features
GET /portal/api/models every exposed name, each marked `permitted`
GET /portal/api/usage their own spend
GET /portal/api/quota their own budget windows
GET /portal/api/requests their recent requests, if enabled
GET /portal/api/sessions
GET /portal/api/keys (+ POST, DELETE …/{id}, POST …/{id}/rotate, GET …/{id}/secret)
GET /portal/api/oauth-sessions (+ DELETE …/{id})
POST /portal/api/password
GET /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 key

The 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.

See Models, Routes & Exposed Names for the four name forms and the ordering. The budget:

  • it is the route’s own maxAttempts, capped by settings.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.