One YAML file configures the gateway. Resolution order:
GW_CONFIG=<path> — explicit config fileconf/gateway.yaml)GW_HOST / GW_PORT override listen.host / listen.port at runtime
(the container image sets GW_HOST=0.0.0.0). GW_CONTENT_KEY (64 hex chars =
32 bytes) is the deployment key that seals retained content at rest; without it,
full retention refuses to store raw text and falls back to redacted.
listenlisten:
host: 127.0.0.1
port: 8080
storage — durable records and fleet backendsstorage:
sqlite_path: /var/lib/gw/store.db # empty/absent = in-memory
postgres_url: postgres://gw:secret@db.internal/gw # fleet-shared backend
redis_url: redis://cache.internal:6379 # shared counters + health
shared_cache: false # also share the response cache in Redis (needs redis_url)
ledger_max_rows: 100000 # prune oldest billing rows past the cap; 0 = unlimited
The billing ledger, uploaded files, and batch jobs live here. In-memory by
default (lost on restart); a SQLite path makes them durable on one node.
postgres_url turns Postgres into the fleet backend: the source of truth for
config (versioned documents + a change feed every instance follows), the
shared access-key table, the shared ledger/files/batches store, and a
distributed batch queue (any instance claims and runs submitted batches).
redis_url shares rate/quota/TPM counters and account-health cooldowns across
instances; shared_cache: true additionally moves the request cache into
Redis so a hit on one instance serves the fleet (off = each instance caches
in-process, a miss just recomputes).
access_keys — client authentication and per-key governanceaccess_keys:
- ak: ak-demo-123 # bearer / x-api-key value clients send
product: demo # product group (for product-level QPM)
tenant: acme # optional; absent = the unrestricted `default` tenant
owner: alice # optional; binds the key to one end user (authoritative
# for per-user attribution; a shared key omits it and
# falls back to the request's `x-gw-user` / `user`)
qps: 100 # per-key request rate
daily_token_quota: 1000000
tokens_per_minute: 600 # optional TPM window limit
expires_at_epoch_secs: 1767225600 # optional expiry (403 after)
banned: false # optional; a banned key 403s but stays listed
model_quotas: # optional per-model daily caps (override tenant defaults)
gpt-4o: 200000
tenants — pooled limits, entitlement, quota defaultstenants:
- name: acme
qps: 50 # pooled across ALL of acme's keys
models: [gpt-4o, gpt-4o-mini] # entitlement allowlist; absent = every model
model_quotas: # per-model daily-token defaults, applied per key
gpt-4o: 100000
fallback_model: gpt-4o-mini # over-quota requests degrade here instead of failing
admin_token_env: ACME_ADMIN_TOKEN # optional tenant-scoped /admin token
model_prices: # optional per-model charged-price override for this tenant
gpt-4o: {input_price_per_1k_micros: 5000, output_price_per_1k_micros: 20000}
tts-1: {unit_price_micros: 20}
user_daily_token_quota: 100000 # optional soft per-end-user daily cap
security: # optional; overrides the global `security:` WHOLE for this tenant
blocklist: ["forbidden"]
blocklist_action: flag # block | flag | shadow
detect_secrets: true
regex_rules:
- {name: ssn, pattern: '\d{3}-\d{2}-\d{4}', action: block}
retention: # optional prompt/response retention; absent = retain nothing
content: redacted # none | redacted | full (full needs GW_CONTENT_KEY)
days: 30 # purge after N days; 0 = keep until manually purged
Keys without a tenant join the implicit default tenant (no pooled limits,
entitled to every model), so a flat config keeps working unchanged. The model
catalog (GET /v1/models) filters to the caller’s entitlement.
user_daily_token_quota, security, and retention are enterprise controls
detailed in Governance; security replaces the global policy
outright when present (it is not merged field-by-field).
models — public model names and dispatchmodels:
- name: gpt-4o # name clients request
protocol: openai-chat # wire protocol (or set `provider:` instead)
input_price_per_1k_micros: 2500 # billing rates (micros per 1k tokens)
output_price_per_1k_micros: 10000
unit_price_micros: 0 # per non-token unit: TTS character, transcription second, rerank search unit
qpm: 60 # optional model-level rate limit
cache_ttl_seconds: 60 # optional request-level response cache
token_rate: # optional per-component billing weights
read_cache: 0.1 # cache reads at 10% of the input price
write_cache: 1.25 # (prompt/completion/reasoning default 1.0)
write_cache_1h: 2.0 # 1-hour cache writes (default = write_cache)
audio_prompt: 16.67 # audio input tokens (default = prompt weight)
audio_completion: 8.33 # audio output tokens (default = completion weight)
long_context: {threshold_tokens: 200000, prompt_weight: 2.0, completion_weight: 1.5} # optional tier past a prompt size
batch_discount: 0.5 # optional: /v1/batches items at this fraction of the price
prompt_cache: true # anthropic-messages only: prompt-cache breakpoints
variants: # optional weighted canary split, sticky per user
- {model: gpt-4o, weight: 90} # self-reference keeps a share here
- {model: gpt-4o-next, weight: 10}
token_rate weights scale cost and quota consumption per token component; the
ledger’s prompt/completion columns stay vendor-reported, while total_tokens
is the weighted platform total. Audio tokens (realtime, audio chat: the
vendor’s audio_tokens details), 1-hour cache writes (Anthropic’s
cache_creation.ephemeral_1h_input_tokens) are subsets of prompt/completion
and cache-write with their own weights (audio tokens are attributed to the
fresh, non-cached prompt side — the flat OpenAI usage shape does not say
whether a cached token was audio); long_context re-scales both billable
sides once the prompt crosses the threshold (Anthropic’s >200k tier);
batch_discount multiplies the charged and vendor cost of items served
through /v1/batches. unit_price_micros prices what the surfaces
without token usage meter — a tts model’s input characters, an stt
model’s audio seconds (the vendor’s usage.seconds / duration, else the
uploaded WAV/MP3’s own play length; fractions rounded up), a rerank
model’s search_units, an image model’s images, a video model’s
generated seconds — and adds to cost_micros next to any token cost;
the count lands in the ledger’s billed_units and the /admin/usage
aggregates. An account’s cost_unit_price_micros is the vendor side of the
same unit, for margin. prompt_cache (anthropic-messages models)
marks the system prompt and the latest user turn as Anthropic prompt-cache
breakpoints, so each turn of a conversation re-reads its prefix at the
cache-read rate; it is per model and off by default because a long one-shot
prompt would pay the cache-write premium for nothing. A /v1/messages client
that sends system as blocks with its own cache_control (including
ttl: 1h) keeps them as sent — the gateway adds no second breakpoint there. variants splits a
public name across other declared same-protocol models (one level):
entitlement and the per-(AK, model) daily counter judge the public name,
billing prices the served variant, and the response echoes the requested
name. Selection hashes the effective user, so a user sticks to one backend
across the fleet; a realtime session picks its variant once at the handshake
and pins it for the whole session.
providers — first-class provider presetsproviders:
- name: openai
kind: openai # openai | anthropic | gemini | deepseek | openrouter | moonshot | xai | siliconflow
api_key_env: OPENAI_API_KEY
# endpoint / timeout_seconds / connect_retries / retry_status / secret_key_env may be
# set here too and are inherited by every account naming this provider
# for whatever the account leaves unset
models:
- name: gpt-4o
provider: openai # fills the protocol with the kind's default
# and pins the model to that provider's accounts
A provider entry expands into an upstream account with the kind’s preset
base URL (overridable via endpoint:, e.g. for OpenAI-compatible
vendors) and served wire types; an explicit account with the same name
wins.
accounts — upstream credential slotsaccounts:
- name: openai-main
provider: openai
priority: 1 # lower = preferred
tier: ptu # ptu (provisioned, preferred) | paygo (default)
protocols: ["openai-chat", "embeddings"]
endpoint: "" # empty → mock transport, or the provider's endpoint when
# `provider` names a declared one; real base URL → real
# upstream; explicit "mock://…" → stays on the mock
timeout_seconds: 60 # per-attempt timeout (default 60): bounds a non-streaming
# attempt whole; a streaming one gets it on the headers and
# then per gap between chunks
connect_retries: 1 # connect-phase retries; also bounds retry_status replays
retry_status: [] # statuses this vendor issues BEFORE the model runs, so a
# replay cannot double-bill — e.g. [429, 502] for a relay
# whose 502 means its own upstream call failed. 4xx/5xx
# only; unset inherits the provider's list, [] never
# replays (the default). Replays honour Retry-After capped
# at 30s per attempt on top of each attempt's timeout, so
# worst case ≈ (retries+1)×timeout + retries×30s; statuses
# that outlive the budget still count against account
# health and can fail over to another account
api_key_env: "" # env var name holding the API key (never the key itself)
secret_key_env: "" # AWS only: env var of the secret key (api_key_env = access key id)
cost_input_price_per_1k_micros: 100 # optional: what this vendor charges us (margin accounting)
cost_unit_price_micros: 0 # and per non-token unit (see the model's unit_price_micros)
cost_output_price_per_1k_micros: 400
Secrets never live in config files: api_key_env names an environment
variable that is read per request. The optional cost_*_price fields record
what the vendor charges, so the ledger carries vendor_cost_micros alongside
the charged cost_micros and margin is queryable per tenant/model via
GET /admin/usage.
security, stability, productssecurity: # global default; a tenant may override it whole
dlp_redact: true # redact emails/phone numbers, both directions
detect_secrets: true # also mask API keys / credentials in inbound text
blocklist: ["badword"] # reject/flag requests containing listed terms
blocklist_action: block # block (deny) | flag (record) | shadow (trial a rule)
regex_rules: # named recognizers, each with its own action
- {name: ssn, pattern: '\d{3}-\d{2}-\d{4}', action: block}
moderate: false # route inbound text through the wired external moderator
# (its verdicts: allow / mask spans / degrade to the
# tenant fallback model / deny)
moderation_fail_open: false # on a moderator error: admit (true) or deny (false)
stability:
failure_threshold: 3 # consecutive failures before an account cools down
cooldown_seconds: 300
availability_window_minutes: 5 # /admin/models/status judgment window (max 60)
unstable_error_rate: 0.1 # window error rate that reports `unstable`
unavailable_error_rate: 0.5 # ... and `unavailable`
availability_min_samples: 20 # fewer samples than this reports `no_data`
products:
- name: myproduct
qpm: 120 # product-level request rate
abuse: # automatic suspension; omit = off
tiers: # highest tier at or under the day's reject count wins
- {rejects: 20, suspend_hours: 2}
- {rejects: 30, suspend_hours: 24}
alerts: # outbound webhook; omit = off
webhook_url_env: GW_ALERT_WEBHOOK # env var naming the URL (secrets stay out of config)
dedup_seconds: 300 # mute repeats of the same (kind, subject)
Every rule that fires (block / flag / DLP / moderation) is recorded without the
prompt text to the security-event stream (GET /admin/audit/events). The same
policy runs on the realtime WebSocket, so it is not a bypass. moderate needs a
moderator wired into the handler — the default one allows everything. See
Governance.
admin — the runtime-admin gateadmin:
token_env: GW_ADMIN_TOKEN # env var holding the global admin bearer token;
# absent (and no tenant admin_token_env) = the whole
# /admin/* surface answers 404
The global token manages everything; a tenant’s admin_token_env token is
scoped to that tenant (see API — Admin).
trust_proxy_headers: false # audit source IP: false = the real TCP peer (unforgeable);
# true = trust x-real-ip / rightmost x-forwarded-for hop
# (only behind a proxy that sets them)
GET /metrics serves the Prometheus registry: gateway_requests_total
(route/status), gateway_request_duration_seconds,
gateway_node_duration_seconds (pipeline stage), gateway_tokens_total,
gateway_cache_hits_total, gateway_ledger_write_failures_total, and
gateway_upstream_connect_retries_total (account),
gateway_upstream_status_retries_total (account, status),
gateway_thinking_signature_review_total (result) and
gateway_thinking_signature_cache_events_total (event). One structured access
log line per successfully served request goes to stdout.
export OPENAI_KEY=sk-... # your key, in your environment
# account in YAML: endpoint: "https://api.openai.com", api_key_env: "OPENAI_KEY"
cargo run -p gw-server
Accounts with an endpoint egress to it; accounts without one are served
by the in-process mock — unless the account names a declared provider,
whose endpoint and credentials it then inherits. An explicit
endpoint: "mock://…" opts a provider-bound account back onto the mock.
GW_TRANSPORT overrides the routing: mock forces zero egress (nothing
leaves the process), http disables the mock so misconfigured accounts
fail loudly instead of returning fake data.