Operations¶
Read the security boundary before exposing the listener.
The whole embedded dashboard is protected: every dashboard, /metrics/*,
/admin/* and /mcp request requires the MILLIVOLT_OPERATOR_TOKEN credential
and is denied while it is not configured. At the application gate, only the
unauthenticated /healthz liveness probe, the POST /session login handshake,
origin-root brand/PWA files and transparent inference stay open; see the
canonical operator access list.
Configuration and CLI¶
proxy.example.yaml is generated from the canonical
config.Example() and schema documentation. Example() starts with neutral
config.Default() values and adds the enabled
Grok, OpenCode Zen and Cursor compatibility profiles
and the opencode sub-conversation tracking exemplar (the promptCacheKey
tracked param, strip on). It is not mutable repository state. Source
deployments copy it to ignored local proxy.yaml; images bundle their own copy
for fresh config volumes. Do not start Settings against the committed example.
| Flag | Behavior |
|---|---|
-config PATH |
Config path; defaults to local proxy.yaml. Empty selects built-in defaults and disables Settings persistence. |
-listen ADDRESS |
Override the configured listen address. |
-db-path PATH |
Override durable storage; none disables it. |
-pid-file PATH |
Write/refresh the process PID after boot, including handoff children. |
-healthcheck |
Probe GET /healthz on the configured listen address and exit nonzero on failure. Loads config read-only; used by the image's Docker HEALTHCHECK. |
-print-config |
Print neutral built-in default YAML. |
-print-example-config |
Print the deployment example, including its enabled compatibility profiles and the opencode sub-conversation tracking exemplar. |
-version |
Print JSON build identity. |
The three output-only flags are mutually exclusive and exit without loading local config, opening a database or starting runtime services.
Precedence is built-in defaults, then the YAML file, then CLI overrides.
A missing file loads defaults; verify the path rather than assuming a typo
created a new empty overlay. On startup, unknown keys, unknown nested fields and
extra YAML documents are dropped and the file is rewritten without them. An
invalid recognized value fails startup without rewriting the file, and an
unparseable file is moved to PATH.invalid and replaced with the documented
defaults, except an ambiguous duplicate top-level key, which fails closed and
stays in place. Reload rejects an invalid document without rewriting it.
Settings, healthcheck and restore merge treat a missing file as defaults, while
an invalid or unreadable document fails closed and is reported instead of
substituting a default. Backup capture is stricter: a missing config file is an
error because there is nothing to archive. Settings POST still rejects invalid
inputs. CLI-overridden fields cannot be saved from Settings. Healthcheck loads
the file read-only and never rewrites it.
To regenerate the public example after changing its canonical owners:
go run ./cmd/proxy -print-example-config > proxy.example.yaml
The shared checks verify generated-file consistency. Built-in defaults remain
neutral; compatibility-profile values belong only in config.Example(), never
consumer fallbacks or private credentials. Optional mappings are mechanisms,
not provider registrations, for example:
providers:
neutral.example:
cost_keys: [billing.charge_usd]
usage_keys:
input_tokens: prompt_toks
Use the derived provider label as the map key. The example file and Settings schema document other maps, model rules and header templates; do not maintain a second complete list of defaults here.
Finding a setting¶
The generated example is the complete field reference: it includes defaults,
types, ranges, units and hot-reload/restart behavior. GET /admin/config exposes
the same field/category metadata alongside saved values, neutral defaults,
CLI overrides, the process-effective snapshot and the current revision. Its
last_reload section reports the most recent configuration application:
ok, error (empty when ok), dropped_keys, restart_required and the
unix-millisecond at timestamp.
Settings searches labels, keys and help across categories.
Durations and byte sizes use a magnitude plus unit (2s, 32MiB) in YAML and
Settings; do not write a bare number and assume seconds. A bare integer is still
accepted as a count of bytes. Integer counts (retries, percents, tokens) stay
unitless numbers with the unit in the schema label when it is not obvious.
| Settings category | Configuration area |
|---|---|
| Server | Listen/database paths, ring depth, shutdown/restart drain and HTTP server timeouts. |
| Request | Upload bound, allowed upstream prefixes, content preview, debug capture retention/size cap and token refresh. |
| Upstream | Response-header deadline, connection pools, model-discovery budgets and SSE keepalives. |
| Queue & retry | Per-key admission, queue capacity/wait, retry counts/hints, backoff, quality retries and thinking rescues. |
| Error storm protection | Enable provider/model detection, failure selectors and window/sample thresholds, recovery, queue/scope bounds, additional retries and banner visibility. |
| Quota pause | Reaction to durable quota/billing 429s: surface, provider-wide recovery gate or indefinite operator-held pause, and the recovery-probe success count. |
| Conversations | Automatic grouping idle gap, open-conversation cap and per-client tracked body params. |
| Format translation | Native-adapter defaults, the cross-protocol translation loss policy, Cursor parked-run lifetime and heartbeat. |
| Storage | Writer queue/batches/flush cadence, restricted-query time/output limits and the irreversible database deletes (filtered and whole-database). |
| Backup | Size cap for one Settings backup download or restore upload. |
| MCP | The operator-gated /mcp LLM tool endpoint: the enable switch, the result-size limits its tools clamp with, and the call timeouts. Restart to apply. |
| Dashboard | Request-log page size, KPI/chart/explorer refresh cadence and background refresh while its tab is hidden. |
| Models | Ordered model-name grouping rules and their preview. |
| Providers | Usage/cost field paths, discovery enrichment, upstream headers and provider aliases. |
| Request overrides | Scoped upstream rewrites: set, replace or remove headers and set output-token ceilings per client, provider and/or model. |
The table is a navigation aid, not another source of default values. Schema, configuration validation and generated YAML remain authoritative when a setting is added or changed.
Saving settings safely¶
Settings collects a draft until Apply. Revert reloads the saved document, and closing a dirty sheet keeps it open until Apply or Revert. The server validates the whole result and compares its revision before writing. A stale tab receives 409; reload/review its values instead of resubmitting an old full form.
API saves overlay supplied top-level keys on the saved config. Omitted keys
remain unchanged, but a supplied map/list replaces that entire field. For
example, providers, provider_aliases and model_rules are not recursively
patched. Read the current document and preserve wanted entries when editing a
structured field. Explicitly empty collections clear those fields.
The response separates saved values from the effective snapshot, reports
restart-required keys and carries the same last_reload section as GET; the
Settings sheet renders it as its last-apply row with apply time, failure,
dropped-key count and restart notice. A successful file write can still be
followed by a reload failure; saved:true means the file changed even when
the response reports an error. Do not assume rollback. The writable
indicator means a config path is configured, not that filesystem permissions
or every future write are guaranteed.
See persistence and
reload behavior before changing startup-bound fields.
Provider aliases and model grouping¶
provider_aliases merges provider labels in stored history at boot/reload and
rekeys new requests. All aliases in one map commit as a single transaction, so
a trigger or constraint failure on a later alias leaves every earlier label
unchanged. It rejects self-maps and alias chains. This is a durable rename, not
a temporary view: removing the alias does not reconstruct original labels.
Existing in-memory ring records can retain their previous label until they age
out or the process restarts. Back up before an intentional history rename.
model_rules instead controls display/grouping. The list is capped at 64 rules.
Its ordered exact, pattern and lowercase steps keep stored model spellings
unchanged; an empty rule list groups by those raw spellings. The Settings editor supports reordering, parking and
previewing rules. Raw export/delete predicates do not silently expand to a
display group. Debug's native-model normalization is separate from these rules.
Request overrides¶
request_overrides is an ordered rule list that rewrites the upstream request
before relay; the default empty list leaves every request untouched. Each rule
has a scope and at least one action: headers sets or replaces upstream
headers, remove_headers deletes them, and body sets the output-token
ceilings max_tokens / max_completion_tokens (1..1000000) on a Chat
Completions or Anthropic client body before translation. A Responses client
body is not rewritten by the body action because its cap is
max_output_tokens; header and removal rules still apply. A Chat client whose
target is the Responses wire has the rewritten cap translated to
max_output_tokens. Both override spellings address an Anthropic body's single
max_tokens field, with max_completion_tokens winning when both are set. The
list is capped at 64 rules and validated like every
other setting: a rule needs at least one scope field and one action, duplicate
scope triples are rejected, and one header name cannot be both set and removed
in the same rule.
Scope matching is exact leaf. A rule applies when every non-empty scope field
equals the request's value exactly: client against the classified client
label (X-Proxy-Client when sent, except a label that only repeats the user
agent's product name, which records the fuller user-agent identity; otherwise
inferred from SDK/user-agent metadata), provider against the canonical
label after provider_aliases,
and model against the recorded model id (for Cursor requests the base id the
dashboard records, not the fused spelling). An empty scope field matches
anything. There is no model-rule canonicalization, regex or wildcard broadening.
All matching rules apply in list order, and for the same header or body field
the later rule wins.
Header rules are the operator's last word on the upstream wire: the resolved
merge applies after client-forwarded values, configured provider headers and
the client's X-Proxy-Headers map, so a matching override wins over all of
them. The proxy-owned transport-mode names accept-encoding and range are
the exception: the client header maps (X-Proxy-Headers and an
X-Proxy-Auth-Header naming one) reject them with a 400 before any send, and
configured provider headers or request-override values are stripped from the
final upstream request, so the transport keeps its own gzip negotiation and
never sends a partial range. Credential and protocol names (authorization, cookie,
proxy-authorization, host, content-type, content-length,
transfer-encoding, te, connection, keep-alive, proxy-authenticate,
proxy-connection, trailer, upgrade) and the whole x-proxy- control
prefix are rejected at load in both header lists, and the client header maps
reject content-length before any send so the transport keeps ownership of
message framing; at runtime the target's configured auth header is skipped
even if one slips through, so credentials stay at their owner. The fixed list cannot know a provider's configured
auth header, so a rule naming the target's (for example x-api-key) passes
config validation and is denied at send time by the runtime belt. Header
names are trimmed and canonicalized
case-insensitively, and values must be non-empty single-line header values.
The body rewrite is fail closed. It applies to Chat Completions or Anthropic
client bodies before translation, whatever the target (a Responses client body
is not rewritten); when the body is not a JSON object, or the rewritten
result would exceed max_request_bytes, nothing is written: the original
bytes are used unchanged by the rewrite stage and are still translated when
translation is active, with one process log line recording the reason. Only a
passthrough relays them verbatim. A successful rewrite replaces the body for
the request's whole life, so every relay attempt, absorbed retry and quality
re-send reuses it, and the recorded request cap is re-stamped from the
effective body. A rule sets only the body
fields it names, and the re-read pair keeps the wire's documented precedence -
max_completion_tokens wins over max_tokens - so a rule setting only
max_tokens cannot raise a request whose client already sent
max_completion_tokens: set both fields to the same value to override either
spelling. Cursor targets carry no
token ceilings on their wire, so the body section is skipped for them while
header rules still apply at the native send.
Overrides apply to inference sends only, never model discovery or token refresh. The list hot-reloads: changed rules apply to new requests. The Settings editor mirrors the server's checks and rejections live for each rule, marks empty scope fields as matching any request, and blocks Apply on the first invalid rule.
To raise the output budget for one provider, either write:
request_overrides:
- provider: nano-gpt.com
body:
max_completion_tokens: 65536
or open Settings, select Request overrides, pick raise the output budget for a provider from the add-a-rule menu, and fill the provider scope; the other starter templates seed an empty rule and an upstream header for a client.
Sub-conversation tracking¶
sub_conversations gives requests from one client a conversation identity of
their own, taken from a value the client already sends in its request body
(exemplar: opencode's promptCacheKey). The default empty list leaves tracking
fully off, and the feature is deny-by-default per client: an entry applies only
when its client equals the request's classified client label exactly (the same
leaf the request-overrides client scope matches; no wildcard, no case
folding), and a request from any other client is never inspected, leaving its
bytes and grouping untouched.
Each entry lists 1..4 exact JSON field names, matched only at the request body's top level and checked in order; the first present field decides. A present value that is not a JSON string counts as absent and the next field is checked, while a present string that cannot be decoded or fails the identity rules drops the identity for the request - no later field is consulted. A value is kept only when it is non-empty after trimming, valid UTF-8, free of control bytes and at most 512 bytes, the same bound explicit session declarations satisfy. The UTF-8 judgment runs on the value's raw bytes before JSON decoding: the standard decoder coerces invalid UTF-8 inside strings to U+FFFD, so a raw invalid-UTF-8 sequence drops the identity while an escaped lone surrogate, whose bytes are ASCII, is kept and decodes to U+FFFD. The drop is conservative: the value's raw spelling is scanned inside a bounded window, so a value whose raw span exceeds the window (escape sequences spend more raw bytes than they decode to, and trimmable edges count before trimming) is dropped even when its trimmed form would pass. An absent or dropped identity is never a client error: on the passthrough wire the body is payload, and the request keeps automatic grouping. A translated target still requires the whole request body to be valid UTF-8, as protocol describes.
The kept value groups the request under a k: conversation identity.
Precedence is the explicit X-Proxy-Session header first, then the tracked
value, then automatic client+key grouping; a request carrying both header and
tracked value keeps the header identity. X-Proxy-Parent-Session still owns
parent linkage, and declaring a parent still requires the explicit session
pair, so a tracked identity never carries a parent. In the dashboard a k: id
is an ordinary conversation id: lineage, filters, saved views and the request
drawer read it like any other.
strip: true removes every configured field of the entry that is present at
the body's top level from the client body before relay or translation,
including a Responses client body; the default false forwards them
unchanged. Unlike the request overrides body fields,
strip is not limited to Chat Completions or Anthropic inbound. The removal
rides the same body-rewrite engine: one document decode when both features
match one request, the original bytes used unchanged by the rewrite stage when
the body is not a JSON object or the rewrite would exceed
max_request_bytes, and cursor targets skipping the body section. Without
strip, the client's spelling reaches the upstream wire on a same-protocol
passthrough; a translated body carries the value in prompt_cache_key or drops
it.
When a tracked value was extracted and the request translates to the Anthropic
wire, the translated body gains a top-level prompt_cache_key carrying the
value. This is a proxy tracking extension, not an Anthropic cache field:
Anthropic caching uses cache_control, and an upstream that rejects unknown
top-level fields will reject the injected request. Injection follows extraction
alone: strip governs the tracked client-body fields, never the injected
field.
Validation runs at the load boundary for YAML and Settings saves alike: at most 16 entries, at most 4 params per entry, param names one JSON key segment each (trimmed, non-empty, at most 64 bytes, printable ASCII, no quote, backslash or control bytes), and duplicate clients or duplicate params within one entry are rejected. The list hot-reloads: changed entries apply to new requests.
The Settings editor lives in the Conversations category and mirrors every check live per entry card: classified-client autocomplete from observed clients, ordered param rows with add/remove gated at the caps, the strip toggle, a count line against the entry cap, the server's exact rejection wording, and an Apply blocked on the first invalid card. To track opencode's prompt cache key and strip it from the upstream body, write:
sub_conversations:
- client: "opencode"
params:
- "promptCacheKey"
strip: true
Containers¶
The Dockerfile builds Linux amd64 and arm64 targets and runs
as UID/GID 65532:65532, with no source tree, Go toolchain or shell in the
runtime image. JavaScript/CSS are embedded in the Go binary; Node and Python
are test tools, not runtime dependencies. The binary is stripped of debug and
symbol tables. The final stage copies only that binary, dependency notices,
the generated example and prepared state directories onto the minimal distroless base.
The Compose quickstart downloads the image-only
compose.yaml. Normal deployment never builds from source.
Local builds use the separate
development Compose.
A registry tag is usable only after its publishing workflow succeeds.
Compose operation¶
Keep the same Compose project name to reuse its named volumes. The normal file
names the project millivolt; an explicit -p overrides it. The deployment
inputs are MILLIVOLT_IMAGE (image reference),
MILLIVOLT_PORT (loopback host port), MILLIVOLT_OPERATOR_TOKEN (the
operator credential, forwarded into the container environment only), and
MILLIVOLT_STOP_GRACE_PERIOD (container
stop grace). Their defaults live in compose.yaml, not server
configuration. Supply them consistently through the environment or an ignored
local .env file. docker compose config shows the resolved deployment.
The image carries a Dockerfile HEALTHCHECK that probes the unauthenticated
/healthz through the binary's -healthcheck mode, so docker compose ps
reports health without any operator credential.
# Start or adopt a newly pulled image while preserving volumes.
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail 100
# Stop without removing containers or data.
docker compose stop
# Remove this project's containers and network, retaining named volumes.
docker compose down
Do not add --volumes or -v to down unless deliberately deleting its
configuration and history. Changing the Compose project name selects different
volumes and can look like missing data. Back up before an upgrade; the existing
image can be replaced, but a database migration is not necessarily reversible.
See Docker's Compose lifecycle
and volume removal
documentation for the underlying behavior.
Do not add a build section to the deployment file. The development Compose owns source builds, its private port and its separate project identity.
The Compose service drops Linux capabilities, disallows privilege escalation,
uses a read-only root filesystem and a bounded temporary filesystem. These
controls do not add application authentication or a tenant boundary. Access to
a rootful Docker daemon through the docker group is effectively root access;
see Docker's Linux post-installation guidance.
Persistent state and configuration¶
| Container path | Persistence and permissions |
|---|---|
/data |
Writable database directory, including SQLite WAL/SHM sidecars. Mount the directory, not just proxy.db. |
/config |
Writable private configuration directory. The image bundles /config/proxy.yaml from the generated example; if deliberately absent, the application uses neutral built-in defaults. |
Fresh named config volumes receive the image's bundled example, owned by
UID/GID 65532:65532 with mode 0600. No separate configuration download is
required. Replacing an image does not overwrite an existing saved configuration;
review example-profile changes deliberately when upgrading.
These bundle semantics apply to images built from this revision. Existing release images are not changed by a main-branch update; consult the documentation at the selected release tag for older images.
Existing volumes and host bind mounts must permit the runtime UID to read/write
their contents. Bind mounts hide bundled files: supply a config yourself when
using one, or intentionally accept the application's missing-file defaults.
Use private directory permissions and mode 0600 for a supplied config; with
rootless Docker or user namespaces, account for the host UID mapping. Do not
make sensitive directories world-writable to work around ownership failures.
A first launch cannot prepare its own bind-mount host directories. When a
bind-mount target does not exist, Docker creates it as root:root mode 0755,
which the runtime UID cannot write, and startup fails with SQLite's
unable to open database file error. The log names the database path, the
directory state and the runtime UID. Pre-create the host data/ and config/
directories owned by 65532:65532 before the first start, or
chown -R 65532:65532 them after a failed start, then start the container
again. Named volumes avoid this entirely: Docker seeds them from the image
with correct ownership.
Settings saves by creating a temporary file beside the config and atomically renaming it. A read-only mount, or a bind mount of only the config file, cannot support that save contract. Mount the whole writable directory, or deliberately use read-only configuration and update it outside the dashboard. Do not mount the committed example as a mutable runtime config.
The container listens on :8080; restrict publication on the host with
-p 127.0.0.1:8080:8080. Binding to loopback inside the container prevents
ordinary published-port access. Docker port publication does not add operator
authentication. Do not share one database among independent proxy containers.
Upstream URLs are reached from the proxy container, so localhost in
X-Proxy-Base-URL means that container, not the Docker host. Use an upstream
hostname reachable on its network, such as another Compose service name.
Host-service access needs an explicitly configured host-gateway mapping or
another reachable host address; it is not included in the supplied deployment.
Arguments after the image replace its complete default command. If overriding
a server flag, also keep -config /config/proxy.yaml -db-path /data/proxy.db
unless intentionally selecting other paths or disabling storage. The
-print-config and -print-example-config commands print their respective
documents without starting a server.
Replace the container to upgrade or adopt startup-bound settings; the dashboard
cannot rebuild an immutable image. Keep the same volumes and back them up before
upgrades. If only a saved config file changed, ordinary up -d need not replace
the container. Apply startup-bound changes explicitly:
docker compose up -d --force-recreate millivolt
Container stop sends SIGTERM, which cancels unfinished requests before draining storage. Allow enough stop grace time for the configured shutdown and queued writes; forced termination can prevent that drain. No stop/flush can recover records already dropped under storage overload.
Image-only backup and restore¶
The supplied deployment can back up both named volumes without a source checkout,
Python, a shell inside the image or a helper image. This is an offline backup:
stop callers first, allow active work to finish, and keep the service stopped
while copying. Run from the deployment directory with the same Compose project
and environment used to start it. If you use -p, repeat that selection on every
Compose command. Do not concurrently replace or restart the source container.
The following saves the original Compose file, the pulled image's registry digest,
and both volume archives in a new private directory. It expects the supplied
single-service layout, /data and /config, with a saved proxy.yaml present.
Privately retain any local .env or other deployment inputs as well.
(
set -eu
umask 077
backup_dir="$(mktemp -d ./millivolt-backup.XXXXXXXX)"
printf 'Backup directory: %s\n' "$backup_dir"
container_id="$(docker compose ps -q millivolt)"
test -n "$container_id"
image_id="$(docker inspect --format '{{.Image}}' "$container_id")"
docker image inspect --format '{{index .RepoDigests 0}}' "$image_id" > "$backup_dir/image.txt"
cp compose.yaml "$backup_dir/compose.yaml"
docker compose stop millivolt
test "$(docker inspect --format '{{.State.Status}} {{.State.ExitCode}}' "$container_id")" = 'exited 0'
docker compose cp millivolt:/data/. - > "$backup_dir/data.tar"
docker compose cp millivolt:/config/. - > "$backup_dir/config.tar"
docker compose start millivolt
)
A failed stop or copy exits without automatically restarting the service; inspect
the failure before resuming. Copy the completed backup to protected independent
storage. The data archive includes any WAL/SHM files, not just proxy.db. Never
use this file-copy procedure while SQLite is open. An archive cannot recover
records already dropped or writes lost through forced termination.
Restore into a new project and fresh volumes, leaving the original deployment
and backups untouched. Enter the completed backup directory and an unused
loopback host port when prompted. The original supplied Compose file is required:
do not substitute resolved docker compose config output, custom external-volume
names or bind mounts, which can point the new project at existing data.
(
set -eu
umask 077
printf 'Backup directory to restore: '
IFS= read -r backup_dir
test -s "$backup_dir/data.tar"
test -s "$backup_dir/config.tar"
MILLIVOLT_IMAGE="$(head -n 1 "$backup_dir/image.txt")"
case "$MILLIVOLT_IMAGE" in *@sha256:*) ;; *) exit 1 ;; esac
printf 'Unused loopback host port: '
IFS= read -r MILLIVOLT_PORT
case "$MILLIVOLT_PORT" in ''|*[!0-9]*) exit 1 ;; esac
test "$MILLIVOLT_PORT" -ge 1
test "$MILLIVOLT_PORT" -le 65535
export MILLIVOLT_IMAGE MILLIVOLT_PORT
restore_dir="$(mktemp -d ./millivolt-restore-XXXXXXXX)"
restore_project="$(basename "$restore_dir" | tr '[:upper:]' '[:lower:]')"
cp "$backup_dir/compose.yaml" "$restore_dir/compose.yaml"
restore_compose() {
docker compose --env-file /dev/null -p "$restore_project" -f "$restore_dir/compose.yaml" "$@"
}
existing_containers="$(docker ps -aq --filter "label=com.docker.compose.project=$restore_project")"
test -z "$existing_containers"
existing_volumes="$(docker volume ls -q --filter "name=^${restore_project}_")"
test -z "$existing_volumes"
restore_compose create millivolt
restore_compose cp --archive - millivolt:/data < "$backup_dir/data.tar"
restore_compose cp --archive - millivolt:/config < "$backup_dir/config.tar"
restore_compose up -d millivolt
restore_compose logs --tail 100 millivolt
printf 'Restored project: %s\nCompose directory: %s\nHost port: %s\n' "$restore_project" "$restore_dir" "$MILLIVOLT_PORT"
)
Keep the tar streams intact and retain --archive on restore: the private config
must remain readable/writable by runtime UID/GID 65532:65532, with mode 0600.
Check Settings and retained history on the new private port before redirecting
clients. Save the printed project name and deployment inputs for later commands;
the example bypasses automatic .env loading during recovery.
Docker documents tar-stream copying and Compose archive mode. The repository's isolated container check exercises stopped-volume backup and restore into fresh volumes, including settings, history and file permissions. Source deployments can instead use the live SQLite online-backup helper or the Settings archive described next.
Settings backup and restore¶
The dashboard Settings Backup category downloads and restores a self-checked
.mvb archive. Choose config, database, or both. The archive is a packed
SQLite snapshot (VACUUM INTO, so free pages are not copied) and/or the
schema YAML, then zstd-compressed with a content checksum and an outer SHA-256
of the uncompressed payload. Encoding streams each member into the compressor
while hashing and counting it, writes the archive once into a staging file,
and self-checks that file before returning it; an archive that fails decode
or semantic checks is never emitted, so a produced backup is never a silent
corrupt file. The database member is read from the packed snapshot file in
bounded chunks instead of being copied whole into memory, so the download cost
does not grow with a heap copy of the database. A source file that changes
size or cannot be read fails the encode before an archive is emitted.
Restore streams the upload through the backup_max_bytes ceiling into one
private staging file and decodes it with the same header, zstd, SHA-256 and
member-framing checks. When a live store can stage it, the database member is
written straight into a private file on the live database's volume instead of
being materialized on the heap; a restore that selects the database member
without a store fails closed before staging, and a member the request did not
select is stream-discarded after its framing checks. Config bytes stay
buffered within the same 16 MiB config-document bound
Config.Validate enforces on the canonical rendered YAML, so a config that
validates can never exceed what an archive may retain. A config member that
declares a larger size is rejected before its bytes are copied, and encoding
refuses to emit one, so an older archive whose config member exceeds that bound
fails restore. The bound covers only the config member; the database member
stays streamed under the payload ceiling. It validates that hash, the
zstd frame, member framing and config Validate() before applying anything,
and a selected database member additionally passes PRAGMA integrity_check
before it is staged. Decode refuses an uncompressed payload larger than the
Schema maximum for backup_max_bytes, so a small compressed file cannot expand
without bound. Download lists how many live settings differ from default and how
many stored requests would be packed. Picking a file first inspects the
archive: created time, size, member sizes, each config key as default vs
modified vs the live file, and the database request span when a live store can
stage the member. Inspect loads the live file through the same strict, bounded
reader used by config reload and restore, not the boot repair path: an unknown
nested field, an extra YAML document, a non-regular file, or a file above the
16 MiB document bound fails the inspect instead of comparing against a repaired
or partial view. Inspect decodes and counts the database member in place and
computes its overlap with the live store without writing another copy. Without a
live store it reports present:true, the decoded bytes, inspectable:false
and the reason, and produces no request span.
Config replace loads and validates the backup, writes canonical YAML, and
preserves CLI-overridden listen and db_path. Config merge overlays only
keys that differ from Default() onto the live file, so live custom values
stay unless the backup also customized them. An omitted config_mode or
database_mode defaults to replace; an explicitly empty or whitespace value is
rejected as malformed rather than treated as absent. Either way the restored config
hot-applies through the same owner as a Settings save, is reflected in
last_reload, and reports restart-required keys. Database
replace streams the decoded file into a pending snapshot next to db_path,
syncs it, and atomically renames it into place; Open admits the pending file
only after it checks again, and the restore waits for process restart.
Database merge attaches the decoded file read-only and inserts request ids
that are not already in the live store immediately (live rows win on conflict);
the attached snapshot rejects writes, while the live database stays writable
for the merge, and no restart is required. Its request rows, debug sidecars and
since-inception totals commit as one transaction, so a failed totals write
leaves no partial merge behind. Truncated or bit-flipped files are rejected.
The backup_max_bytes setting is the archive ceiling; encoding enforces it
while the stream is written, so an over-cap archive is removed instead of
partially served, and an over-cap upload answers 413 before decoding.
Restore phases are ordered, not atomic. The database member runs first: a
replace stages the decoded file and preflights it with the same schema,
writer-INSERT and request-scan value checks boot runs before touching the live
database, and a merge commits rows into the running store. Only then is the
config written and hot-applied. A failed stage, preflight, or merge leaves the
config file and live snapshot unchanged, and a failed config write or reload
rolls the config back and restores a replace's previous pending slot. A
successful live merge has no rollback: if the later config phase fails, the
merged rows stay applied. A crash between the database and config phases is
likewise not atomic; the pending snapshot or completed merge can be durable
while the config file still has its previous bytes. Preserved .incoming,
.previous, and .snapshot-state files next to db_path are boot-recovery
evidence and may need operator inspection after such a crash; do not remove them
while a restore may have been in flight. A combined config and database merge
answers 400 when the restored config's effective db_path differs from the
running store's file, because the rows would be written to a database the next
boot does not open; merge the database separately or use database replace.
At boot, a pending snapshot is admitted only after the same schema, trigger,
writer-insert and request-scan value preflight Open runs. Recovery consults
the durable .snapshot-state marker rather than inferring a transition from the
files present. A markerless rollback set from a pre-marker release cannot be
classified: when a live main exists, recovery keeps it and removes the
.previous debris. Discarding a non-empty .previous-wal in that markerless
state logs a startup warning, because its committed frames cannot be
distinguished from debris and may be lost. Marked pending and committed recovery
behavior is unchanged.
Both directions stage database-sized transient files in the live database's own
directory, so capacity follows the data volume rather than a hardened
deployment's small /tmp tmpfs. Budget roughly one compressed upload plus one
packed database copy during a restore, and for a replace also the pending copy
at db_path. The download's VACUUM INTO snapshot directory is removed after
the archive is encoded, including on failure or a client disconnect. A restore
removes its private upload file and decoded database directory after inspect or
apply on every path: a normal response, an invalid or over-cap archive, a
validation or apply failure, or a client disconnect. A config-only upload may
use the process temp directory because it carries no database-sized member. A
packed file already on the staging volume (VACUUM dest, decoded member, pending
.incoming) is integrity-checked in place, and inspect, overlap, replace and
merge consume it by path, so none writes a second database-sized copy. A
container's /tmp is a small tmpfs by design and never caps backup size.
Versions and images¶
VERSION is the application release source. The dashboard asset fingerprint is separate: it detects changed embedded frontend bytes, not a semantic release or a complete backend change.
main is promotion-only. Full source, browser and live MCP integration, and
native container checks run on all testing pull requests, code-bearing
testing pushes, manually dispatched testing gates, and manually pushed v*
tags; docs and site-only pushes use docs.yml. They also run on Dependabot's main security PRs. An ordinary
promotion pull request from testing requires its head to be the current
testing tip, main to be already merged back into testing, and a
successful full-gate record for that exact commit: the canonical check.yml
push run's own check suite, with checks and both native container legs. When
a final docs-only commit follows the closest gated testing commit, it reuses
that gate if every path since it is docs/site-only; the fallback selects the
closest gated testing commit from canonical testing push runs, with no
commit-count bound. The proof polls
pending exact check runs a bounded number of times and fails closed if they are
still pending. For a code-bearing head, let the testing check.yml push gate
finish before opening or re-running a promotion PR. A docs/site-only head has no
canonical check.yml push run of its own, so wait for docs.yml or only the
required docs validation. It does not rerun the suite. A code-bearing promotion must also have a VERSION
newer than every existing release tag. Docs/site-only promotions skip the
version and full-gate proofs, validate docs, workflow policy and the site in
required within its ten-minute budget, and do not rebuild images or cut a
release. Merge with a merge
commit. The merged-PR release workflow tags that reviewed merge commit and
publishes the image; a delivered code-bearing main push is an idempotent
fallback. It never pushes main. Dependabot security pull requests are the
only other exception and normally do not cut a release by themselves; an
untagged pending VERSION can publish. A manually pushed v* tag ships exactly
as tagged. The full maintainer checklist lives in
Contributing.
go run ./cmd/proxy -version (or /millivolt -version in the image) prints
JSON with version, revision, modified, and go_version. Unavailable VCS
metadata is reported honestly as revision unknown or modified:null.
The registry target is ghcr.io/llm-4-people/millivolt. Check the
container package
and image workflow for what has
actually been published; a version in a checkout does not prove an image exists.
Use a published digest to pin exact bytes. Version tags identify releases but
are not intrinsically immutable; moving branch and stable aliases can change.
The workflow runs source, race and browser
checks, then native container smoke checks on Linux amd64 and arm64 for all
testing pull requests, code-bearing testing pushes, manually dispatched
testing gates, and manually pushed v* tags, and for Dependabot's main
security PRs. Docs and site-only pushes use docs.yml instead. A normal
promotion PR proves the current testing tip, merged-back main and the full-gate
record for that commit, or for the closest gated testing commit with only docs
changes after it; docs-only promotions validate the docs, workflow policy and
site directly. The merged promotion PR carries the reviewed merge commit and
releases it; a delivered code-bearing main push only reuses the same reviewed
merge and is idempotent.
Publication is limited to this repository's merged promotion pull requests,
delivered pushes to main, matching release tags, and testing-branch pushes
(which publish only the moving testing tag). The release publish job waits
for required. The publish-testing job waits for checks and container
instead, because a testing push has no promotion proof to wait for. A release
run whose VERSION is already tagged, such as a Dependabot security merge,
normally does not publish; if main carries an untagged pending VERSION, that
run can publish it. Open or unmerged pull requests do not publish images; a
merged promotion PR does.
| Image tag | Meaning |
|---|---|
main |
Moving production image for the released current main revision; updated by non-prerelease versioned releases from a merged promotion PR, a delivered main push or a release tag. A prerelease does not move it. |
testing |
Moving development image after a successful testing-branch push; never a release. Its OCI version label reports the embedded VERSION, which can lag the latest release. |
sha-<full commit> |
Release tag for the exact cut revision built and published; the version bump is part of the reviewed promotion merge, so this is a main commit. Testing pushes do not publish it. |
Version from VERSION |
Created by the matching v-prefixed release tag on the promotion merge commit, or by a manually pushed tag. |
latest |
Moving stable alias updated by every non-prerelease release, including a release cut by a merged promotion PR, a main push or a tag. Prereleases and testing pushes do not update it. |
Release builds request an SBOM and max-mode provenance. The image and its
attestations are published before the tag and release are updated; the workflow
never pushes main. A failed image build does not leave a public release
pointing at a missing image. Verify
the published manifest and attestations with docker buildx imagetools inspect
and pin the digest for deployment. A tag name alone is not an integrity check.
Set MILLIVOLT_IMAGE to the desired full image reference or published digest
before the Compose pull/up commands. The 0.x version series is initial
development, not a promise of stable APIs across releases.
GitHub initially publishes GHCR packages privately, even when their repository is public. A package administrator must make the package public separately and verify an anonymous pull. Repository linkage grants workflow access, not public visibility; see GitHub's package access documentation.
Reload, restart and shutdown¶
SIGHUP, POST /admin/reload, a successful Settings save, and a backup
config restore use the same hot-reload owner. Invalid reloads preserve the
running config. Reloadable transport/scheduler settings affect new work
without closing active transports.
Startup-bound consumers keep their boot settings until restart. Settings reports
restart_required; its effective configuration snapshot can contain new values
that those consumers have not adopted yet. The schema/example identify which
fields require restart.
Rebuild & restart requires Go on PATH and the original source tree available at its compiled-in path. Moved/trimpath builds may report it unavailable. It builds first, stops acceptance while retaining the listening socket, drains active handlers, flushes storage, then hands the socket to a ready child. Drain timeout or flush failure aborts and resumes the old process. Parked native-adapter runs are process-local and do not survive a successful restart.
Ordinary SIGINT/SIGTERM shutdown is different: it cancels active stream contexts, then shuts down HTTP and drains/closes storage. Do not describe it as preserving unfinished streams. Already-dropped records cannot be recovered by any flush.
Starting another matching proxy executable on an occupied port can trigger same-binary port reclamation (SIGTERM, then escalation). This is not the graceful rebuild workflow; choose distinct ports and databases for separate instances.
Operator and data routes¶
The routes below belong to millivolt. They are operator-gated unless the row
is marked open; the canonical open-path list is in
operator access. Read handlers enforce their supported
methods; wrong-method requests must not fall through to inference. The
namespace is standardized: /metrics/* and dashboard routes are the protected
read plane, and /admin/* is the operator action plane; the credential gates
both.
| Route | Action / important contract |
|---|---|
GET /healthz |
Unauthenticated liveness probe for Docker HEALTHCHECK and load balancers. Reports process/HTTP liveness only, no storage depth. |
GET /favicon.ico |
Unauthenticated ICO brand mark. Browsers fetch this without Authorization; it is registered on the mux so it never reaches the inference catch-all. |
GET /favicon.svg, /apple-touch-icon.png, /icon-*.png |
Ungated SVG/PNG icons for tabs, home screens and the web app manifest. |
GET /manifest.webmanifest |
Ungated web app manifest (name, standalone display, 192/512 icons). |
GET /sw.js |
Ungated service worker. Precaches brand icons/manifest; cache-first only for a /dash/ URL carrying exactly the stamped version, network-first with cache fallback for unversioned or stale /dash/ URLs and brand paths. Navigations to / are fetched live (never cached: login vs bootstrap) with a static offline fallback. Never intercepts /metrics/*, /admin/*, /session/* or /v1. |
POST /session |
The one open operator route: exchanges the credential for the session cookie the dashboard's live feed needs. Throttled like every gated route. Lives outside /admin so an external authentication layer can front the admin plane without gating millivolt's own login. |
GET/POST /admin/config |
Schema/file/effective state; save {revision,values}. Stale revision returns 409. Saved-but-reload-failed is explicitly reported. GET and save responses carry a last_reload section: ok, error, dropped_keys, restart_required, at. |
POST /admin/reload |
Re-read config and report restart-required keys. |
GET/POST /admin/restart |
Status / rebuild. GET ?watch=1 streams progress; concurrent starts are rejected. |
GET/POST /admin/pause |
Inspect/add/edit/resume holds. POST requires paused; optional ID targets one hold. |
GET/POST /admin/quota |
Inspect storm state (shared with the banner) / POST {"provider","resume":true} closes a retry-mode quota gate; parked sends resume immediately. |
GET/POST /admin/debug |
Inspect/add/edit/stop capture sessions. POST requires enabled; starting a session requires durable storage (503 durable storage is disabled otherwise); optional ID targets one session. |
GET/POST /admin/throttle |
Inspect/provider-limit updates; POST requires provider. Supplied limits merge; clear:true removes policy. |
GET /admin/debug/capture?id= |
Load an unexpired durable debug sidecar; absent/no-store returns 404. &download=1 serves the same document as a gzip artifact named millivolt-debug-<captured_at>.json.gz, falling back to the record id when captured_at does not decode. |
POST /admin/purge/count |
Preview the same deletion/export predicate; traffic can change the count afterward. Filters are body-only: any query key is rejected with 400 before the body is read. |
POST /admin/purge |
Delete matching finalized records; genuinely no body means all. An empty/invalid supplied object is rejected. Filters are body-only: any query key is rejected with 400 before the body is read, so ?provider=... can never broaden the no-body full delete. |
GET /admin/backup |
Download the self-checked .mvb Settings backup archive of config and/or database members. ?config=1 and ?database=1 select members, defaulting to both when no key is given; an explicit empty selection is an error. The query grammar is strict: parse errors, repeated or unknown keys, and malformed values all return 400. |
POST /admin/restore |
Restore an uploaded .mvb Settings backup archive, validated before anything is applied. ?inspect=1 previews the archive (created time, member sizes, config keys vs default and live, and the database request span when a live store stages the member) without applying anything. A store-less preview still reports the member's presence and decoded size with inspectable:false and the reason. ?config=1 and ?database=1 select members as on backup; ?config_mode= and ?database_mode= take merge or replace and default to replace when omitted; an explicit empty or whitespace value is rejected as malformed. Success reports the restart_required keys. The query grammar is strict: parse errors, repeated or unknown keys, and malformed values all return 400. |
GET /metrics/export |
Download all or exactly filtered finalized records as a gzip artifact named millivolt-logs-<timestamp>.json.gz; gunzip it for the JSON array. Debug-only export can contain sensitive sidecars. |
GET /metrics/query?q= |
Restricted SELECT with timeout/output limits; 503 when durable storage is disabled. Not a hostile-query sandbox. |
GET /metrics/bootstrap |
Dashboard state and full/incremental recent-record snapshot. |
GET /metrics/live/stream |
Replayable finalized SSE feed plus ephemeral pending lifecycle/reset events. |
GET /metrics/agg/chart, /explorer, /log |
Scoped history chart, faceted explorer, and durable log paging. |
GET /metrics/prometheus |
Prometheus exposition over the in-memory ring, not durable since-inception dashboard totals. |
GET /metrics/pprof/ |
Standard Go profiling handlers behind the same credential as every /metrics route: the index listing plus the profile (CPU), heap, allocs, goroutine, block, mutex and threadcreate profiles at /metrics/pprof/<name>. Unregistered subpaths answer the reserved namespace 404, never inference. See the capture workflow below the route table. |
POST /mcp |
Streamable HTTP MCP endpoint, operator-gated like /metrics and enabled by default: mcp_enabled: false removes it from the process (the route stays reserved, answering 404 and never inference; restart to apply). Serves the 22 MCP tools. Bearer-only: the gate admits the session cookie on gated paths, but this endpoint refuses a cookie-only request (403) and forwards the caller's own Bearer on its in-process calls to the routes above, so no session cookie or per-request state is retained between requests; the admitted credential keys an in-memory cache of its SDK server object (never logged or persisted) so repeated calls do not rebuild it. Stateless: GET/DELETE answer 405. /mcp/... look-alikes are reserved and answer 404, never inference, and a reserved-namespace spelling whose separator is written single-encoded (%2f or %5c, any hex case) is refused the same way. A double-encoded separator (%252f) is not a reserved-namespace spelling: it decodes to a literal %2f inside one segment, so it can reach neither the MCP handler nor a reserved namespace and is ordinary inference traffic. |
Pause/Debug/Limits successes may include a persistence warning: runtime state was applied, but saving it failed. Do not retry as though the mutation rolled back. With storage disabled, Pause and Limits keep their memory-only state intentionally. Debug is the exception: starting a session answers 503 because with no store every matched request would retain bodies for a sidecar that could never be fetched.
The profiling subtree is operator-gated like every /metrics route: an
unarmed plane answers 403, a wrong credential 401, and a reserved-namespace
spelling with an encoded separator answers the reserved 404. go tool pprof
cannot send an Authorization header, so fetch the profile with curl and
analyze the file against a locally built binary. Against the dev instance
(scripts/dev.sh, loopback 127.0.0.1:8081):
go build -o /tmp/millivolt-profiling ./cmd/proxy
curl -fsS -H "Authorization: Bearer $MILLIVOLT_OPERATOR_TOKEN" \
-o /tmp/cpu.pprof 'http://127.0.0.1:8081/metrics/pprof/profile?seconds=30'
go tool pprof /tmp/millivolt-profiling /tmp/cpu.pprof
The seconds=N parameter on /metrics/pprof/profile is the capture window:
that one request stays open for the whole window while samples accumulate. A
process runs one CPU profile at a time, so a second concurrent profile
request fails with 500 until the first finishes. On /metrics/pprof/heap,
gc=1 forces a garbage collection cycle before the sample.
The proxy's own /mcp endpoint serves this whole surface to an LLM, using the
same credential and the same routes. It adds no route of its own and dispatches
each tool call in process through the same gate, so its forwarded Bearer is
re-validated exactly like an external request. For its irreversible call, the
operator credential and the tool's own guards are the entire gate: an empty
purge filter, a purge without the exact confirmation phrase, and a purge
without the purge_preview token issued for that same filter are each refused.
It also refuses a capture session on a proxy without durable storage, and an
unknown name in a capture scope or a throttle provider is offered the known
values instead. The throttle provider check fails open when the proxy's
vocabulary is empty or cannot be read; the capture scope check accepts any name
when the vocabulary is empty but refuses when the vocabulary cannot be read.
It never follows a redirect, and it presents the credential only in the
Authorization header. See the MCP server guide.
Operator access¶
MILLIVOLT_OPERATOR_TOKEN is the single operator credential for the whole
dashboard. It is read once at boot from the process environment, never from
proxy.yaml, so it cannot leak through Settings, -print-config,
/admin/config output, snapshots or logs.
The gate is deny by default and lives in one chokepoint in front of every route. This is the canonical open-path list; Security and the reverse-proxy guide summarize it instead of maintaining a second list.
- Open:
GET /healthz, thePOST /sessionlogin handshake (the one open operator route), origin-root brand/PWA files (/favicon.ico, icons,/manifest.webmanifest,/sw.js) and transparent inference. Provider credentials ride the same header name and are never inspected by the gate. - Gated: the dashboard HTML and
/dash/*assets, every/metrics/*surface and every/admin/*route, including unclassified future admin paths and unsupported methods, plus the/mcpendpoint and its reserved/mcp/*look-alikes. A gated MCP request also needs its credential as a Bearer header: the session cookie alone is refused there (403), because the caller's credential is forwarded on the endpoint's internal calls.
Present the credential as Authorization: Bearer <value>. A valid Bearer
request on a dashboard, admin, metrics or dash route also mints a session
cookie, because the dashboard's live feed uses EventSource, which cannot send
Authorization headers. The /mcp endpoint deliberately returns no cookie: its
caller forwards the credential on the endpoint's internal calls. The cookie is
HttpOnly (script never reads it), SameSite Strict, scoped to 12 hours, and
signed with a key derived from the operator token, so a container restart with
the same credential keeps the session. Rotating the token invalidates outstanding
cookies. The cookie deliberately carries no Secure flag:
millivolt's listener is plain HTTP, and the documented TLS deployments
terminate at the ingress, where the remaining loopback hop is trusted-local.
Do not expose the listener over plaintext networks anyway. The login page
served for unauthenticated dashboard visits exchanges the entered value for
that cookie through POST /session and works without JavaScript; API
clients can call the same endpoint or simply send the Bearer header on every
request. An expired cookie re-prompts in the dashboard or reappears as the
login page on navigation.
Unregistered /admin/*, /session/*, /metrics/* and /mcp/* paths are reserved: they
answer 404 and are never forwarded upstream. The gate also refuses 404 when an
owned-namespace path is written with a percent-encoded separator (%2f or
%5c, any hex case): the mux cannot route it to the reserved subtree, so it
would otherwise fall through to inference. A double-encoded separator
(%252f) is not a reserved-namespace spelling: it decodes to a literal %2f
inside one segment, matches no reserved route, and is ordinary inference
traffic.
Failed authentications are throttled per source IP (RemoteAddr only;
forwarded-header chains are never trusted, since no trusted-proxy model
exists): after five rejected credentials the IP is locked out with a window
that doubles from 10 seconds to a 15 minute ceiling. The throttle counts only
presented-but-wrong credentials, so an expired-cookie dashboard polling in a
background tab cannot lock its own operator out, and a verified credential is
always admission: presenting the correct Bearer during a lockout clears the
budget, because the lockout exists to rate-limit guessing, not to lock the
operator out of their own dashboard. The map is hard-capped at 4096 source
IPs with idle expiry, so spoofed-address floods cannot grow it. A lockout
response is 429 with a coarse rounded Retry-After and a generic body.
With the variable unset or empty the deny-all state holds: the whole dashboard
returns 403, the open POST /session handshake cannot mint a cookie, and only
/healthz, brand/PWA files and inference respond. A nonempty value shorter than 16
or longer than 512 bytes fails the boot instead of silently weakening
the gate. The credential is process bound: config reload does not re-read it,
so rotating the value requires a real process restart (a supervisor restart
or a fresh deployment; the dashboard's restart handoff inherits the parent
environment, so it keeps the old credential by design). The dashboard asks
for the value once per browser tab, keeps it in sessionStorage, attaches it
to gated calls only and drops it as soon as the server rejects it.
Non-browser callers send the header directly, for example
curl -H "Authorization: Bearer $MILLIVOLT_OPERATOR_TOKEN" -XPOST .../admin/purge.
Environment variables are readable by same-user processes through /proc,
so run the proxy under a dedicated account.
Pause and Limits¶
Pause creates holds; it does not disconnect already-running sends. Matching new sends and retries wait until the hold expires or is resumed. Choose all traffic, named clients/providers, or clients that were unseen when a New-clients hold was created. "New" does not mean new requests or new conversations. Multiple names within a dimension are alternatives; provider and client constraints intersect. When named clients and New are combined, either client condition may match.
A hold has its own duration and queue cap; the cap defaults to max_queue_size
when the hold does not set one. Excess waiting work can be refused;
an indefinite hold needs an explicit resume. Existing holds can be edited in
place or resumed individually, while Resume all clears all holds. Overlapping
scopes are rejected instead of silently replacing a different hold.
Limits applies to a provider across all clients and keys, independently of the per-key concurrency/queue settings. Choose concurrency, requests per window and tokens per window, then Apply; Clear removes that provider policy. The menu shows whether a policy came from the UI or a request header, together with available budget and in-flight/queued state. Header semantics are in protocol. Persisted policy does not mean the live scheduler's counters and in-flight requests survive a restart.
Operator limit edits persist synchronously and report a warning when the store rejects the write. Header-driven limit changes apply in memory immediately and are persisted by one coalescing background writer: a burst of changes becomes the latest whole-policy document, with bounded retry and backoff after a store failure. A crash can therefore lose the most recent header-driven changes, and a failed save never rejects inference. Graceful shutdown and restart flush the latest policy before storage closes or the restarted process opens the database. Provider labels are capped at 255 bytes and the persisted throttle document at 8 MiB; normal provider labels derived from base URLs remain below both bounds.
Admission is bounded end to end. max_inflight_requests is the process-wide
inference ceiling (default 10000), max_inflight_body_bytes its buffered
body budget (default 256 MiB), and max_inflight_response_bytes its budget
for buffered upstream response bodies (default 256 MiB): whole-document
translation of non-streaming responses, the non-streaming 200 quality spool
(at most 1 MiB plus one 32 KiB chunk), and model discovery. Requests above
any bound are refused with 503 and Retry-After before the body or upstream
response is read. Request-body reads must make progress within
request_body_idle_timeout and finish within the absolute
request_body_read_timeout, so a stalled peer releases its reservation
without cutting off a slow but progressing upload. Buffered whole-document
response reads use whole_document_read_timeout when the send has no deadline
and whole_document_read_max as the ceiling even when X-Proxy-Timeout-Ms is
longer, so a slow or stalled body releases its response-buffer reservation; a
shorter caller deadline wins and configuration validation preserves the
models_discovery_timeout budget below the ceiling. The three admission
budgets accept 0 = unlimited and hot-reload.
max_queue_size (default 1024
per group) and max_queue_wait (default 10m) bound a held or saturated
provider's parked work; both accept explicit 0 = unlimited. Independent
per-key/header concurrency remains unlimited by default. The scheduler retains
at most 100000 distinct provider+key groups, evicting the least-recently-idle
group (no in-flight or queued work) to admit a new key at the bound; a new key
is refused only when every retained group has active work, so a table full of
active groups still fails closed. Recent pacing/backoff state for evicted
groups is kept in a separate 4096-entry bounded LRU. The scheduler also
retains at most 4096 header-created provider limits, refusing new ones past
that bound rather than growing process memory without limit. A new request
body has the request_body_read_timeout budget, SSE writes have the
sse_write_progress_timeout per-event progress bound, and an upstream body
that stops sending for upstream_stream_idle_timeout is canceled.
Request and token budgets start full when enabled and refill continuously at the configured count/window rate. Request budget is charged once per proxy admission, not again for each absorbed upstream retry. Token admission reserves an estimate from captured prompt characters divided by four plus a positive captured output limit, then settles against final reported usage. A reservation larger than the bucket's entire capacity can proceed while credit is positive, putting the bucket into debt that refill or usage settlement can repay. These controls permit bursts and oversized requests; they are not strict fixed-window provider quota enforcement.
Error storm protection¶
Open Settings, select Error storm protection, and turn on Enable protection. It is opt-in in both neutral defaults and the deployment example. Choose the rolling window, minimum samples and failure threshold, then select HTTP failure statuses, transient transport failures and response/stream failures. Provider protection requires all models receiving requests in that same window to meet the thresholds individually. A single affected model is held on its own while other active models remain healthy. If only one model is active, that model can establish a provider-wide incident. Provider and model protection can also be enabled independently.
Configure initial/maximum recovery delays, multiplier, jitter, recovery successes,
additional retries, queue capacity, per-wait timeout and retained scope capacity
in that category. The generated example supplies every
type, range and default. Existing Queue & retry limits and provider Limits still
apply; neither storm_max_wait nor a per-send deadline is a total request timeout.
Use a client timeout appropriate to the desired end-to-end wait.
Each clickable banner identifies its current scope and safe failure category (HTTP status, transport error or response error), percentage/count of failed upstream attempts, detection window, queued callers and next probe or recovery progress. Open an incident to inspect the distinct requests that encountered a selected failure, failed attempts, total sampled attempts, error category, queue count and next retry time. Provider entries also show affected versus active models. Distinct request counts follow each request's latest selected failure in the rolling window, so repeated retries do not inflate that count. The percentage still describes upstream attempts. These are process-local observations, refreshed through the normal dashboard bootstrap/poll cadence; they are not chart history or the Errors chart's distinct-request percentage. Scope counts can overlap where a caller waits on both provider and model protection. Hide the banner independently without disabling protection.
Changing a storm policy resets its samples/incidents and wakes waiters to re-evaluate the new policy. Banner visibility alone preserves detector state. A reset does not cancel work already sent or erase request history. Restart starts detection afresh, and queued requests do not persist. History deletion does not reset live outage protection.
Storage stays outside the detector: fixed 64 time buckets retain counters per
scope, with oldest-edge precision within approximately storm_window / 64.
storm_max_scopes includes provider aggregates and exact provider/model scopes;
stale unreferenced healthy scopes can be evicted, while active or referenced
scopes remain protected. Scope identifiers over the internal 512-byte retention
bound and exhausted scope capacity reject admission rather than silently bypass
protection. These bounds are not a total heap ceiling: waiting callers retain
bounded request bodies, and existing ordinary queues remain separately sized.
The protocol guide defines retry safety,
quota exclusions, native handshakes and client-facing failures.
Quota pause¶
Open Settings, select Quota pause, and choose the reaction to a 429 whose
error envelope is a durable account condition - insufficient_quota,
insufficient_credits, spend/hard limits, the recognized numeric billing codes
(the classification is fixed; only the reaction is configurable):
- off (default): the 429 surfaces to the client immediately. Nothing is parked or retried.
- retry: a provider-wide recovery gate opens on the first such 429 from an
OpenAI-wire provider. The triggering request absorbs the 429 and re-sends
per the error storm recovery delays; every new request to the provider
parks on the gate (bounded by
storm_max_queueandstorm_max_wait); the gate closes afterquota_pause_recovery_successesconsecutive successful probes (the default 1 closes on the first 2xx) and the queue drains. A providerRetry-Afteron the quota 429 paces the next probe like any other 429. - manual: an indefinite provider-scoped pause (an operator hold labeled with the quota class) is created automatically; every request, triggering one included, queues until an operator resumes it on the pause surface. When a pre-existing hold already covers the provider, no duplicate is created: the triggering request queues under that hold only if it parks the triggering client too, and a hold that parks other clients only lets the triggering request surface the 429 immediately.
The retry-mode gate shares the storm scheduler's parking, probes and pacing,
so storm recovery settings configure it without storm protection being
enabled. It surfaces in the storm banner as a quota incident with a
Resume now action (POST /admin/quota); manual-mode holds appear on the
pause surface with their reason and resume like any hold. A quota 429 is
never marked as a rate limit, and quota re-sends never burn the transient
max_retries budget. The retry gate is in-memory - a restart drops it and
the provider's next durable quota 429 re-arms it - while manual-mode holds
persist like every operator pause. In-band occurrences (the same envelope
inside a 200 stream) arm the pause the same way; that request already
answered, so it is never parked. Translated Anthropic-format and Cursor
upstreams keep the off behavior in every mode.
Debug and preview capture¶
Debug selects clients, providers and/or models and starts a capture session for a chosen duration or until stopped. At least one dimension is required; selected dimensions intersect, with alternatives inside each dimension. It does not have an unrestricted "all" switch. Sessions can be edited or stopped individually; Stop all ends every active session. Conflicting scopes are rejected.
Debug model matching uses the native-model normalization in debug.go, separate from display-only model rules. Do not assume that every model spelling is an independent exact scope.
The session timer selects future captures. debug_capture_ttl separately
controls retained capture lifetime after completion, and
debug_capture_max_bytes bounds captured request plus client-facing response
bytes. Oversized captures are marked truncated. debug_store_max_bytes
(default 512 MiB) caps the whole capture sidecar; once the next write would
exceed it, new captures are skipped and TTL expiry frees capacity again.
Capture writes are counted in bytes, not characters. The request drawer loads an
available sidecar only for a captured request; expired/absent captures are not
recreated from ordinary metrics. Completion is published before the sidecar is
encoded and saved, so a slow sidecar write delays neither the live end nor
durable accounting. A drawer fetch that follows the live end waits for that
request's in-flight save instead of answering a transient 404; a disconnected
client stops waiting.
Starting a capture requires durable storage. With db_path empty,
enabled:true without an ID answers 503 durable storage is disabled and
creates no session. A store-less process
also has no persisted session document to restore, so it never starts with an
enabled session; if one were present in memory, the request path still refuses
to build a capture tap, so no headers or bodies are retained for an unfetchable
sidecar. Editing or stopping a named existing session remains available.
Retained request headers use the same policy as recorded response headers:
names are visited in sorted order for the budget, each value is capped at 4 KiB
and marked with … when cut, at most 16 values are kept per name, ordinary
headers share a 16 KiB total budget per header map, a header name is capped at
512 bytes, and at most 256 names are retained per map. A longer name or an extra
name is omitted without a marker, exactly like a value beyond the budget.
Credential headers (the
configured auth header, Authorization, cookies, and token-, API-key- or
secret-bearing names) become [REDACTED] before any truncation, so a cut
value is never a credential prefix. Headers or values beyond the budget are
omitted without a marker; the capture schema has no header-truncation field.
The capture JSON is encoded without HTML escaping, so <, > and & appear
literally while JSON control escapes remain.
The drawer's Debug section also carries a Download button. It fetches the same
capture with download=1 and saves a gzip artifact named
millivolt-debug-<captured_at>.json.gz, using the record id in the name when
the capture does not carry a readable captured_at.
capture_body_preview is a different opt-in feature: it retains short prompt/
response previews in the normal record without starting a Debug session. The
two features also retain different request bytes: on the passthrough wire
the record's prompt preview is the client's original body, captured at the
metadata decode before request_overrides or the sub-conversations strip
rewrites it, while a debug capture's request body is the rewritten upstream
body sent after any rewrite. A translated Chat Completions or Anthropic target
re-decodes the translated body for its prompt preview; a translated Responses
target keeps the client-body prompt preview, because the generated item list is
not re-decoded for preview. The response preview follows the client-facing
response content on every target. Both features can retain sensitive content
even when credential headers are redacted.
Exports/backups can outlive the configured retention period. See
Security.
Logs and deletion¶
Logs downloads finalized request records as JSON, not the process's stderr log and not a complete administrative audit trail. With durable storage it reads the database; otherwise it uses available ring records. Open the menu, choose a filter and review its matching count before Download matching, or deliberately choose Download all. A debug-only export can include retained capture sidecars.
The download is always a gzip artifact named millivolt-logs-<timestamp>.json.gz;
gunzip it to read the JSON array of records. Compression is applied at the
export writer itself, so every export shape (filtered, all or debug-only,
from the ring or durable storage) downloads compressed the same way. This is a
breaking change for scripts that consumed the plain JSON download: there is
no uncompressed fallback, so scripted consumers must decompress the artifact
first.
A durable export holds one read snapshot for the whole stream, so the server bounds it with an internal total budget. If the budget expires after bytes were written, the download is aborted mid-artifact instead of completing as a successful gzip: the saved file is partial and must be discarded. A caller cancellation or shorter caller deadline is reported separately from this server budget.
In the dashboard, both database deletions live in Settings, in the storage
category, and nowhere else. Delete matching uses the same filter/count owner
as the export, and its scope is the chosen filter; Delete everything wipes
the whole database. Deletion requires confirmation in the dashboard; it does
not happen when the sheet opens. A selected filter makes a read-only
POST /admin/purge/count preview. Current traffic can change the count
before the action, but the UI retains the previewed age cutoff rather than
silently moving it. The filter selections reset every time the sheet
refills, so a stale invisible filter never arms a destructive default.
Both actions are irreversible without a backup.
Export/delete filters are exact raw record fields, not display-name model canonicalization. The API also supports conversation/error-type/time filters beyond the menu's common choices. See the shared filter decoder and PurgeFilter. Empty-body full deletion is deliberate and destructive; never use malformed JSON as an all-records command. The delete fence preserves newer completions and in-flight work.
Settings and Restart¶
Settings only reads configuration when opened. Its search, category rail and structured editors work on a local draft; Apply uses the revision-checked save contract.
Opening Restart reads availability and status. Restart now is disabled while a restart is in progress or rebuilding is unavailable. Progress reports actual build/drain/flush/handoff phases; it is not a simulated timer. After a successful handoff, reconnect/bootstrap refreshes history, aggregates, operator state and config. Changed embedded frontend assets trigger a page reload through that same refresh owner. The lifecycle section distinguishes source rebuilds from container replacement and ordinary shutdown.
Dashboard data and request inspection¶
The KPI band is global. Explorer selections and the request status filter scope the timeline and request log; selecting a card adds a filter and entity links pivot within that shared route. Browser back/forward restores hash-based scopes. Chart preset, range, percentile and series visibility persist in browser storage. These saved views are local browser state, not shared server settings.
Explorer dimensions are Providers, Models, Client, Conversations, Tools, Time, Status, Errors and Keys. Their meanings are server-owned: model grouping and time-bucket membership are not browser guesses. Conversations can show declared parents and main/sub/unresolved counts; read the lineage contract before interpreting them. Tools and error signatures can each have multiple memberships per request. Tool cards count affected requests for N and share, with request-level card metrics counted once per affected request; their calls KPI separately sums request ToolCalls, so repeated same-name calls do not inflate the request count. A request using several tools contributes to each tool card. Error/429 health badges and every gallery share use distinct affected requests.
The visual guide covers every timeline and its controls. Overview is the summary: it carries no plot, and its tiles take the whole card (requests over the blended token volume, tokens in/out/cached with their shares, spend with the blended per-Mtok price, the errors / 429 pair, and average latency / speed over every measured request with each metric's low-high sample range); Traffic compares requests/errors; Tokens separates available input/output/reasoning/cache usage and tracks the cache-hit share of input on a percentage axis; Speed + latency plots throughput on its own axis with TTFT and the decode window (first token to last, the span decode speed measures) sharing a duration axis; Errors compares error count/rate; Cost places reported USD spend beside request volume. One percentile selection controls the timing series (the Overview keeps only the time-range control). Period readouts are computed from period samples, not averages of bucket percentiles. Insufficient samples remain unavailable. All time means the available retained history, not deleted, dropped or never-observed requests.
Request rows and details¶
The log starts with recent rows and pages older durable records as you scroll. Live rows show in-flight/queue/retry progress; final rows retain their outcomes. Retry disclosure opens absorbed attempts under the original request, not another inference record. Click the detail arrow or a non-link row cell to open its drawer; previous/next controls navigate requests without changing the scope.
The drawer presents available facts in sections: request identity/status/times; client/runtime and provider metadata; captured model and parameters; conversation turns and content-size counts; input/output/cache/reasoning tokens; performance, cost and finish state; tool names/calls; queue/rate-limit information; retries, errors and retained headers. Recognized parameters include sampling and output limits, stop/logprob controls, reasoning/verbosity, response format, tool choice and streaming flags. Explicit zero/false parameters remain distinct from absent ones. Ordinary HTTP relay metadata does not infer missing parameter defaults. Adapters differ: Anthropic metadata comes from the translated body, including its configured output-limit default, and Cursor stores the base model ID rather than its fused spelling. See adapters before treating these fields as an exact representation of the caller's original request.
Opening ordinary details does not fetch private request content. Prompt/response previews appear only if captured, and Debug sidecars use their separate opt-in and retention contract. Even ordinary labels, error text and headers can be sensitive; do not publish request drawers from private history without review.
Reading timing, cost and health¶
Request duration starts after upload and includes metadata work, queue/hold/retry
waits and response handling. TTFT is measured from the upstream attempt's own
send start to the first token (reasoning or answer), so every retry has its own
TTFT and the value excludes queueing, request parsing and retry backoff. A
request that produced no token, and any non-streamed response, has no TTFT: the
log shows a placeholder, not 0 ms, and aggregates skip it. TPS is the single
generation-speed measure: (generation tokens - 1) over the first-token to
last-token window. Average TTFT and average TPS are the arithmetic means of
those per-request samples. TPS is absent for fewer than two tokens, a
non-positive window, or a response that did not stream (no token timestamps).
The compact surfaces (the KPI band and the Overview timing tile) abbreviate
this unit as t/s; roomier readouts spell it tok/s.
The numerator assumes the first observed content chunk carries one token; a provider that bundles several tokens into that chunk can
still overstate the rate. These are provider-attempt measurements, not claims
about total user wait or system-wide throughput.
Money stays USD in storage and APIs. The shared UI formatter uses cents below $1, including fractional cents, and dollars for larger magnitudes. Usage/cost comes from recognized upstream fields or configured field mappings; unavailable reporting and a displayed zero do not establish a zero bill. Reasoning/cache splits depend on what the upstream actually reports. When a stream carries no usage object, or carries one whose output count is zero, the recorded output token count is restored from the streamed chunk count, which includes tool-argument fragments; the count is an estimate, not a provider report. Chat tool calls are deduplicated by delta index while the retained index set holds up to 1,024 distinct indexes. Once the set is full a further index cannot be retained, so each of its fragments counts again; a hostile stream of repeated indexes can overstate the tool-call count without growing memory past the bound.
Errors count affected requests with a genuine failure, including recovered
upstream failures. HTTP 429 counts requests that encountered a final, retried or
in-band 429 (a 429 whose status line a committed 200 could not relay); the two
signals can overlap, but 429 alone is not an error. Client-local
cancellation is likewise distinct from an upstream failure - except a 499 whose
record carries a structured error type: that observed the provider's in-band
stream failure before the client left (typically an error event the client
received and aborted around), and counts as the provider error it is. Zero
explorer health badges are hidden independently. Error-dimension card
headlines count failure events (final plus absorbed), while card N counts
distinct affected requests. The gallery share denominator error_total is the
union of affected requests represented by the error cards, so a card's share
is N / error_total and can never exceed 100%. This denominator includes
recovered quality failures whose final status is not an error (for example an
absorbed empty-completion attempt), counting each affected request once even
when no request ends in an error status. For every other dimension
error_total remains the canonical affected-request error count. Do not treat
every displayed error number as the same metric.
The explorer gallery renders at most 24 cards. Its fold tracks exact
per-value accumulators for at most 8192 distinct values in the active
dimension; later values are combined into a synthetic other card with
aggregate count, cost, token, tool and health fields, competing for the 24
cards by that count. The synthetic card carries synthetic: true on the wire,
renders as other (grouped), and is not filterable. A real value named other
keeps its own exact card and filter; other is a display bucket only when the
card is synthetic. Rail distinct-value counts, footer scope totals and
conversation summaries still cover the whole filtered history, so card counts
need not add up to them.
Snapshots, live feeds and monitoring¶
The HTML includes the canonical bootstrap state so the first paint need not wait for a second state request. Live SSE carries pending progress and finalization; KPI/operator polling and configured aggregate refresh keep derived surfaces current. Reconnects use sequence/feed identity and fall back to full snapshots when replay cannot be trusted. The footer distinguishes live/offline, active holds/Debug, process-local storage drops and totals degraded by a failed startup history scan. An offline indicator alone does not prove inference is down. None of these mechanisms makes initial history preload instantaneous.
For integrations, use the documented read routes rather than scraping the DOM. Bootstrap/live data is a recent observation window; scoped aggregate routes query available history; JSON export provides finalized records; restricted SQL queries durable data within configured time/output limits. The API is in initial development, so pin and verify the selected revision for integrations.
Prometheus is a bounded-ring view. Its request/token/error totals are gauges that
can shrink after eviction, purge or restart, not monotonic lifetime counters;
do not apply counter-only rate()/increase() assumptions to them. Its TTFT
quantiles use the ring's sample contract, which differs from dashboard R7 history
percentiles. Metric names and units are exposed with HELP/TYPE metadata at
/metrics/prometheus; prometheus.go owns them.
An unchanged finalized ring revision serves the exact last exposition without
another ring copy, aggregate or TTFT sort; any ring content transition rebuilds.
Operator recipes¶
The gated query route (GET /metrics/query?q=, above) and the MCP
query tool both take exactly one SELECT with no statement separator, and
results are bounded by storage_query_max_rows and
storage_query_max_bytes; an over-budget result is an error on the route, so
keep wide scans behind a started_at window or a LIMIT. Column meanings
come from the MCP describe tool. started_at is Unix milliseconds: a
relative window is started_at >= strftime('%s','now')*1000 - 3600000, and
calendar math divides by 1000 and applies SQLite's unixepoch modifier,
with localtime for the host's zone. The recipes below earn their keep in
the derived shapes no dashboard surface computes: the outlier ratio, the
write/read ratio, the latency decompositions. Their ingredient aggregates
are conceded up front, the way the decode window already is: the explorer
sums per-group counts and cache reads (its N and Cache fields) and a
per-group blended $/Mtok (its cost_per_mtok KPI, the same cost-reporting
in+out rule), and the chart's dec series samples the shared decode window
(last_token_at - first_token_at) that recipe 1 decomposes latency
around. Several of the columns a recipe reads do overlap the aggregate
scans: ttft_ms, reasoning_tokens, status_code, attempts,
output_tokens and cache_read_tokens all appear in the dashboard's scan
lists. The recipes' private reading set, which no aggregate scan reads, is
queue_wait_ms, first_answer_at, retry_after_ms,
cache_write_tokens, the chars_* columns, total_tokens,
req_max_tokens, duration_ms and retries. Each recipe is a
question, the SQL that answers it, and how to read the result.
Where did the time go on a slow request?
SELECT started_at, duration_ms, queue_wait_ms, retries,
first_token_at - started_at AS pre_token_ms,
ttft_ms,
last_token_at - first_token_at AS decode_ms,
started_at + duration_ms - last_token_at AS tail_ms
FROM requests
WHERE started_at >= strftime('%s','now')*1000 - 3600000
AND first_token_at > 0
ORDER BY duration_ms DESC
LIMIT 20
pre_token_ms is the wait the caller felt, including queue and retry waits;
ttft_ms is measured from the adopted upstream attempt's send start, so a
retried request shows a small ttft beside a large pre_token_ms. decode_ms is
the streaming window and tail_ms what remains after the last token, where a
millisecond of rounding noise is normal. A request that produced no token, or
was not streamed, has nothing to decompose, hence the first_token_at > 0 gate.
How long did a model think before answering?
SELECT started_at, model, reasoning_tokens,
first_answer_at - first_token_at AS thinking_ms
FROM requests
WHERE started_at >= strftime('%s','now')*1000 - 3600000
AND first_answer_at > 0
ORDER BY thinking_ms DESC
LIMIT 20
thinking_ms spans the first token of any kind, reasoning included, to the
first answer token; 0 means the first token already was the answer.
first_answer_at is streaming-only on the generic relay; cursor-format
non-streaming requests set it (the bridge synthesizes each delta and tracks
it via cursorTrackDelta), and such rows legitimately enter the gate because
first_token_at is stamped by the same helper. Tool-call-only streams keep
0 and stay outside the gate.
How long does an agent idle between conversation turns?
SELECT conversation_id, started_at,
started_at - LAG(started_at + duration_ms)
OVER (PARTITION BY conversation_id ORDER BY started_at) AS idle_ms
FROM requests
WHERE started_at >= strftime('%s','now')*1000 - 86400000
AND conversation_id != ''
ORDER BY conversation_id, started_at
idle_ms is the gap between one request's end and the next request's start
inside the same conversation; the first turn has no predecessor and stays
null. Order by idle_ms DESC instead to surface the biggest stalls first.
What retried before the client saw a response?
SELECT r.started_at, r.id, r.retries, r.status_code AS final_status,
j.value->>'status_code' AS attempt_status,
j.value->>'at' AS attempt_at,
j.value->>'retry_after_ms' AS retry_after_ms
FROM requests r, json_each(r.attempts) j
WHERE r.started_at >= strftime('%s','now')*1000 - 86400000
AND json_array_length(r.attempts) > 0
AND COALESCE(json_extract(j.value,'$.in_band'),0)=0
ORDER BY r.started_at, j.key
One row per absorbed retry, oldest first. The in_band filter excludes a 429
recorded behind a committed 200: that entry is health evidence, not a retry.
The json_array_length gate is required: attempts defaults to the JSON
literal null, and json_each over that literal still yields a row. retry_after_ms appears (nonzero)
when the provider sent a Retry-After header or a ratelimit-reset header
(x-ratelimit-reset-requests/-tokens, anthropic-ratelimit-requests-reset,
anthropic-ratelimit-tokens-reset), bounded by the proxy's hint ceiling.
Did a conversation's cache writes pay for themselves?
SELECT conversation_id, COUNT(*) AS requests,
SUM(cache_write_tokens) AS cache_written,
SUM(cache_read_tokens) AS cache_read,
CASE WHEN SUM(cache_read_tokens) > 0
THEN ROUND(1.0 * SUM(cache_write_tokens) / SUM(cache_read_tokens), 2)
END AS write_per_read
FROM requests
WHERE conversation_id != ''
GROUP BY conversation_id
ORDER BY cache_written DESC, cache_read DESC
LIMIT 20
write_per_read at or below 1 means the reads repaid the writes; reads with
no writes ride a cache somebody else warmed. A conversation with writes and
no reads paid for a prompt that never came back.
When does traffic arrive?
SELECT strftime('%w', started_at/1000, 'unixepoch', 'localtime') AS weekday,
strftime('%H', started_at/1000, 'unixepoch', 'localtime') AS hour,
COUNT(*) AS requests, SUM(cost) AS cost,
ROUND(AVG(duration_ms), 1) AS avg_ms
FROM requests
GROUP BY weekday, hour
ORDER BY weekday, hour
weekday is 0 (Sunday) through 6 and hour is two digits in the host's
local zone; drop localtime for UTC. Cost is USD, summed as stored.
How far does the token estimate drift?
SELECT model, COUNT(*) AS requests,
ROUND(AVG((chars_system + chars_user + chars_assistant + chars_tool) / 4), 1) AS avg_chars_est,
ROUND(AVG(input_tokens), 1) AS avg_input,
ROUND(AVG(1.0 * input_tokens /
((chars_system + chars_user + chars_assistant + chars_tool) / 4)), 3) AS input_vs_chars4,
ROUND(AVG(1.0 * total_tokens /
((chars_system + chars_user + chars_assistant + chars_tool) / 4 + req_max_tokens)), 3) AS total_vs_est
FROM requests
WHERE input_tokens > 0
AND chars_system + chars_user + chars_assistant + chars_tool > 0
GROUP BY model
ORDER BY input_vs_chars4 DESC
The scheduler reserves prompt chars/4 plus req_max_tokens when the client
sent one. input_vs_chars4 compares real input tokens with the chars/4
share, and total_vs_est compares settled total tokens with the full
reservation. A ratio far from 1 on a well-represented model means the
reservation systematically over- or under-reserves for it.
Which requests paid far above their model's blended rate?
SELECT o.model, o.id, o.started_at,
ROUND(1e6 * o.cost / (o.input_tokens + o.output_tokens), 2) AS per_mtok,
ROUND(1e6 * a.cost_sum / a.tok_sum, 2) AS model_avg_per_mtok,
ROUND((1e6 * o.cost / (o.input_tokens + o.output_tokens)) /
(1e6 * a.cost_sum / a.tok_sum), 2) AS ratio
FROM requests o
JOIN (SELECT model, SUM(cost) AS cost_sum,
SUM(input_tokens + output_tokens) AS tok_sum
FROM requests WHERE cost > 0 GROUP BY model) a ON a.model = o.model
WHERE o.cost > 0 AND o.input_tokens + o.output_tokens > 0
ORDER BY ratio DESC
LIMIT 10
per_mtok is the request's own rate over its in+out tokens, and ratio
scales it against the model's blended average, computed over cost-reporting
requests only so unpriced traffic cannot dilute it. A ratio far above 1
marks expensive outliers such as cache misses or long-tail pricing; flip the
sort to find the bargains. SQLite has no median(), which is why the
blended average plus a sorted ratio stands in for it.
Storage and accounting¶
SQLite retains finalized records; the ring and pending registry support live observation. Full bootstrap windows are capped in durable mode and older log rows page from storage. Without storage, the ring is the available history.
The durable log order owns one secondary index, idx_requests_log(started_at,
id). The log page pushes exact provider, client, key-hash, conversation and
canonical numeric status predicates into SQL and rechecks them on that scan;
model, status-class, tool, error and time-bucket filters stay Go-owned because
they match canonical/derived values, not raw stored text. The export and purge
filter surface is different: it accepts exact provider, model, client,
conversation, error type, status code, has_error, debug, and time bounds,
with no key-hash selector, and its equality-only preview count or purge scans
the table instead of maintaining per-dimension composite indexes.
BenchmarkStoreDiskBytes measures
this on its deterministic synthetic fixture (10,000 and 100,000 seeded
records); on that fixture the 100,000-row database fell from 118.2 MiB to
45.4 MiB. It is a reproducible fixture scope, not a prediction of a
deployment's disk use or a capacity ceiling.
Dropping a superseded request-log index only moves its pages to SQLite's
freelist, so the first startup that finds and removes one also runs a one-time
VACUUM after the migration. Fresh and already-migrated databases skip it. A
VACUUM needs free temporary space up to roughly twice the database size; if
it fails for space, locking or interruption, startup logs a warning and
continues with every row intact, but the freed pages stay on the freelist.
Reclaim them manually during a maintenance window: stop the proxy, run
sqlite3 /data/proxy.db 'VACUUM;' (or any SQLite client) against db_path,
and confirm PRAGMA freelist_count returns 0. The automatic pass is never
retried on a later boot, so a failed attempt needs this manual step.
Enqueue is bounded and nonblocking. Overflow or write failures can lose durable
records even when HTTP succeeds. Bootstrap's
storage:{enabled,dropped,totals_degraded} reports cumulative process-local
loss and a failed startup history scan. Either appears in the status footer;
degraded since-inception totals count only rows committed since process start.
Increasing queue size absorbs bursts, not arbitrary sustained overload.
Storage overload remains an unresolved capacity boundary, not a lossless mode.
Measure it with the isolated stress workflow:
successful responses, exact durable accounting and process-local drops are
separate acceptance criteria. A short successful burst does not establish
sustainable writer throughput or an absolute request-capacity ceiling.
storage_query_timeout bounds interactive dashboard reads (chart, explorer,
log/bootstrap, capture reads) and the ad-hoc query route. Startup projection
preload, the boot totals rebuild, an administrative purge, and a backup merge's
totals scan run under a separate internal ten-minute maintenance budget: they
legitimately read or rewrite full history and must not inherit the interactive
bound. The
maintenance budget still honors an earlier caller cancellation or deadline, and
its expiry fails the operation closed (a purge transaction rolls back). It is a
fixed implementation guardrail, not a configuration field.
One shared analytical projection retains compact data and sorted metric orders proportional to stored history. Initial preload costs startup CPU/RAM; fast dashboard reuse is not free startup or bounded total-history memory. Concurrent independent proxy writers sharing a database are not a supported coherent-KPI deployment.
Expired debug sidecars are hidden from reads immediately by their TTL. Physical reclamation deletes bounded batches per writer tick and continues on a near-term tick, so a large expired set drains without holding the sole writer connection; the sidecar byte cap accounting is updated exactly for the rows removed.
Scheduler groups and some observed-identity sets also retain historical labels. Bounded request queues do not bound all process memory; long-running workloads with many unique identities need their own memory/cardinality measurements.
From a source checkout with Python installed, use the existing online backup into a new destination:
python3 scripts/backup_db.py proxy.db /path/to/new-backup.db
The helper opens the source read-only and includes committed WAL state. Do not plain-copy an open database or omit its WAL. Protect backups, debug captures and exports as sensitive data. This helper backs up the database, not configuration. Operators can also download a self-checked config and/or database archive from Settings backup and restore. For image-only deployments and both persistent volumes, use container backup and restore.
Performance and footprint¶
Separate deployment size, cold startup, idle footprint, request overhead and loaded throughput. They answer different questions; a small image is not a RAM limit, and a cached dashboard read is not a cold-start measurement.
The runtime contains a stripped Go binary with embedded UI assets, dependency notices and the generated configuration, on a minimal non-root base. There is no frontend build/runtime dependency in deployment. Docker's reported image size describes an image artifact, not compressed network transfer, persistent volume growth or the process's resident memory.
On the request path, one bounded body read/metadata decode feeds shared routing and accounting. Upstream connections are pooled; streamed observation and durable enqueue avoid a per-request history scan or synchronous SQLite write. On the dashboard path, the initial HTML embeds bootstrap state and shared analytical projections/cache slots avoid building a separate history copy for each view. These implementation choices reduce repeated work, not every cost of a request.
RAM still includes active request/response buffers, connection pools, the live ring and pending state, and the history/cardinality costs documented under storage. Raising queue/body/pool limits or concurrency changes that footprint. Retained history and observed identities have no universal process-memory cap; configuration limits must be evaluated together.
The startup preload decodes every stored row once, so its transient allocations
(SQL strings, attempts and tool-name JSON) exceed the retained projection several
times over. The process returns those burst pages to the OS immediately after the
preload completes; idle RSS after readiness reflects retained state, not the
startup burst. The projection's per-record retained footprint is measured
reproducibly by BenchmarkProjectionFootprint (retained and transient bytes per
record over a seeded scratch database) and BenchmarkProjectionProcessRSS
(process RSS after readiness), without a live instance; the
performance workflow owns the commands.
go_memory_limit is the operator's steady-state RSS lever: a soft cap on Go
runtime managed memory (heap, stacks, runtime structures) applied once at
startup. With the setting at 0, an operator GOMEMLIMIT wins and otherwise a
detected finite cgroup memory ceiling of at least 80MiB (v2 memory.max or v1
memory.limit_in_bytes) becomes a soft limit at 80% of that ceiling so a
container's own bound actually reaches the Go runtime; smaller ceilings are
ignored and, with no GOMEMLIMIT, the runtime stays unbounded. A set value
overrides GOMEMLIMIT, not GOGC, for the process lifetime. GOGC remains
independent. The limit is soft - exceeding it makes the collector
more aggressive (capped near 50% of CPU time) instead of failing requests - and
bounds Go-managed memory only, never SQLite's page cache or OS overhead.
The performance and resource usage page is the single source
for the current measured tables, hardware, method, reproduce commands and
limitations. This section owns the implementation cost model above and the
go_memory_limit behavior. Use the isolated performance workflow
when adding evidence. Short local-upstream bursts and microbenchmarks are useful
regressions, not sustained production capacity, provider-latency or lossless
storage guarantees.
Known limits¶
- Recognized provider fields drive usage/cost; this is not independent billing.
- Non-streaming usage/cost inspection keeps a bounded prefix, so larger valid responses can forward completely without complete accounting.
- Optional non-streaming translation reads the complete upstream 200 body and
the translated document under a fixed 32 MiB cap each. Exceeding either
fails closed with 502
response_too_largebefore any client bytes; the cap is an internal safety constant, not a setting, and discovery budgets do not bound the path. - General multi-line SSE accounting/native event assembly remains incomplete; some split terminal/content shapes can affect classification, not just metrics.
- SQL result limits do not bound every intermediate SQLite allocation.
- Automatically grouped sessions are not proof of agent ancestry. Explicit parent declarations and exact-leaf filtering are described in protocol.
- Existing values lost by older storage formats cannot be reconstructed.
- Extreme numeric data can produce explicit accounting/encoding errors rather than invented saturated prices or token counts.