Calling the gateway
OpenAI-compatible inference API: paths, listeners, tenant keys, streaming, limits, errors and troubleshooting.
The gateway takes an OpenAI-compatible request, reads model from the body, and forwards it to a cluster that serves that model. It is a separate process (hangar-gateway) with its own address, not part of the control plane API. For how it works inside, see Data plane.
Supported paths
Only these paths are forwarded. All are POST except GET /v1/models, which the gateway answers itself.
| Path | What |
|---|---|
/v1/chat/completions | Chat, streaming or not. Also the path for image models that return the image as an image_url part |
/v1/completions | Text completion |
/v1/responses | Responses API |
/v1/messages | Anthropic Messages API |
/v1/embeddings | Embeddings |
/v1/images/generations, /v1/images/edits | Image generation and editing |
/v1/videos | Video generation. Synchronous: the response is the finished video |
/v1/videos/stream | Experimental streaming video generation |
/v1/audio/transcriptions, /v1/audio/translations, /v1/audio/speech | Audio |
GET /v1/models | The models the caller can reach |
Any other path or method is 404 unsupported_path, answered before the body is read. That includes engine routes such as /tokenize, /health and /metrics. There is no job store for video, so GET, DELETE and listing under /v1/videos are also 404 unsupported_path.
Output modality
Each endpoint generates text, image or video, and the gateway refuses paths the endpoint cannot answer with 404 unsupported_path.
| Modality | Accepted paths |
|---|---|
text | Everything except /v1/images/* and /v1/videos* |
image | /v1/images/* and /v1/chat/completions |
video | /v1/videos* |
GET /v1/models returns the OpenAI list shape with an extra output_modality field on each model:
{
"object": "list",
"data": [
{ "id": "llama-3-8b", "object": "model", "owned_by": "system", "output_modality": "text" }
]
}
id is the endpoint’s public name. It is the value to send as model. The gateway rewrites it to the name the engine expects before forwarding. owned_by is always system.
Listeners and authentication
The gateway runs two listeners over one routing table. Which one accepted the connection decides how you authenticate and which models you can reach. See Tenants and projects for the ingress and auth settings.
| Internal listener | Public listener | |
|---|---|---|
| Address | :8080 by default | HANGAR_GATEWAY_PUBLIC_LISTEN; off when unset |
| Credential | None. Reaching the port is the proof | Authorization: Bearer <tenant key> when the tenant has auth: api_key |
| Tenant | Owner of the endpoint named by model | The tenant whose subdomain is in the Host header |
| Reaches | Every enabled tenant’s endpoints, private ones included | Only that tenant’s endpoints with visibility: public, and only if its ingress is public |
The internal listener authenticates nobody. Without a NetworkPolicy, any pod that can reach it can call every tenant’s models. See Networking and Security.
On the public listener:
- A model that belongs to another tenant, or is private, reads as
404 model_not_found, never403. - A tenant with
auth: noneis refused with401 unauthorizedunless the gateway runs withHANGAR_GATEWAY_PUBLIC_ALLOW_NO_AUTH=true. - A
Hostthat matches no public tenant is404 unknown_host. A tenant created moments ago can get this until the gateway’s next resync (30 seconds by default).
A disabled tenant gets 403 tenant_disabled on both listeners.
Tenant keys
A tenant key (hgrk1...) belongs to one tenant and is minted under Manage tenants. Send it as a bearer token:
Authorization: Bearer hgrk1...
The gateway does not read X-Api-Key. It strips that header, along with Authorization, Cookie and Proxy-Authorization, before forwarding, so no cluster sees your key. A missing, unknown, revoked or other-tenant key is 401 unauthorized. Keys are not accepted by the control plane API; see Authentication.
Make a request
The public listener sits behind a wildcard host, one subdomain per tenant. The examples use acme.gateway.example.com for the tenant acme.
On the internal listener, drop the key and call the gateway address directly:
curl http://gateway.internal:8080/v1/embeddings \
-H "Content-Type: application/json" \
-d '{"model": "text-embedding-ada-002", "input": "The quick brown fox"}'
Request and response bodies are the OpenAI formats. The gateway returns the engine’s response unchanged, except for errors.
Streaming
Set "stream": true and read server-sent events. The stream ends with data: [DONE]. The gateway forwards bytes as they arrive and does not buffer or parse the stream.
A stream is never cut for being long, only if you stop reading: one blocked write to you is limited by HANGAR_GATEWAY_WRITE_IDLE_TIMEOUT (default 2 minutes).
Other request rules
stopaccepts at most four sequences on/v1/chat/completionsand/v1/completions, otherwise400 too_many_stop_sequences.- Routing hints in the body (such as
nvextworker pins) and steering headers (x-dynamo-*,x-worker-instance-id,x-tenant-idand similar) are removed before forwarding. You cannot pick a worker. - Image and video responses carry no token usage. The gateway records output counts and size instead.
Limits
Request limits
| Limit | Variable | Default | Result |
|---|---|---|---|
| Request body size | HANGAR_GATEWAY_MAX_BODY_BYTES | 100 MiB | 413 request_too_large |
| Request-body memory held across the replica | HANGAR_GATEWAY_MAX_BUFFERED_BYTES | 1000 MiB | 503 no_capacity |
| Time to send headers | HANGAR_GATEWAY_READ_HEADER_TIMEOUT | 10s | connection closed |
| Time to send the body | HANGAR_GATEWAY_BODY_READ_TIMEOUT | 2m | 408 request_timeout |
All variables are documented in Gateway configuration.
Rate limits
Limits apply per tenant, not per key, and per gateway replica. The fleet-wide figure is the per-replica value times the replica count.
| Limit | Variable | Default |
|---|---|---|
| Requests in flight | HANGAR_GATEWAY_TENANT_MAX_IN_FLIGHT | 1024; 0 for no cap |
| Request rate and burst | HANGAR_GATEWAY_TENANT_RPS, HANGAR_GATEWAY_TENANT_BURST | unset, no rate limit |
A tenant over either limit gets 429 rate_limited with a Retry-After header. On the public listener the check runs before the body is read. On the internal listener it runs after the model is resolved, because that is when the tenant is known.
These are protective limits. The serving quota an endpoint declares is not enforced by the gateway yet.
Video input
Video input is bounded in decoded bytes, not frames, so a short high-resolution clip and a long low-resolution one are held to the same ceiling. Where each bound is enforced:
- Gateway: a
media_io_kwargsfield in the request is silently dropped, so a caller cannot override the sampling rate set on the workload. Adata:URL clip is bounded only by the body size limit above. - Workload:
serving.max_videos_per_requestcaps videos per request, and the bounds assume 1. A request over the cap is400. - Worker: the fetch size of a remote clip and its decoded size are bounded in the worker’s video loader, which refuses with
400naming the clip’s duration and resolution. The gateway cannot see inside a remote URL, so it does not enforce these.
See Decisions for the reasoning.
Remote media URLs
Image, video and audio inputs can be given by URL. The engine fetches them from inside the serving cluster, so the gateway checks every remote media URL first and refuses one it does not allow with 400 media_url_not_allowed. data: URLs carry their bytes and always pass.
| Setting | Behaviour |
|---|---|
HANGAR_GATEWAY_MEDIA_URL_HOSTS unset | The URL must be http or https and must not name a private or cluster-internal host (loopback, private, link-local, CGNAT, single-label names, .local, .internal, .svc, .cluster.local and similar). Every public host is allowed |
HANGAR_GATEWAY_MEDIA_URL_HOSTS set | Only hosts on the comma-separated list pass. Entries can be wildcards such as *.cdn.example.net. Listing a private host allows it |
The check is by name, so a public name that resolves to a private address gets through. It does not replace the engine’s own allow-list or an egress NetworkPolicy on the worker namespace. Use all three where tenants can send media URLs. Multipart bodies carry media as bytes and are not checked.
Prefix-cache isolation
Every text generation request (/v1/chat/completions, /v1/completions, /v1/responses, /v1/messages) is salted with its tenant, so two tenants on one deployment never share KV cache blocks. You do nothing to get this.
On the internal listener, X-Hangar-Cache-Scope narrows the salt further. An operator serving many customers through one tenant can name the customer there:
curl http://gateway.internal:8080/v1/chat/completions \
-H "X-Hangar-Cache-Scope: customer-42" \
-H "Content-Type: application/json" \
-d '{"model": "llama-3-8b", "messages": [{"role": "user", "content": "Hello"}]}'
The header is ignored on the public listener and never reaches the cluster. A request that carries media is salted with a hash of its prompt and media, so a multi-turn conversation with media does not reuse the prefix cache across turns. Text-only requests are unaffected. See Data plane.
Responses and errors
X-Request-Id
Every response carries X-Request-Id. If you send one of at most 128 characters from [A-Za-z0-9._:-], the gateway keeps it. Otherwise it mints a UUIDv4. The same ID is in the gateway’s access log line and is forwarded to the cluster, so quote it when you report a problem.
Error format
Errors use the OpenAI envelope. type follows the status and code is what to branch on:
{
"error": {
"message": "The model 'nope' does not exist.",
"type": "invalid_request_error",
"code": "model_not_found"
}
}
| Status | type |
|---|---|
| 401 | authentication_error |
| 403 | permission_error |
| 429 | rate_limit_error |
| 503 | overloaded_error |
| other 5xx | api_error |
| other 4xx | invalid_request_error |
Engine errors are rewritten. A 4xx from the engine keeps its class with a fixed or sanitised message (invalid_request for most; 422 becomes 400). An engine 401, 403 or 404 becomes 502 upstream_error, because those mean a misconfiguration on the gateway side. A 5xx becomes 502 upstream_error with a fixed message, an engine 503 becomes 503 no_capacity, and an engine 504 becomes 502 upstream_unavailable. The engine’s own text is never quoted on a 5xx, since it can carry paths or addresses.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json, missing_body, unreadable_body, invalid_multipart | The body could not be read |
| 400 | missing_model | No model in the body |
| 400 | too_many_stop_sequences | More than four stop sequences |
| 400 | media_url_not_allowed | A remote media URL is refused |
| 400 | invalid_request | The engine rejected the request |
| 401 | unauthorized | Key missing, wrong, revoked or for another tenant; or public tenant with auth: none |
| 403 | tenant_disabled | The tenant is disabled |
| 404 | model_not_found | No such model for this caller |
| 404 | unknown_host | Public listener: Host matches no tenant |
| 404 | unsupported_path | Path not forwarded, or not answered by this model’s modality |
| 405 | method_not_allowed | Method not accepted on this path |
| 408 | request_timeout | Body took too long to arrive |
| 413 | request_too_large | Body over the size limit |
| 429 | rate_limited | Tenant over a limit; honour Retry-After |
| 502 | upstream_error, upstream_unavailable | The engine failed or timed out |
| 503 | no_capacity | No live tunnel to a cluster serving this model, or the buffer budget is full |
| 503 | tenant_unverified | The gateway could not verify the model’s tenant; retry after Retry-After |
Troubleshooting
What the gateway keeps
It keeps no request or response content. The access line has the request ID, method, path (never the query), status, outcome, timings, token counts, the tenant and a 12-character key-hash prefix. It never logs a body, a header value or a model’s answer.