omnigate

Operations

Hardening a deployment (what to expose, where each credential lives, the admin allow-list, log hygiene) has its own page: Security hardening.

Dashboard and admin API

The control plane serves a dashboard at / (sign in with the admin token). It shows totals for today and overall, every key with its spend against its budget, recent text requests with their failover attempts, recent media jobs with their webhook state, requests the gateway refused, the latest admin actions, and a read-only view of the configuration. It refreshes itself every four seconds.

What can be changed without editing a file, from the dashboard or the admin API: gateway keys. A key can be created, given a budget, a budget period, a rate limit and a model list, disabled, rotated and deleted, and data planes pick the change up within seconds. Keys written in the config file stay read-only there. Every such change is written to an audit log.

Everything else (upstreams, routes, prices, the media section, retention) is still changed by editing the config file, which is reloaded within about two seconds.

The tour of the dashboard, the key API with curl examples, and the audit log are in Admin. The endpoint list is in API.

Health and readiness

Which probe to use where, and what the container health checks do, is in Deployment. The data plane has no readiness endpoint: whether it has a config shows in its answers (503 gateway_not_ready) and in its log. Its binary can probe itself: omnigate-dp healthcheck asks the address in OMNIGATE_LISTEN for /healthz and exits 0 on a 200 within three seconds, 1 otherwise, which is what the data-plane image's HEALTHCHECK runs (the image has no shell and no curl).

The control plane's listener outlives its stores. The ledger, the job store, the keys and the audit log run as one group that is restarted, with a pause growing from half a second to thirty, if one of them keeps crashing; the listener is not touched. During such a restart /healthz says 200, /readyz says 503 with the reason, and GET /internal/config answers 503 not_ready, so data planes keep their config and a key that is over budget stays blocked.

Metrics

GET /metrics on the control plane serves Prometheus text format behind the admin token. Metric names are listed in the API reference.

A scrape job looks like this (standard Prometheus configuration; the README notes that the output has been checked by tests, not by a real Prometheus server):

scrape_configs:
  - job_name: omnigate
    scheme: https
    metrics_path: /metrics
    authorization:
      type: Bearer
      credentials_file: /etc/prometheus/omnigate-admin-token
    static_configs:
      - targets: ["admin.gateway.example.com"]

Things to know:

Useful alerts, given what is exposed: omnigate_key_blocked == 1, omnigate_webhooks{state="failed"} > 0, omnigate_key_period_cost_usd approaching omnigate_key_budget_usd, a rising omnigate_ledger_unpriced_events_total or omnigate_ledger_invalid_events_total, and a rising omnigate_rejected_requests_total{reason="overloaded"}, {reason="concurrency_limit_exceeded"}, {reason="request_memory_exceeded"} or {reason="invalid_api_key"}.

Refused requests

A request the gateway answers itself before routing (a bad key, a rate limit, a model the key may not use, a malformed body) never reaches a provider and never becomes a usage event. Each data plane counts these per key name and reason and reports the counts to the control plane every OMNIGATE_FLUSH_MS. They appear in the dashboard's "Refused requests" table and as omnigate_rejected_requests_total{key, reason}.

reason is the error.code the client received: invalid_api_key, missing_api_key, budget_exceeded, model_not_allowed, rate_limit_exceeded, concurrency_limit_exceeded, request_memory_exceeded, model_not_found, not_found, invalid_json, missing_model, payload_too_large, method_not_allowed, invalid_request, request_too_complex, endpoint_not_supported, untranslatable_request, gateway_not_ready, control_plane_unavailable, and overloaded (a connection refused at the connection cap, under the key -, or a request refused because the gateway's memory budgets were spoken for, under the key's name), and too_many_redactions (a private request with more distinct values to replace than one request may hold). One more has no error body: client_timeout, a client that was disconnected for sending too slowly.

The same report from the data planes carries one more kind of count, which is not a refusal and is kept apart: how many values privacy mode replaced before a provider saw them, per key and class. They appear as omnigate_redactions_total{key, class} and in GET /admin/overview under redactions; like the refusals they are held in memory and start from zero with the control plane. They are counts: what was replaced is never reported.

upstream_rate_limited and all_upstreams_failed are not in this list. Those requests reached the failover loop, so they are in the ledger (and in omnigate_key_requests_total and omnigate_key_errors_total) instead.

key is the key's name, or - when the request could not be tied to a known key (no key, an unknown key, or a connection refused before anything was read).

invalid_request covers everything the gateway refuses for the shape of the request rather than its content: a malformed or ambiguous request head (answered 400, 411, 417 or 431), a path that is not in canonical form, a query string or forwarded header with characters outside the allowed sets (API). A request refused for its head is counted under the key -, since the key was never read. A steady trickle of these from the internet is normal; a rise from one of your own applications means its HTTP client builds requests the gateway will not take.

Limits, all by design:

Budgets

A key with budget_usd is blocked once the spend recorded for it in its current budget period reaches the budget.

How a text call is priced. The data plane reads four token counts from the provider's response and reports them separately:

Count Priced with Meaning
input_tokens price_in_per_mtok Input that was not served from the provider's prompt cache.
cached_input_tokens price_cached_in_per_mtok Input read from the provider's prompt cache.
cache_write_input_tokens price_cache_write_per_mtok Input written to the provider's prompt cache.
output_tokens price_out_per_mtok Output.

