Configuration Reference
Secret Guard starts from a TOML file (by default secret-guard.toml; change the path with
--config), plus a state file the program writes back automatically
(secret-guard.state.toml, the result of WebUI edits). Their division of labor:
| File | Role | Written by |
|---|---|---|
secret-guard.toml |
Declarative config (the subject of this document); read-only in-process, changes need a restart | You, by hand |
secret-guard.state.toml |
WebUI edit results + override decisions over declarative entries; delete to reset | The program |
An entity can have both a static and a dynamic source; the latter can override the former.
Override modes (OverrideMode): Default (dynamic acts as an incremental override),
PreferStatic (static wins), Disabled (disable the entry).
The config file may contain only what you need — every section is optional; omitted entries are empty and numeric values fall back to the defaults in the tables below.
Minimal Working Example
Section titled “Minimal Working Example”[[providers]]id = "openai-main"kind = "direct"api_key = "sk-your-upstream-key"[[providers.endpoints]]protocol = "openai"base_url = "https://api.openai.com"
[[secrets.entries]]id = "my-github-token"value = "ghp_0123456789abcdefghijklmnopqrstuvwxyz"[server] — Listening and Forwarding (changes need a restart)
Section titled “[server] — Listening and Forwarding (changes need a restart)”| Field | Type | Default | Description |
|---|---|---|---|
host |
string | "127.0.0.1" |
Listen address. Defaults to loopback only, to avoid accidental exposure to the LAN. |
port |
u16 | 8787 |
Listen port. Can also be overridden via --port / the SG_PORT environment variable. |
records_capacity |
usize | 1024 |
Upper bound of forward records kept in memory (evicted FIFO beyond that). |
upstream_connect_timeout_secs |
u64 | 15 |
TCP+TLS handshake timeout towards the upstream (seconds). 0 = unlimited. |
upstream_response_header_timeout_secs |
u64 | 60 |
Timeout waiting for upstream response headers (seconds), streaming tier: only applies to requests with an explicit stream: true (streaming response headers return once the first token is generated; 60s covers large-context prefill). Times out with a 504. 0 = unlimited. |
upstream_nonstream_response_header_timeout_secs |
u64 | 300 |
Timeout waiting for upstream response headers (seconds), non-streaming tier: applies to all other requests (a missing stream field counts as non-streaming). Non-streaming response headers only arrive after the entire response is generated — with large contexts (tens of k tokens) total time easily exceeds 60s, hence the 300s default. Times out with a 504. 0 = unlimited. |
upstream_stream_idle_timeout_secs |
u64 | 120 |
Maximum idle time between two chunks of a streaming response (seconds). 0 = unlimited. |
[redact] — Redaction Behavior (changes need a restart)
Section titled “[redact] — Redaction Behavior (changes need a restart)”| Field | Type | Default | Description |
|---|---|---|---|
global_mock_prefix |
string | "" |
A unified prefix for all Auto-mode Mocks (e.g. "sgm_"), making them easy to spot in logs / the WebUI. Once set, Secret values may not contain the prefix. |
on_probe_exhausted |
"fail_open" | "fail_closed" |
"fail_open" |
Strategy when Mock candidate probing is exhausted (extremely rare; requires adversarial construction): fail_open skips that Secret and forwards as-is; fail_closed rejects the entire request (503) — fail rather than leak. |
[usage] — Model Usage Stats (changes need a restart)
Section titled “[usage] — Model Usage Stats (changes need a restart)”Token usage data comes 100% from the usage field echoed by upstream responses (zero
local estimation); costs are estimated against the models.dev price
table (always an estimate; models without pricing show “—”). Details are persisted as an
append-only <stem>.usage.jsonl next to the config file (one line per request).
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | true |
Master switch; false = no collection, no persistence (zero overhead). |
retention_days |
u32 | 90 |
Detail retention in days; 0 = forever. Expired rows are cleaned at startup. |
pricing_url |
string | models.dev api.json | Pricing data source; can point at a self-hosted mirror. |
pricing_refresh_secs |
u64 | 86400 |
Price table refresh interval (seconds). |
pricing_override |
model → price table | empty | Custom model pricing (takes precedence over models.dev); see the example below. |
Pricing override example ($ / 1M tokens; cache_read / cache_write may be omitted —
they fall back to the input price / 1.25×input):
[usage.pricing_override."my-relay/gpt-fork"]input = 0.5output = 2.0Known limitation: OpenAI streaming requests only get usage echoed by the upstream when
the client sets stream_options.include_usage = true — such requests appear in the Usage
page with a request count only and no token stats (the page shows a notice).
[[providers]] — Upstream LLM Endpoints (flat array)
Section titled “[[providers]] — Upstream LLM Endpoints (flat array)”Each Provider is one of two constructions: Direct (a real upstream endpoint) or
Router (a routing endpoint that dispatches by the request’s model). The construction
is discriminated by the kind field ("direct" / "router", required — missing it
fails fast at startup), and the fields of the chosen construction live in the same
[[providers]] table. Fields that don’t match the construction are silently ignored:
base_url/api_key on a Router entry, or routes on a Direct entry, have no effect — don’t
rely on that; treat kind as the source of truth.
Shared fields (either construction):
| Field | Type | Default | Description |
|---|---|---|---|
id |
string | (required) | Unique identifier. 1..=64 chars, starts with a letter/digit, only letters, digits, _, -. Appears in forwarding URLs (/o/<id>/...). |
kind |
string | (required) | Construction discriminator: "direct" (direct upstream) or "router" (routing). |
enabled |
bool | true |
When false, forwarding to this Provider returns 503. |
name |
string | — | Optional human-readable name (WebUI display only). |
Direct construction (kind = "direct"):
| Field | Type | Default | Description |
|---|---|---|---|
protocol |
string | (required) | Upstream protocol: openai / anthropic / openairesponses (OpenAI Responses API); gemini / ollama are experimental pass-through (no codec — Secret Redact does not apply, and requests on those protocols are rejected by default when secrets are configured; see the protocol support matrix). |
base_url |
string | (required) | Upstream base URL. Must start with http:// or https:// and not end with / (Secret Guard appends the path). E.g. https://api.openai.com. |
api_key |
string | "" |
Upstream API key in plaintext. Mutually exclusive with api_key_file (setting both fails at startup). |
api_key_file |
path | — | Read the API key from a file (suits external injection like sops-nix / systemd LoadCredential, keeping the toml itself free of secrets). Read per request; an unreadable file is treated as an empty key with a WARN. Leading/trailing whitespace is trimmed. |
Router construction (kind = "router") — no protocol / base_url / api_key fields:
A Router doesn’t forward by itself; it matches the request’s model against routes and
resolves the chain down to a Direct Provider. The ingress protocol is decided by the
request URL and the egress protocol by the chain’s tail entity (cross-protocol
translation happens automatically when they differ) — hence the Router has no protocol
field (leftovers are silently ignored).
Routes live in the [[providers.routes]] sub-table array (at least one; an empty table
fails at startup):
| Field | Type | Default | Description |
|---|---|---|---|
model_pattern |
string | (required) | Model-name wildcard: only * is a metacharacter (matches any string, including empty); all other characters are literal, case-sensitive. "*" matches everything. Non-empty, ≤64 chars. |
target |
string | (required) | Target Provider id; may point at another Router (resolved down the chain to a real entity). A dangling target (nonexistent) is accepted at write time and 503s per request. An enabled self-loop fails at startup (disabled routes don’t count as cycle-check edges); cross-entry cycles are rejected at WebUI write time (cycles introduced by hand-editing config return 503 per request, without hanging). |
upstream_model |
string | — (passthrough) | Outbound model rewrite: the top-level model field of matching request bodies is replaced with this value. Omit to pass the client’s model through. Multi-hop rewrites along the chain are pipeline-semantics: the rewritten value participates in the next Router’s matching; the later hop wins. Non-empty / not only whitespace / ≤128 chars (clear it by omitting the field). Cost: while a rewrite is in effect the request gives up byte-passthrough (upstream prefix-cache invalidated); Gemini/Ollama have no codec to rewrite: WARN + verbatim passthrough. |
priority |
i64 | — (disabled) | Priority; higher wins. Omitted (null) = the route is disabled (excluded from matching and from cycle-check edges). Ties are broken by order of appearance in the list. |
[[providers]]id = "anthropic-main"kind = "direct"api_key_file = "/run/credentials/anthropic.key" # either this or api_key; also entry-level, before [[providers.endpoints]][[providers.endpoints]]protocol = "anthropic"base_url = "https://api.anthropic.com"Router Endpoints (routes) — Forwarding by Request Model
Section titled “Router Endpoints (routes) — Forwarding by Request Model”Clients connect to a fixed router endpoint URL (e.g.
/o/my-model/v1/chat/completions); which upstream actually serves the request is decided
by the model name in the request body — dispatch extracts the top-level model after
the body is collected and matches it against the routes:
[[providers]]id = "openai-main"kind = "direct"api_key = "sk-..."[[providers.endpoints]]protocol = "openai"base_url = "https://api.openai.com"
[[providers]]id = "anthropic-main"kind = "direct"api_key_file = "/run/credentials/anthropic.key"[[providers.endpoints]]protocol = "anthropic"base_url = "https://api.anthropic.com"
[[providers]]id = "my-model"kind = "router"[[providers.routes]]model_pattern = "claude-*" # claude-family models go to Anthropic, rewritten to a pinned versiontarget = "anthropic-main"upstream_model = "claude-sonnet-4"priority = 100[[providers.routes]]model_pattern = "*" # fallback: everything else (incl. requests without a model) goes to OpenAItarget = "openai-main"priority = 0Semantics in brief:
- Route selection: among enabled routes (
prioritynon-null) whose model_pattern matches the request model, the one with the highestprioritywins; ties are broken by order of appearance. The request model is the JSON top-level stringmodelfield — non-JSON / missing / non-string / empty body is treated as""(only"*"-style patterns can match it). - Model rewrite is a pipeline: a hit route’s
upstream_modelrewrite takes effect immediately — the next Router in the chain matches against the rewritten model; with multi-hop rewrites the later hop wins. This is how you switch between model families like gpt-4o and claude. - Switches affect new requests only (resolved per request, after body collection); in-flight requests complete against their already-resolved target.
- Error behavior (all 503): no matching route / dangling target on the chain /
target
enabled = false/ cycle. Enabled self-loops fail at startup (disabled routes don’t count as edges); cross-entry cycles are rejected at WebUI write time. - When the target’s protocol differs from the URL’s protocol, cross-protocol translation kicks in automatically (non-streaming; streaming cross-protocol returns 501, matching the direct behavior).
- The way the WebUI overrides a static Router is by submitting the complete routes array (see the WebUI Guide).
[[secrets.entries]] — Secrets to Protect (nested under [secrets])
Section titled “[[secrets.entries]] — Secrets to Protect (nested under [secrets])”| Field | Type | Default | Description |
|---|---|---|---|
id |
string | (required) | Unique identifier; same rules as Provider ids. |
name |
string | — | Optional human-readable name (WebUI display only). |
category |
string | "apikey" |
Category: password / apikey / token / cookie / privatekey / other. |
value |
string | "" |
Secret plaintext. At least 3 bytes; must not contain global_mock_prefix; must not contain Unicode private-use characters (U+E000..U+F8FF). Mutually exclusive with value_file. |
value_file |
path | — | Read once at startup (suits external injection like sops-nix); a failed read refuses to start (fail-fast: a missing Secret silently disables protection, so it must surface at deploy time). Leading/trailing whitespace is trimmed. |
mock_strategy |
table | (see below) | Generation strategy for the stand-in (Mock). Defaults to inferring from the real value: an equal-length, same-charset deterministic random value. |
The mock_strategy Sub-table
Section titled “The mock_strategy Sub-table”[[secrets.entries]]id = "my-github-token"value = "ghp_0123456789abcdefghijklmnopqrstuvwxyz"
[secrets.entries.mock_strategy]# Dimension one: initial value. Defaults to "auto" (system-generated).# Fixed stand-in style:# initial = { kind = "fixed", value = "ghp_FAKEplaceholder" }
# Dimension two: generation strategy (only in effect when initial = "auto").# Defaults to inferring from the real value.[secrets.entries.mock_strategy.gen]prefix = "ghp_mock_" # fixed prefixlength_range = [24, 36] # [min, max] character count (inclusive)[secrets.entries.mock_strategy.gen.charset]lowercase = truedigits = true# other switches: uppercase / underscore / hyphen / other = ['$', '#']Constraints: both values of length_range must be greater than 0 with min not exceeding
max; the prefix length must not exceed min; at least one charset class must be enabled.
A fixed stand-in must not equal the real value nor contain long substrings of it
(validated at startup).
[auth] — Authentication (off by default = single-user mode)
Section titled “[auth] — Authentication (off by default = single-user mode)”By default enabled = false: all routes are unauthenticated, suiting personal local use.
Enable OIDC login + API keys for multi-user / LAN exposure:
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
When true: the WebUI requires OIDC login, and forwarding paths require an API key. |
oidc |
table | — | OIDC Provider configuration (required when enabled = true). |
api_keys |
array | [] |
Statically preset API keys (can also be issued in the WebUI’s API Keys page). |
[auth.oidc] fields:
| Field | Type | Description |
|---|---|---|
issuer_url |
string | OIDC issuer (e.g. https://sso.example.com/realms/main). |
client_id |
string | OAuth2 client id. |
client_secret_file |
path | Optional; path to the client secret file. |
redirect_url |
string | Optional; callback URL. Only scheme/host/port may differ — the path is fixed at /oauth2/callback. Required when the listen address isn’t reachable from the browser (e.g. behind a reverse-proxied domain). |
[[auth.api_keys]] fields: label (unique identifier), and key or key_file (either
one; semantics as above).
[auth]enabled = true
[auth.oidc]issuer_url = "https://sso.example.com/realms/main"client_id = "secret-guard"
[[auth.api_keys]]label = "ci-pipeline"key = "sg_ci_abc123..."CLI Flags and Environment Variables
Section titled “CLI Flags and Environment Variables”secret-guard run starts the gateway. CLI flags / environment variables take precedence
over the config file:
| Flag | Env var | Default | Description |
|---|---|---|---|
--host |
SG_HOST |
falls back to [server] host (127.0.0.1) |
Listen address. |
--port |
SG_PORT |
falls back to [server] port (8787) |
Listen port. |
-c, --config |
SG_CONFIG |
secret-guard.toml |
Path of the declarative config file. |
--state |
SG_STATE |
derived from the config path | Path of the state file (secret-guard.toml → secret-guard.state.toml). |
Common Configuration Errors
Section titled “Common Configuration Errors”| Symptom | Cause and fix |
|---|---|
``TOML parse error … [[providers]] missing field kindvalue` |
The old kind = "virtual" is gone: switch to kind = "router", rewrite the old route_to target as a [[providers.routes]] entry (model_pattern = "*" + target = ...), and the old model_override as the route’s upstream_model field. |
Startup WARN unknown field route_to / unknown field model_override |
These two top-level provider fields are removed (leftover fields warn without effect): rewrite as [[providers.routes]] sub-tables (see the Router construction above). |
TOML parse error ... [[secrets]] invalid type: map, expected a sequence |
Secrets must be written as [[secrets.entries]], not [[secrets]]. A WARN line did you mean [[secrets.entries]]? is printed just before the error. |
Startup WARN unknown field ... did you mean ...? |
A misspelled field. Misspelled optional fields are ignored (WARN only) — “configured in name only”; fix them. A misspelled required field (e.g. protocol) warns and then fails startup with missing field. |
base_url must not end with '/' |
Drop the trailing /; Secret Guard appends the path. |
secret value too short (min 3 bytes) |
Secret values need at least 3 bytes. |
key and key_file are mutually exclusive / api_key and api_key_file both set |
Pick one; delete the other. |
provider ... must declare at least one route |
The Router construction’s routes is empty — an empty-route Router cannot forward anything and is rejected at startup. Configure at least one route (a model_pattern = "*" catch-all works). |
Request returns 404 not_found |
The Provider id in the URL doesn’t exist, or the proto prefix is misspelled (see Quick Start — URL Routing Conventions). |
Request returns 503 unavailable |
The Provider has enabled = false, or the route chain is broken: the message contains router provider '...' has no route matching model '...' (no matching route — check model_pattern and priority) / route target '...' not found (dangling target) / route target '...' is disabled / route cycle detected (a cycle from hand-edited config). |
A missing config file still starts the process (empty config + defaults, but nothing to forward to; the startup log carries a WARN).