The two protocols report prompt caching differently, and the data plane turns both into the meaning above, by the protocol of the upstream that answered (which, for a translated request, is not the client's protocol):

The two cache prices default to the input price, so a config without them bills every input token at price_in_per_mtok, as before. Set price_cached_in_per_mtok (and, for Anthropic-style upstreams, price_cache_write_per_mtok) from your provider's price list to bill cached input at its real rate. The parsing is covered by unit tests on response shapes in each provider's format; the README does not record a comparison of ledger totals against a real provider's bill with prompt caching in use.

An answer served from the gateway's own response cache is recorded as a request with no tokens and no cost.

Still true, from the README: reasoning tokens have no price of their own; budgets are enforced on a single control-plane node; and a key cannot be newly blocked during a control-plane outage.

Rate limits

A key with rpm: N may burst up to N requests and is refilled at N per minute (a token bucket).

Unfinished jobs per key. Separately from the rate, a key may have at most max_open_jobs media jobs queued or running at once: the key's own setting, else media.max_open_jobs, 100 by default, 0 for no limit.

Jobs per day per key. A key may create at most max_jobs_per_day jobs per UTC day: the key's own setting, else media.max_jobs_per_day, 10,000 by default, 0 for no limit.

Timeouts, failover and cooldowns

Four timeouts, all on the data plane:

Timeout Variable (default) Applies to When it fires
Upstream OMNIGATE_UPSTREAM_TIMEOUT_MS (10 minutes), or the upstream's timeout_ms Waiting for a provider's response headers The target is abandoned (UpstreamTimeout) and the next one is tried.
Client OMNIGATE_CLIENT_TIMEOUT_MS (30 seconds) A client sending its request head and body, and an idle keep-alive connection between requests The connection is closed. No response is sent (there is no 408).
Stream idle OMNIGATE_STREAM_IDLE_TIMEOUT_MS (5 minutes) The upstream sending nothing after its response head. A response already being relayed: both sides are closed, and the call is billed with status 499: the provider's figures if they had arrived, otherwise an estimate (below). An answer of which nothing has been sent on yet: a failed attempt (StreamIdleTimeout), and the next target is tried.
Client stall OMNIGATE_CLIENT_STALL_TIMEOUT_MS (30 seconds) A client that is connected but takes none of a response: a provider's answer, a file download, a jobs API answer, or one of the gateway's own answers (an error, the model list, a cache hit) once the request has been read. The client's connection is closed. For a metered stream the upstream is then read to its end in the background and the call billed with status 499 (below).

The first three can be turned off with 0; a client stall timeout of 0 means "the same as the stream idle timeout". Until commit e4e30e4 the stream idle timeout covered both directions, so a client that stopped reading was given five minutes; it now gets thirty seconds. Notes:

Failover and cooldowns:

To see what happened to a request, look at x-omnigate-attempts and x-omnigate-upstream on the response, at the attempts column in the dashboard, or at the request's line in the access log.

Streams that end early

A response that stops before its end is still paid for at the provider, so the gateway bills it. What it bills depends on whether the provider's closing usage figures were seen.

What happened What the data plane does What is billed
The client disconnected mid-response, or stopped reading for OMNIGATE_CLIENT_STALL_TIMEOUT_MS (30 seconds) Finishes with the client's connection and keeps reading the upstream in the background: to the provider's closing usage, or the end of the answer, or for at most OMNIGATE_ABORT_DRAIN_MS (2 minutes) and OMNIGATE_ABORT_DRAIN_MAX_MB (64) more megabytes. Then closes the upstream connection. The provider's real figures if they arrived; otherwise an estimate.
The upstream went silent until the stream idle timeout Closes both sides The real figures if they had already been seen; otherwise an estimate.
The upstream broke the connection mid-response, or sent less than it declared Drops the client's connection (without the closing chunk of the chunked response) The same.
An event stream reached its proper end, and the provider never reported usage (it ignores stream_options.include_usage, as some OpenAI-compatible servers do) Nothing special: the client got the whole answer, status 200 An estimate. Before the third-review commit such a call was recorded with zero tokens.

Earlier commits billed only what had been metered when the client left, which for an OpenAI-style stream was usually nothing; then, until commit e4e30e4, the client's own connection waited up to five seconds (and 8 MiB) for the figures, which a long generation outlasted.

Connection limits and shutdown

Connection cap. The data plane serves at most OMNIGATE_MAX_CONNECTIONS client connections at once (4096 by default). A connection beyond that is answered at once, without its request being read, with 503 overloaded, retry-after: 1 and connection: close, and is counted as overloaded among the refused requests. The data plane uses one thread per connection, so the cap is also a cap on threads. The README records what has been tried: a soak at 94 connections for five minutes, a run at 550 for two, and 16 minutes of mixed load at about 830 requests a second; nothing beyond that.

Memory. Besides the budgets below, the data plane's resident memory follows its threads: about 10 MiB idle, 213 MiB with 890 threads in that last run (on Windows; the Linux binary held 304 to 317 MiB with as many threads in a shorter run), and flat for its length. The allocator asks the system for memory in many separate mappings and returns them: that Linux run stayed at about 2,300 mappings, far below the kernel's default limit of 65,530 per process (vm.max_map_count), but a data plane holding many thousands of connections on a host with that default is untried. Builds before 0.1.0-rc.1 grew by about half a kilobyte per request served and never gave it back (Upgrading); if a data plane of yours shows steadily rising memory, check its version first.

Per-key and memory limits. A valid key is not allowed to decide how much the process holds. Before a request body is read, the data plane checks, in this order:

Limit Variable (default) Over it
Requests of one key in flight OMNIGATE_MAX_CONCURRENT_PER_KEY (512) 429 concurrency_limit_exceeded
Request bodies of one key in flight (declared length, counted twice) a quarter of OMNIGATE_REQUEST_MEMORY_MB (1024) 429 request_memory_exceeded
Request bodies of all keys in flight OMNIGATE_REQUEST_MEMORY_MB (1024) 503 overloaded

Then the rate limit, then the declared length against the 8 MiB limit, and only then is the body read. Where a body has to be parsed into a tree (translation, the response cache, provider quirks), the tree is built in an arena capped at 128 times the text plus 1 MiB and at most 96 MiB, and the caps of all trees being built are reserved against OMNIGATE_PARSE_MEMORY_MB (1024; half of it per key): 400 request_too_complex, 429 request_memory_exceeded or 503 overloaded. Every one of the 429 and 503 answers has retry-after: 1 and is counted among the refused requests.

Graceful shutdown. On SIGTERM or SIGINT (Ctrl-C, Ctrl-Break or closing the console on Windows) the data plane:

  1. stops accepting connections;
  2. closes connections that are between requests;
  3. lets requests in flight finish, for up to OMNIGATE_SHUTDOWN_GRACE_MS (10 seconds by default), then cuts off whatever is still running, and stops reading upstream answers whose client has left (each is billed what is known by then, as an estimate);
  4. makes a last attempt to deliver queued usage (up to 20 batches of 500 events) and the refusal counts;
  5. exits. Usage that could not be delivered stays in the spool file and is sent at the next start; with OMNIGATE_SPOOL=off it is lost, and the log says how many events.

In a container this is what makes docker stop end the data plane cleanly instead of waiting to kill it. Docker waits 10 seconds by default before killing a container, which is the same as the default grace, so compose.yaml gives the data plane stop_grace_period: 20s; if you raise OMNIGATE_SHUTDOWN_GRACE_MS, raise that above it. The container test checks that the data-plane container exits with status 0 on SIGTERM, and that both services have their stop timeouts.

Exit codes of the data plane. 0 after a clean stop, 1 for a startup error (a missing token, a bad setting), 2 for an argument it does not know (the only one is healthcheck, which itself exits 0 or 1), and 70 after a panic. Request input is validated so that it cannot reach a panic, but if one happens anyway the whole process ends, so an optimized build logs one line (error(panic): <message>, or a JSON line with "scope":"panic") and exits with 70 for the supervisor to restart it. Usage events already queued are in the spool and are delivered after the restart; requests in flight, and refusal counts not yet reported, are lost. Debug builds keep the standard handler and its stack trace instead. Treat an exit with 70 as a bug to report.

A restart still drops the state the data plane keeps only in memory: rate-limit buckets, cooldowns, round-robin counters, learned model quirks, the response cache, and refusal counts not yet reported.

The control plane has no drain step for requests; at a clean stop it writes a ledger snapshot, for which compose.yaml gives it stop_grace_period: 30s. What a restart of it means for jobs is in Deployment.

Retention

Jobs, files and ledger segments are kept for ever unless the config has a retention section that says otherwise. The ledger's duplicate-detection ids are the one thing with a finite default: 3 days.

"retention": { "jobs_days": 30, "ledger_ids_days": 3, "ledger_days": 90, "delete_bucket_objects": true }

One sweep runs a few seconds after the control plane starts and then every hour. When it removes something it logs retention sweep removed N finished jobs, N stored files, N bucket objects, N ledger dedup ids, N ledger segments.

jobs_days. Jobs that finished (succeeded, failed or canceled) more than this many days ago are removed from the job store and its file, and their outputs are deleted: files on local disk, and objects in the bucket.

ledger_ids_days. The ledger forgets the event ids it uses to reject duplicates once they are older than this. The default is 3 days, also when there is no retention section at all; 0 keeps them for ever. (The default was 14 until commit d79406b, and before commit 3a0b147 ids were kept for ever.)

ledger_days. Sealed ledger segments (the files ledger.NNNNNN.jsonl) whose last write is older than this are deleted.

Without ledger_days the segments are kept for ever. They no longer slow down a start, which loads the newest snapshot and replays only what came after it; they only take disk space.

Private jobs

Jobs created under privacy mode's no_store have a rule of their own, which does not wait for jobs_days:

The sweep runs hourly, so the outputs live between 24 and 25 hours with the default. Its log line counts them:

retention sweep removed 0 finished jobs, 0 stored files, 0 bucket objects, 0 ledger dedup ids, 0 ledger segments, and the outputs of 3 private jobs

A bucket lifecycle rule is still a good backstop for objects a failed delete left behind.

When the control plane is down

The data plane is built to keep serving text traffic without it.

During the outage
Text endpoints Keep working, with the last config received.
Keys, routes, rate limits As in that last config. Config edits are not seen until the control plane is back, and keys cannot be created, changed, rotated or revoked (the admin API is the control plane).
Budgets Keys already blocked stay blocked, also past the end of their period. A key cannot be newly blocked.
Response cache Keeps working: it lives in the data plane.
Refused-request counts Keep adding up in the data plane's memory and are handed over when the control plane answers again.
Usage Queued in memory and appended to the spool file; delivered in batches when the control plane answers again, and deduplicated by request id, so each call is billed once.
/v1/jobs*, /v1/files/* 503 control_plane_unavailable. Signed file URLs for local storage also need the control plane.
Running media jobs Stopped with the control plane. On restart, a job a provider had accepted resumes polling; it is not submitted again.
Restarting the data plane It starts from the config cached on disk. With no cache (first start, or OMNIGATE_CONFIG_CACHE=off) it answers 503 gateway_not_ready until the control plane is reachable.

"Down" has two shapes, and the data plane handles both:

Limits of the spool:

Logs

Both processes log to their console; with Compose, read them with docker compose logs control and docker compose logs dataplane.

If OMNIGATE_ADMIN_TOKEN is not set, the generated token is written to the control plane's log at startup. Treat that log as a secret, or set the variable.

Data plane: level, format and the access log

OMNIGATE_LOG_LEVEL is debug, info (the default), warn or error. OMNIGATE_LOG_FORMAT is text (the default) or json. Everything goes to standard error.

Every finished request gets one access-log line at info level, except successful GET /healthz probes, which are logged at debug. In text form:

info(access): POST /v1/chat/completions 200 key=my-app model=claude upstream=anthropic attempts=1 in=11 out=6 12ms id=0123456789abcdef0123456789abcdef
Part Meaning
method, path, status The status sent to the client. 499 is a stream that broke or stalled; 408 a client that stalled while sending (logged only, never sent).
key= The gateway key's name. The key itself and its hash are never logged. Absent when the request had no known key.
model= The route name the client asked for.
upstream=, attempts= The upstream that answered and how many were tried. Absent when none answered.
usage=estimated The token counts on this line are an estimate: the response ended before the provider's closing figures arrived. When the upstream is read on in the background, the usage event written afterwards may carry the provider's real figures instead. See Streams that end early.
in=, out= Uncached input tokens and output tokens. Present when either is not zero.
cached_in= Input tokens the provider read from or wrote to its prompt cache, when there were any.
cache=hit The answer came from the gateway's response cache.
Nms From the request head being read to the end of the response.
id= The request id (x-omnigate-request-id, and the usage event's id in the ledger).
aborted= Why the request ended without a normal response, if it did: ClientTimeout, StreamIdleTimeout, ClientDisconnected, ClientStalled, UpstreamTruncated, or the name of the I/O error.

Text that came from a client (the path, the model name) cannot break out of its line: in the text format a control byte is written as \xNN and a space as _, so a line break in a model name cannot start a forged log line. A request whose head was refused leaves no access-log line; it is counted among the refused requests and logged at debug level.

With OMNIGATE_LOG_FORMAT=json every line is one JSON object. Ordinary log lines look like

{"ts":1794312003415,"level":"info","scope":"proxy","msg":"upstream mock-flaky returned 503, failing over"}

and access-log lines have "scope":"access" and carry their fields as members instead of a message:

{"ts":1794312003415,"level":"info","scope":"access","method":"POST","path":"/v1/chat/completions","status":200,"key":"my-app","model":"claude","upstream":"anthropic","attempts":1,"input_tokens":11,"output_tokens":6,"latency_ms":12,"request_id":"0123456789abcdef0123456789abcdef"}

ts is Unix time in milliseconds. key, model, upstream with attempts, cached_input_tokens, cache ("hit"), estimated (true, the same fact as usage=estimated in the text form), request_id and aborted are present only when they apply; method, path, status, input_tokens, output_tokens and latency_ms always are. A message longer than about 2,000 bytes is cut off, and a path longer than 160 bytes is shortened.

The query string of a request is never logged, in either format: for a signed file URL it is the credential.

(The values in these examples are made up; the field names and order are the code's.)

The control plane has no access log and no JSON format: it logs through Elixir's standard logger.

Lines worth knowing

Control plane:

Line Meaning
OMNIGATE_INTERNAL_TOKEN is not set (or is empty). ... The control plane refused to start. Set the same value for both processes.
OMNIGATE_ADMIN_TOKEN is shorter than 16 characters. ... The control plane refused to start. Set a long random value, or leave it unset to have one generated.
OMNIGATE_CONFIG is not set. Point it at your config file ... A release build of the control plane refused to start: only a development build falls back to the development config.
OMNIGATE_CONTROL_PORT is not a whole number: ..., OMNIGATE_CONTROL_PORT must be between 1 and 65535, got ..., OMNIGATE_AUDIT_MAX_MB is not a number: ..., OMNIGATE_AUDIT_MAX_MB must not be negative, got ..., OMNIGATE_CONTROL_BIND is not an IP address: ... The control plane refused to start over a setting it could not read. Nothing is replaced by a default.
loaded config <path>: N routes, N keys Startup.
loaded N managed keys from <path> Startup, when the keys file has keys.
cannot write <path>: ... A key change could not be saved and was not applied.
audit log <path> could not be written: ... An admin action went ahead without its audit line.
retention sweep removed N finished jobs, N stored files, N bucket objects, N ledger dedup ids, N ledger segments A sweep removed something.
retention: could not delete bucket object <key>: ... The object stays in the bucket.
reloaded config <path> An edit was applied.
config reload failed, keeping previous: ... The edited file is invalid; the old config is still in force.
OMNIGATE_ADMIN_TOKEN is not set. Generated one for this run ... The token follows on the same line.
control plane listening on <address>: reachable from the network over plain HTTP ... OMNIGATE_CONTROL_BIND is not loopback.
ledger ready in N ms from a snapshot plus N later events (<path>) Startup. Without a usable snapshot the line says from N events.
ledger: sealed segment N (N bytes) The active segment was closed under its number and a new one started.
ledger: ignoring ledger.snapshot (<reason>) A snapshot failed a check (checksum, contents, or the segment bytes it covers) and was not used. The spare, or a full replay, takes its place.
ledger: using the previous snapshot; the newest one was unusable Startup went on from the spare.
ledger: the id file for <day> (<path>) is missing; the snapshot recorded N ids in it. ... Warning, at startup. A ledger.ids.<day> file that the snapshot counted ids for is gone (deleted, or left out of a restore). The totals are unaffected, but a usage event of that day that a data plane delivers again would be counted a second time. Restore the file from the backup that holds the snapshot, if you have it.
ledger: no usable snapshot, and segments before N were deleted by retention. ... Error. Totals were rebuilt from the remaining segments only; spend recorded before them is lost. Restore the data directory from a backup.
ledger: snapshot failed: ... A snapshot could not be written. Usage is still recorded; the next start replays more.
ledger: sealing segment N failed, will try again: ... Error (a full disk, a permission). The batch is recorded; the segment stays active and is sealed after a later batch.
ledger: skipped N events after the snapshot that it had already counted Startup. Events after the snapshot whose ids the snapshot already knew were not counted a second time.
ledger: dropped N of M usage events in a batch as invalid (missing or mistyped fields) A data plane sent events the ledger cannot record. Also in omnigate_ledger_invalid_events_total.
ledger: usage for route <route> via <upstream>/<model> has no price in the config and is recorded at $0 ... Once per route and target. Also in omnigate_ledger_unpriced_events_total.
media route <route>: target <upstream>/<model> has no price, so its jobs are recorded at $0 ... Once per target.
job <id> could not be stored: ... Error. The client got 503 storage_unavailable; check the disk of the data directory.
job <id>: its cost is not in the ledger yet (...); it will be recorded later Error. The ledger was restarting when the job finished; the cost is recorded within about a minute.
ledger: cutting N bytes of a torn last line from ledger.jsonl Startup after a crash in the middle of a write. The batch was never acknowledged and is sent again.
job store compacted its log (N lines since the last time) Routine.
OmnigateControl.Supervisor stopped (...); the HTTP listener stays up, starting the group again in N ms Error. One of the stores (ledger, job store, keys, audit log) crashed repeatedly and the group they run in gave up. The listener stays up; /readyz answers 503 until the group is back. Find the crash above this line.
OmnigateControl.Supervisor is running again, OmnigateControl.Supervisor could not start (...); trying again in N ms The group came back, or failed to and will be tried again (the pause doubles up to 30 seconds).
key "<name>" is defined in the config file and also exists as a managed key: ... The managed key is shadowed and its secret no longer works. See Admin.
request <id> <METHOD> <path> failed: ... The control plane answered 500 internal_error; <id> is the request_id the client got.
job <id>: <upstream> failed (<outcome>), trying next target Media failover.
job <id>: resuming provider job <provider id> on <upstream> A job continued after a restart.
job <id>: storing outputs failed: ... Storage problem; the job fails with storage_error.
webhook for <job id> gave up after N attempts Delivery failed for good.

Data plane:

Line Meaning
OMNIGATE_INTERNAL_TOKEN is not set The data plane exited at startup. An empty value counts as not set.
bad OMNIGATE_LOG_LEVEL or OMNIGATE_LOG_FORMAT: ..., <VARIABLE> is not a valid number: "<value>" (expected a whole number from <min> to <max>), OMNIGATE_LISTEN must be host:port, got ..., OMNIGATE_LISTEN has no valid port: ... The data plane exited at startup over a bad setting. Every numeric variable is read strictly (digits only) and named in the message.
omnigate data plane listening on <address> (control plane <url>) Startup.
installed config version <id> (N routes, N keys) A new config snapshot is in force.
initial config sync failed (...) The control plane was unreachable at startup; the line says whether a cached config is being served. ControlTimeout in it means the address took the connection, or the packets, and gave no answer within 10 seconds.
config fetch returned 401 The two OMNIGATE_INTERNAL_TOKEN values differ.
config sync failed (...); serving last good config Logged once per outage. control plane reachable again ends it.
upstream <name> returned <status>, failing over A 429 or 5xx.
upstream <name> failed: <error> A connection error or timeout.
upstream <name> keeps failing; trying it last for the next N ms A cooldown started.
usage delivery failed (...); spooling, N events queued Logged once per outage. usage delivery resumed ends it.
reloaded N undelivered usage events from spool Startup after an outage or crash.
dropping usage event (queue full); N dropped so far Error. The control plane has been unreachable long enough to fill the queue; usage for these requests is lost.
the usage spool holds more than can be queued (N bytes on disk): keeping the oldest N events (N bytes) and DISCARDING the rest Error, at startup. See When the control plane is down.
request <id>: read N more bytes from <upstream> after the client left; billing the provider's usage (in=N out=N) A stream whose client left was read to its end. billing ESTIMATED usage when the provider's figures did not come.
unknown argument "<arg>" (the only subcommand is: healthcheck) The data plane was started with an argument it does not know, and exited with status 2.
<upstream>/<model> rejects sampling parameters; retrying without them A learned model quirk.
control plane request <path> failed: ... A jobs or files request got 503 control_plane_unavailable. The line has the path only, never the query string.
error(panic): ... The data plane hit an internal error and exited with status 70. See Connection limits and shutdown.
stopping: N connection(s) open, waiting up to N ms for requests in flight A stop signal arrived.
N request(s) still running after the grace period; cutting them off The grace period was too short for them.
stopped cleanly Exit with all usage delivered.
stopped with N usage event(s) undelivered (kept in the spool) Exit while the control plane was unreachable; the events are sent at the next start. With OMNIGATE_SPOOL=off the line ends (no spool: they are lost).

Load testing

tools/load_test.py drives a running gateway with many kinds of client at once: keep-alive chat clients, streaming clients, clients that disconnect after the first chunk, clients that stall half-way through a body or never send anything, a failover route, a rate-limited key, oversized and malformed bodies, pipelined requests, and a health probe throughout. It starts nothing itself; point it at a gateway whose routes lead to the mock upstreams. Against the quickstart setup:

python tools/load_test.py --gateway http://127.0.0.1:8080 --key og-unlimited-key --model demo-direct --failover-model demo-chat --duration 60

It passes when the gateway stays up and answers /healthz throughout, every completed response is well formed with an expected status, stalled and silent clients are disconnected, and the gateway's memory, threads and handles do not keep growing (when it can find the gateway's process). Options include --limited-key, --dead-model, --events-url (an endpoint that tallies the usage events received, to check that none is lost or duplicated), --client-timeout (set it to the gateway's OMNIGATE_CLIENT_TIMEOUT_MS in seconds) and --skip; the file's docstring and --help list them. It uses only the standard library and exits 0 on PASS, 1 on FAIL.

It is not part of CI. The README records one run (Windows 11, optimized build, Python mock upstream, 94 clients for five minutes) and says what it was not: not over TLS, not against real providers, not beyond 550 connections. tools/mock_upstream.py takes MOCK_PORT_OFFSET to shift its ports (9001-9005 by default), so that a second copy can run next to one that is already in use.

Hostile-input testing

tools/malformed_test.py is the counterpart of the load test for requests that are wrong on purpose. It drives a gateway over raw sockets with:

After every case it asks /healthz on a fresh connection. Staying up is the property under test; where a case has one right answer (400, 411, 417, 413) that is checked too, and no case may produce a 500. It exits 0 only if the gateway survived everything and every expected status matched.

By itself, with a built data plane and mix available, it starts the mock providers, the control plane and the gateway through the end-to-end harness (on that harness's ports, with state in a temp directory) and stops them at the end:

python tools/malformed_test.py

Run this way it also plays the other side and checks the limits:

--thorough runs 1,500 generated cases instead of 250 and takes several minutes. It cannot run at the same time as the end-to-end or SDK suites, which use the same ports; when a case fails it keeps the logs and says where.

Against a gateway you started yourself, name it, with a valid key and a text route that key may use:

python tools/malformed_test.py --gateway 127.0.0.1:8080 --key og-unlimited-key --model demo-direct

--rpm-key adds the check that the trailing-slash spelling cannot be used to create jobs past a rate limit, and that the limit is applied before the body is read. The second check sends as many requests as the key allows per minute and expects the next one to be refused, so the tool has to know that number: --rpm, 3 unless given. The dev config's og-ratelimited-key allows 5: --rpm-key og-ratelimited-key --rpm 5. --other-key, --concurrency-cap, --translated-model, --late-model and --flood-model switch on further checks against your own instance; --help says what each needs. The hostile-upstream and hostile-control-plane cases only run when the script started the stack itself. The billing checks then run only if you give it --events-url (a URL that lists the usage events received) and the routes for --drip-model and --stall-model. Point it at a test instance, never at production: it sends several megabytes of junk and deliberately trips refusal counters. Standard library only.

CI runs it in its own job, in the first form (.github/workflows/ci.yml), and python tools/check.py includes it. The container test sends a smaller set of malformed requests through the TLS proxy as well (Deployment).

Troubleshooting

By the reason in the gateway's answer, which is always error.code, whichever of the two processes raised the error (see API; until commit 10bc009 the control plane put it in error.type). An answer that has an x-omnigate-upstream header came from a provider, not from the gateway; see the last rows. A gateway error carries x-omnigate-request-id, which is the id= of the request's line in the data plane's access log.

Reason Likely cause What to do
gateway_not_ready (503) The data plane has never received a config. Check OMNIGATE_CONTROL_URL, that the control plane is up (it does not start without OMNIGATE_INTERNAL_TOKEN, or with an invalid config file), and that both processes have the same OMNIGATE_INTERNAL_TOKEN (data plane log: config fetch returned 401).
missing_api_key (401) No Authorization: Bearer or x-api-key header. Send the gateway key. For a file URL: the sig parameter was stripped. With an SDK: it may have picked up no key, or the wrong one, from the environment.
invalid_api_key (401) The key's SHA-256 is not known: never configured, deleted, disabled, or replaced by a rotation. For a file key, check key_sha256 is the lowercase hex hash of exactly the key string. echo key | sha256sum hashes a trailing newline; use the Python command in Configuration. For a managed key, look at its state in the dashboard or GET /admin/keys. A new or changed key takes a few seconds to reach the data plane.
budget_exceeded (402) Recorded spend in the key's budget period reached budget_usd. Raise or remove the budget, or wait for the period to turn if it is monthly or daily; a total budget never resets by itself. See Budgets. The key can still read its jobs and files.
overloaded (503) with no request id The data plane is at OMNIGATE_MAX_CONNECTIONS. Retry after a second. If it persists: look for clients that open connections without reusing or closing them, lower OMNIGATE_CLIENT_TIMEOUT_MS so idle ones are dropped sooner, or raise the cap.
overloaded (503) with a request id The request bodies in flight, or the requests being converted, have used up OMNIGATE_REQUEST_MEMORY_MB or OMNIGATE_PARSE_MEMORY_MB. Retry after a second (the SDKs do). If it persists under honest load, raise the budget, and the container's memory limit with it.
concurrency_limit_exceeded (429) The key has its max_concurrent (or, without one, OMNIGATE_MAX_CONCURRENT_PER_KEY) requests in flight. Streams whose client hung up count until the provider's answer has been read to its end. Retry after a second. Raise the cap if one key legitimately runs that many requests at once.
request_memory_exceeded (429) The key's requests in flight are too large together (a quarter of OMNIGATE_REQUEST_MEMORY_MB), or it is having as many large requests converted at once as it may (half of OMNIGATE_PARSE_MEMORY_MB). Retry after a second; send fewer large requests at once, or raise the budget.
request_too_complex (400) The request must be translated for a provider of the other protocol (or its stream_options rewritten) and has an object with more than 1,024 members, or far more values than its size suggests. Use a route with an upstream of the client's own protocol, which forwards the body without parsing it, or split the object.
model_not_allowed (403) The key has a models list without this route. Add the route name to the list.
model_not_found (404) No route with this name. Text endpoints only know routes[]; the jobs API only knows media.routes[]. Check the name, and that you are calling the right API for it.
not_found (404) The path or method is not one the gateway serves. See the endpoint list in API.
invalid_json, missing_model (400) The body is not a JSON object, is nested deeper than 64 levels, or has no "model" string. Fix the request.
invalid_request (400): a JSON object in the request body has too many members, a key in the request body is too long, "model", "stream" and "stream_options" may each appear only once, ... must be spelled without escapes The body has an object with more than 65,536 members, a key over 8 KiB, or one of the three fields the gateway acts on twice or written with escapes. Fix the request.
invalid_request (400): the request path is not in canonical form The path has a trailing slash, an empty, . or .. segment, a percent-encoded character, or another character outside letters, digits and -._~:@. Send the path plain: /v1/jobs, not /v1/jobs/. Look for a base URL that ends in a slash, or a client that encodes path segments.
invalid_request (400): the query string contains unsupported characters A %, /, space or other character outside the allowed set in the query string. Remove the parameter; the gateway uses none beyond limit, exp and sig.
invalid_request (400): invalid idempotency-key header ..., invalid anthropic-version header, invalid anthropic-beta header The header's value has characters the gateway will not forward. For the two anthropic- headers use letters, digits and -._ (plus commas and spaces in anthropic-beta). An idempotency key, which is only checked on job requests, may be any 1 to 255 visible ASCII characters without spaces.
invalid_request (400, 411 or 417) with no request id, or a bare 400 or 431 The request head itself was refused: malformed, ambiguous about its body, without a Content-Length on a POST (a chunked request body included), with an Expect other than 100-continue, or larger than 16 KiB. Fix the client. A 411 can also come from a proxy in front that hands a request body on with chunked encoding (because the client sent it that way, or because the proxy re-chunks): the gateway needs a Content-Length. The included Caddy configuration does not buffer request bodies, so a client that sends a chunked body through it is expected to get the 411 as well; that has not been tested. See API.
payload_too_large (413) Body over 8 MiB. Send less; the limit is not configurable.
rate_limit_exceeded (429) Over the key's rpm. For POST /v1/jobs the control plane counts as well, and can be the one that answers. On GET /v1/jobs: the key is listing too often (its rpm, or 600 per minute). Remember that requests refused for their body or model count against the limit too. Wait Retry-After seconds, or raise rpm.
too_many_open_jobs (429) The key already has max_open_jobs jobs queued or running. Wait for jobs to finish (the answer suggests 10 seconds in Retry-After), cancel some, or raise the key's max_open_jobs (or media.max_open_jobs).
too_many_jobs_today (429) The key already created max_jobs_per_day jobs this UTC day, failed and canceled ones included. Wait for midnight UTC (Retry-After counts down to it, an hour at a time at most), or raise the key's max_jobs_per_day (or media.max_jobs_per_day).
storage_unavailable (503) on POST /v1/jobs The control plane could not write the job to its job store. Check the disk and permissions of the control plane's data directory; its log has job <id> could not be stored.
payload_too_large (413) on POST /v1/jobs, from the control plane input and metadata together exceed media.max_input_bytes. Send less, pass large inputs by URL, or raise the setting.
invalid_request (400) on POST /v1/jobs: "input.duration" must be a number of seconds, 0 or more (or the same for seconds, n, num_outputs, num_images) A field that feeds the price is negative or not a number. Fix the request.
invalid_request (400) on POST /v1/jobs: this model is priced per second: send "input.duration" ... The route has a per_second_usd price, the job names no duration, and the target has no default_seconds. Jobs like this were accepted (and billed nothing per second) before commit d79406b. Send input.duration, or set default_seconds on the route's targets.
invalid_request (400) on POST /v1/jobs: "input.duration" must be at most N seconds for this model, this model does not accept the input field(s) ..., "input.<field>" must be one of: ... The route's max_seconds, allowed_input or price_multipliers refuse the job. Fix the request, or change the route in the config.
all_upstreams_failed (502) Every target failed, and none of them answered 429. Read error.attempts: http_429 / http_5xx are provider answers; UpstreamTimeout means no headers within the timeout; ConnectionRefused and similar are network errors; unknown_upstream means a target names an upstream that is not in upstreams[]; protocol_mismatch means that target speaks the other protocol and the endpoint is not translated; untranslatable_request means the request has audio or file parts and that target speaks the other protocol.
upstream_rate_limited (429) No target succeeded, and at least one answered 429. Wait for the retry-after the response carries (the longest any rate-limited provider asked for), or add a target at another provider to the route. This is your provider key's limit, not the gateway key's rpm.
endpoint_not_supported (400) An untranslated endpoint (embeddings, responses, completions, count_tokens) was called on a route whose upstreams all speak the other protocol. Call a route that has an upstream of the endpoint's own protocol, or add one to this route.
untranslatable_request (400) The request has audio or file parts, and every target of the route would need it translated. Use a route with an upstream of the client's own protocol.
control_plane_unavailable (503) A jobs or files request could not reach the control plane within 30 seconds. Check the control plane; text endpoints are unaffected.
webhook_not_configured (400) A job asked for a webhook but no signing secret exists for the key. Set one; see Configuration.
invalid_webhook_url (400) Not http(s), no host, embedded credentials, does not resolve, or resolves to a private address. Use a public URL. For local development set media.webhooks.allow_private to true.
idempotency_conflict (409) The Idempotency-Key was used before with a different body. Use a new key for a new request.
job_not_found, file_not_found (404) Wrong id, a job of another key, a job removed by retention, or a job of a key that was deleted and created again under the same name.
unauthorized (401, control plane) No valid admin token or session on /admin/* or /metrics. Send Authorization: Bearer <admin token>. A gateway key or the internal token is not accepted here.
too_many_attempts (429, control plane) 20 wrong bearer tokens from this address within a minute. Until the minute is over the right token is refused too. Find what is sending the wrong token (an old token in a script or a scrape job), then wait a minute. Behind a proxy without OMNIGATE_TRUST_FORWARDED, everyone shares the proxy's address. See Admin.
csrf_failed (403, control plane) A change sent with a session cookie but without the session's CSRF token. From a script, use the bearer token instead. In the dashboard, reload the page. See Admin.
read_only_key (409, control plane) The key is defined in the config file. Edit the file.
key_exists (409), key_not_found (404), invalid_request (400, control plane) See Admin.
invalid_request (400): the request path must not contain encoded characters The path sent to the control plane (directly, or through the gateway for /v1/jobs* and /v1/files/*) contains a %. Send the path plain. Key names, job ids and file names never need encoding.
internal_error (500, control plane) The control plane failed while handling the request. Find the request_id from the error in the control plane's log (request <id> ... failed:).
invalid_signature, url_expired (403) A file URL was altered or is older than its lifetime. Changing OMNIGATE_SIGNING_SECRET (or the internal token it is derived from) also invalidates outstanding URLs. Fetch the job again for a fresh URL.

A media job that ends failed:

error.type / attempt outcome Likely cause
all_targets_failed Read attempts[].outcome, below.
missing_api_key_env:<VAR> The variable is not set in the control plane's environment. Media keys are not read from the data plane.
unsupported_kind:<kind> Typo in media.upstreams[].kind.
unknown_upstream A media target names an upstream that is not in media.upstreams[].
http_<status>: ... The provider refused the submit; its message follows.
provider_failed: ..., provider_canceled, provider_expired: ... The provider accepted the job and then reported this.
invalid_input: ... The adapter needs a field the job's input lacks; see Providers.
sume_<code>: ... Sume refused the submit or failed the job; the code is Sume's own (sume_insufficient_funds: top up at Sume; sume_unauthorized: the key; sume_invalid_request: a field the model does not take; sume_rate_limited, sume_queue_full: Sume asked for a wait longer than the gateway retries a submit for; sume_content_policy_rejected and other reasons: Sume failed the job). sume_router_not_set cannot occur with a config that loaded. See Providers.
unpriced_variant: ... The target's price has variants and none covers this job (the config changed after the job was accepted). Nothing was sent to the provider.
timeout The target did not finish within the route's timeout_s.
no_outputs, no_output: ... The provider finished without a file (for Gemini: a blocked prompt or an early stop).
too_many_outputs: ... The provider's answer listed more files than media.max_outputs_per_job (16). Nothing was downloaded. Raise the setting if the model really produces that many.
completed_after_cancel Not a failure: the job was canceled, the provider completed the call all the same, and the job was billed for it (API).
output_fetch_failed: ... The output could not be downloaded: an HTTP error, more than max_output_bytes (or, with the outputs together exceed media.max_job_output_bytes, more than that in all), or a stalled download. output_fetch_failed: blocked: output url resolves to a private or loopback address means the URL the provider returned, or one it redirected to, does not point at a public address; the control plane refuses to fetch it unless media.webhooks.allow_private is set. too many redirects means more than five.
storage_error Outputs could not be stored: bucket endpoint, credentials or permissions. The control log has the detail.
route_removed The media route was deleted from the config while the job was unfinished.

Provider answers passed through (they carry x-omnigate-upstream):

Symptom Likely cause
401 or 403 in the provider's own error format The provider key is missing or wrong in the data plane's environment. An unset api_key_env variable is sent as an empty key.
400 naming a parameter The provider refused the request as sent. In the provider's own protocol the gateway does not alter requests.
404 from the provider The target's model is not a model id the provider knows, or base_url / v1_path is wrong.

Other symptoms:

Symptom Likely cause
Dashboard login answers 429 Five failed logins from one address within a minute. Behind a reverse proxy all clients share the proxy's address unless OMNIGATE_TRUST_FORWARDED=1 is set (as compose.yaml does). Wait a minute.
The dashboard host answers 403 to everything The request comes from outside OMNIGATE_ADMIN_ALLOW. Remember that the proxy judges the address it sees, which behind another proxy is that proxy's.
The data-plane container restarts with exit code 70 A panic. The line error(panic): ... in its log says where. Report it.
A file URL downloads instead of displaying in the browser Intended: stored outputs are served as attachments. Fetch the file and use it from your own origin.
A call the client cancelled shows up with tokens and cost Intended: an abandoned stream is billed, exactly when the provider's figures arrive in time and by estimate otherwise. See Streams that end early.
Signed out of the dashboard after changing the admin token Expected: sessions are signed with a key derived from the token.
Output URLs point at 127.0.0.1:8080 media.public_base_url is still the default.
A webhook never arrives Look at webhook.last_status and webhook.last_error in the job. The receiver must answer 2xx; a redirect counts as a failure.
Streamed tokens arrive in one burst through your proxy The proxy is buffering. In Caddy: flush_interval -1.
Spend shows $0 The route's targets have no prices. Or the answers came from the response cache, which costs nothing.
The control plane exits at once, or its container keeps restarting OMNIGATE_INTERNAL_TOKEN is unset or empty, OMNIGATE_ADMIN_TOKEN is set but shorter than 16 characters, a release build has no OMNIGATE_CONFIG, OMNIGATE_CONTROL_PORT, OMNIGATE_AUDIT_MAX_MB or OMNIGATE_CONTROL_BIND has a value it cannot read (the log names each of these), the config file is invalid (a negative media price, a bad max_open_jobs or a whole-number setting that is a fraction or out of range included), or the keys file exists but cannot be read.
The data plane exits at once A setting it cannot read: the log names the variable. Numeric settings take digits only (2000, not 2s, 2_000 or +2000).
docker compose up fails with a missing env_file .env.control or .env.dataplane does not exist. Copy the .example files; see Deployment.
The control container is unhealthy GET /readyz is failing. Call it (docker compose exec control wget -q -O - http://127.0.0.1:4000/readyz) and read reason; a refused connection means the control plane is still loading its ledger or is not running.
A managed key stopped working after a config edit The config file now defines a key with the same name, which shadows the managed one. GET /admin/keys shows it with "state": "shadowed".
An application reports a rate limit although its key has no rpm 429 upstream_rate_limited: the provider's limit on your provider key, passed on.
A connection the client kept open was closed by the gateway It was idle for OMNIGATE_CLIENT_TIMEOUT_MS (30 seconds by default). Most HTTP clients retry on a fresh connection by themselves.
A long streamed answer was cut off The upstream sent nothing for OMNIGATE_STREAM_IDLE_TIMEOUT_MS, or the client read nothing for OMNIGATE_CLIENT_STALL_TIMEOUT_MS (30 seconds by default; a client that pauses reading for longer than that, to do slow work per chunk, needs it raised). The ledger has the request with status 499, with estimated token counts if the provider's own did not arrive.
A response or a file download ends with a broken connection instead of an error The provider (or, for a file, the control plane) cut its answer short after the gateway had started sending. A chunked response without its closing chunk is how that is signalled.
A file download shows no size or progress Between commit e4e30e4 and the close-out commit, files over 64 KiB were sent chunked, without a content-length.
The data-plane container is killed and restarted under load (OOMKilled) Its memory limit (OMNIGATE_DATAPLANE_MEMORY, 1 GiB) is below what its budgets admit. With the current compose.yaml they are 256 + 256 + 64 MiB; a Compose file from before the close-out commit, or budgets you raised in .env, admit more.
The control-plane container is killed and restarted (OOMKilled) Its memory limit (OMNIGATE_CONTROL_MEMORY, 1 GiB) is too small for the duplicate-detection ids of your traffic, or for a media output a provider returned inline.
The data-plane container is unhealthy omnigate-dp healthcheck gets no 200 from /healthz on OMNIGATE_LISTEN within three seconds.
The control plane does not start after an upgrade, naming a setting Whole-number settings are read strictly since commit d79406b: "rpm": 600.0, a negative budget, a timeout_s of 0 or a media.max_input_bytes over 8,000,000 used to load.
A changed answer keeps coming back unchanged The route has cache_ttl_s and the response carries x-omnigate-cache: hit.
A new key works on one data plane and not yet on another Each data plane polls the config by itself; wait one poll interval.
A blocked monthly key is still blocked after midnight UTC on the first The data plane learns of the change with its next poll; if the control plane is unreachable it cannot learn of it at all.

This page on GitHub