One OpenAI-compatible endpoint on your machine for your ChatGPT/Codex subscriptions, OpenCode Go keys and other providers such as OpenRouter.
via hands out its own API keys and serves /v1/responses,
/v1/chat/completions, /v1/models and Ollama's /v1/systemone on
127.0.0.1:8317. Behind that it
talks to:
| Upstream | How you add it | Models | When one runs out |
|---|---|---|---|
| ChatGPT subscriptions, through the Codex backend | Device-code login: via accounts add or the web UI |
Any model without a prefix | Pooled: via moves on to the next account |
| OpenCode Go API keys | via accounts add --provider opencode-go or the web UI |
opencode-go/<model> |
Pooled: via moves on to the next key |
| Other OpenAI-compatible providers | An entry in config.yaml; OpenRouter and Ollama also in the web UI or with via providers |
<provider>/<model> |
One key each; its errors are passed back as they are |
Each request goes to the first account that still has capacity; when one hits its rate limit, via moves on to the next (see How the pool picks an account).
Status: early (
0.x). Commands and file formats may still change before 1.0.
Risk: pooling ChatGPT subscriptions through the Codex backend may break OpenAI's terms of use, and OpenAI could limit or ban the accounts. By default via presents itself to Codex as the Codex CLI; set
codex.cloak: falseinconfig.yamlto send its own name instead. OpenCode Go's terms apply to its keys too. Use only accounts you own, at your own risk (see Disclaimer).
A web UI at /ui shows the pool and manages accounts, keys and
models.
macOS and Linux, on arm64 and x64. The x64 builds use Bun's baseline target, so they run on CPUs without AVX2 too. There is no Windows build.
Each release attaches a
standalone binary for each platform, via-<os>-<arch> (via-darwin-arm64,
via-darwin-x64, via-linux-arm64, via-linux-x64), and a SHA256SUMS file.
Download yours, check it, and put it on your PATH; it doesn't need Bun or
Node to run:
curl -fLO https://github.com/nkootstra/via/releases/latest/download/via-darwin-arm64
curl -fLO https://github.com/nkootstra/via/releases/latest/download/SHA256SUMS
shasum -a 256 --check --ignore-missing SHA256SUMS
chmod +x via-darwin-arm64 && mv via-darwin-arm64 /usr/local/bin/viagh attestation verify via-darwin-arm64 --repo nkootstra/via also checks that
the release workflow built it.
Prebuilt npm packages are coming: npm i -g @nkootstra/via will install them.
The Linux ones need glibc, so on Alpine and other musl systems use the Docker
image or build from source.
Or build from source with Bun 1.4:
git clone https://github.com/nkootstra/via.git
cd via
bun install
bun run --cwd apps/web build # the web UI, which the binary embeds
cd npm && bun build.ts --hostThat writes a standalone binary to npm/via-<os>-<arch>/bin/via, for example
npm/via-darwin-arm64/bin/via. Copy it onto your PATH. It doesn't need Bun
or Node to run.
# 1. Add an account. Repeat for each subscription you want in the pool.
via accounts add
# Open https://auth.openai.com/codex/device and enter the code <code>
# 2. Create an API key for your clients. It is shown only once.
via keys create --name laptop
# 3. Serve.
via servePoint any OpenAI client at it. In another terminal, put the key via keys create
printed in VIA_KEY:
export VIA_KEY=via_...curl http://127.0.0.1:8317/v1/chat/completions \
-H "Authorization: Bearer $VIA_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "gpt-6-astra", "messages": [{"role": "user", "content": "Hello"}]}'from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8317/v1", api_key="via_...")
reply = client.responses.create(model="gpt-6-astra-high", input="Hello")
print(reply.output_text)Each release publishes ghcr.io/nkootstra/via for linux/amd64 and linux/arm64,
tagged with its version (X.Y.Z and X.Y) and latest; from 1.0.0 on also
the major version (X). The examples use latest; to upgrade only when you
choose to, pin a version from the
releases instead.
via keeps its accounts, keys, config.yaml and cooldowns in /data, so give
that a volume:
docker run -d --name via --restart unless-stopped \
-p 127.0.0.1:8317:8317 -v via-data:/data \
ghcr.io/nkootstra/via:latest
docker exec -it via via accounts add
docker exec via via keys create --name laptopThe running server picks up accounts and keys added this way without a
restart, and so does an OpenCode Go key added with
docker exec -i via via accounts add --provider opencode-go < key.txt. For
other providers, put config.yaml in the volume and pass their
keys as environment variables. With Compose:
services:
via:
image: ghcr.io/nkootstra/via:latest
restart: unless-stopped
ports:
- 127.0.0.1:8317:8317
volumes:
- via-data:/data
environment:
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
volumes:
via-data:Inside the container via listens on 0.0.0.0; the 127.0.0.1: in the port
mapping keeps it off other interfaces. It speaks plain HTTP, so put a TLS proxy
in front of it before exposing it any further. The image runs as a non-root
user (uid 65532) without a shell.
Any host that runs a container with a persistent disk will do, such as a VPS with Compose or a platform with volumes. What via needs from it:
- Exactly one instance, with a persistent volume on
/data. Each token refresh replaces an account's refresh token and writes it to/data, so a second instance, or a disk that is thrown away on restart, logs the accounts out. That rules out scaling to more instances, and serverless hosts without volumes. - TLS in front. Use the host's own, or a proxy such as Caddy, and send it
to port 8317. Every
/v1route needs one of your API keys. - A health check on
GET /healthz. It answers200 okwithout a key and isn't logged. - Provider keys as the host's secrets, passed as the environment variables
config.yamlnames. So isVIA_ADMIN_KEY, if you want the admin API and web UI instead ofdocker exec.
The image is public, so the host needs no registry login. Add accounts on the
host with docker exec -it via via accounts add; the
device-code login needs no browser there. Or copy an existing ~/.config/via
into the volume, owned by uid 65532. Stop via serve on your machine first,
so usage.db and its -wal file are copied as one, and then stop the
container, as the image has no shell to copy with:
docker stop via
docker run --rm -v ~/.config/via:/from:ro -v via-data:/data alpine \
sh -c 'cp -a /from/. /data/ && chown -R 65532:65532 /data'
docker start viaThat's for Docker on the same machine, such as Docker Desktop on a Mac. For a
remote host, copy the folder there first, as with
scp -rp ~/.config/via host:via-home, and mount ~/via-home there instead.
cp -a keeps the files at 0600. Stop the via you copied from for good
afterwards: two vias refreshing the same accounts log each other out (see
above).
Every /v1 route needs Authorization: Bearer <key> with a key from via keys create.
| Route | What it does |
|---|---|
POST /v1/responses |
Passed through to Codex, or to the provider the model names. |
POST /v1/chat/completions |
For Codex, translated to and from the Responses API, streaming included; for a provider, passed through. |
GET /v1/models |
Lists the models Codex offers your accounts, then providers'. |
POST /v1/systemone |
Ollama's System One, passed through to the provider the model names. |
GET /healthz |
Answers 200 ok, for health checks. Needs no key. |
A model named <provider>/<model>, such as opencode-go/kimi-k3 or
openrouter/qwen/qwen3-coder, goes to that provider; any other
model goes to Codex.
The Codex backend refuses sampling and limit options, so via accepts and ignores
them: temperature, top_p, max_tokens, max_completion_tokens and
max_output_tokens, as well as user, metadata, previous_response_id and
context_management.
A refusal comes back as the chat message's refusal, as OpenAI sends it.
A Chat Completions request to Codex is translated as OpenAI would read it:
- A function tool that leaves out
strictisn't strict, as in Chat Completions, where the Responses API would make it strict. - via asks Codex for reasoning summaries, and they come back as the message's
reasoning_content, streamed asreasoning_contentdeltas, with a blank line between summary parts. Codex's encrypted reasoning isn't handed to the client, as Chat Completions has no field for a client to send it back in. - A response Codex completes without reporting usage still finishes; the
answer then has no
usage, and a stream no usage chunk. - A request Codex refuses gets OpenAI's error,
{"error":{"message","type","code"}}, with Codex's status, message and code. When Codex's answer isn't readable, such as an HTML page, the message says only that Codex refused the request./v1/responsespasses Codex's refusal on as Codex sent it.
A provider's chat answer is passed through, with one change: some models, such
as MiniMax's on OpenCode Go, write their reasoning in a <think>…</think>
block at the start of the answer. via moves that block to reasoning_content,
where other models put their reasoning, streamed or not, so a client shows it
as reasoning rather than as the answer. A <think> anywhere later in the
answer is left alone.
via's server never closes a connection as idle, so an answer that goes quiet
while the model works still reaches the client, streamed or not. A streamed
answer that goes quiet also gets a : keepalive SSE comment every five seconds,
so a proxy in between doesn't close the connection as idle. SSE clients skip
comments. Every streamed answer also carries cache-control: no-cache and
x-accel-buffering: no, so a proxy such as nginx passes each event on as it
comes.
via bounds what one request can take:
- A request body over 64 MiB is refused with
413, on every route. OpenAI takes up to 50 MB per request, images included, so anything an upstream would accept fits. - Codex has 2 minutes to start answering a response, and a provider 10, as a
provider that doesn't stream answers only once the model is done. After
that, a stream runs as long as the model takes. An upstream that doesn't
start in time answers
502, like one that can't be reached. - Once an answer has started, the upstream may go 5 minutes without sending
anything, as long as the Codex CLI waits; a reasoning model can think for
minutes between chunks. After that via ends its request upstream. A
streamed answer then ends in an
upstream_incompleteerror event, as one the upstream broke off does, and one via collects for a client that doesn't stream answers504 upstream_timeout. A provider's answer that isn't streamed but passed through as it comes breaks off. - A provider error body or a translated non-streaming Messages body must finish
within 5 minutes. An error body that stalls keeps its HTTP status and lets
fallback proceed. A Messages body that stalls answers
504 upstream_timeout. - A non-streaming Codex response that hasn't completed after 30 minutes
answers
504 upstream_timeout, and one whose stream runs past 128 MiB answers502 upstream_too_large. - A non-streaming Codex response via can't read whole answers
502 upstream_incomplete, with a message saying what happened: the stream ended early, the connection broke off, Codex asked to be retried, or it sent an event or a final response via can't read. A warning with the request'srequest_idsays why, such asThe socket connection was closed unexpectedly. Each but the last falls back when the model has a fallback. - Usage, model lists, key checks and sign-in requests to OpenAI give up after 30 seconds. A token refresh gives up when OpenAI hasn't started answering within 30 seconds; once it has, the answer gets 2 minutes to arrive, as it holds the new refresh token and the old one is already spent.
- A client that hangs up on a stream ends via's request upstream too.
/v1/models lists what the Codex model picker shows your accounts, combined,
since plans offer different models. So new models appear without a via update.
Only accounts that can serve count: enabled ones, including one cooling down,
as it serves again once its cooldown ends, but not one locked out until it
signs in again. With no such account, no Codex models are listed. via fetches
the list as it starts and answers from it at once; once it is five minutes old,
via fetches a new one in the background for the next request. Disabling,
enabling, adding or removing an account, or one being locked out, changes the
list at once. When Codex can't be asked, it lists the models via knows:
gpt-6-astra, gpt-6-sol and gpt-6-luna, logs a warning, and asks again
no sooner than a minute later; meanwhile any account may serve any model.
Add an effort suffix to a model id to pick the reasoning effort, as in
gpt-6-astra-high. The list shows each model with the suffixes it supports,
from -none, -low, -medium, -high, -xhigh and -max. A
model Codex lists under a name that already ends in one of them, such as
gpt-5.1-codex-max, is sent by that name, not as an alias. Other model ids are
passed through to Codex unchanged.
The Codex app also offers an ultra effort on some models. It is maximum
reasoning with tasks handed to agents the Codex app runs itself, and Codex's
API refuses it, so via can't serve it. via doesn't list -ultra ids, and
answers one with a 400 unsupported_effort error without asking Codex. Use
-max for the most reasoning via can give.
A fallback rule names a model and up to three others for via to try, in
order, when that model can't serve a request. The first one that answers
does, and its answer carries x-via-fallback: <asked> -> <answered>, such as
x-via-fallback: gpt-5.6-sol -> opencode-go/kimi-k3; its body names the model
that answered. As the header carries them, a rule's model ids are printable
ASCII without spaces, as every provider's are. Set them on the web UI's
Fallbacks page or with via fallbacks set; they are kept in
fallbacks.json in via's home. One id beyond that, such as
ollama/模型, makes via report the whole file as invalid, naming the id, and
no rule in it applies until it's fixed:
[{ "model": "gpt-5.6-sol", "fallbacks": ["opencode-go/kimi-k3", "gpt-5.5"] }]A running via serve uses a changed file on its next request. The
web UI's Fallbacks page and the admin API set and
remove rules too, and show how each stands:
whether its model and each fallback could serve now (available, cooling
until when and why, or unavailable: no account, an OpenRouter model that
isn't enabled, or a model via doesn't know) and serving, the model that would
answer now, or null. It only knows of cooldowns and accounts: an outage shows
once a request meets it. When fallbacks.json can't be read, the admin state's
fallbacksError says why, and requests don't fall back until it's fixed.
- When: only when the model can't serve before any of its answer reached
the client. That is when every account that could serve it is cooling down
(
429) or there is none (503), when Codex is down or overloaded, even if it says so only once it has started a response via collects for a client that doesn't stream, when the stream of such a response breaks off (upstream_incomplete), when its upstream can't be reached (502) or a provider answers404,429or a5xx, when an OpenCode Go model speaks none of the APIs via could ask it in, and when an OpenRouter model isn't enabled. - Never: when the upstream refuses the request itself, such as a
400for a bad parameter, once a streamed answer has started, even if it then fails, or for System One, whose answers belong to its model. - Efforts: a rule for a Codex model covers it with any effort suffix too, so
one for
gpt-5.6-solalso takesgpt-5.6-sol-high. A Codex fallback that supports the effort gets it, asgpt-5.5-high; any other fallback is asked for as written. A rule for the exact id, such asgpt-5.6-sol-high, wins. - One rule: a fallback's own rule isn't followed. A fallback via doesn't know, such as a Codex model Codex doesn't list, is skipped without being asked; one that refuses the request answers with that refusal.
- None can serve: the client gets the answer of the model it asked for,
whose
Retry-Afteris the soonest any of the models asked for.
Codex cooldowns are per account, not per model: when every account that can serve a Codex model is cooling down, so are the other Codex models those accounts serve. A Codex fallback helps with a rate limit only when another plan's account offers it; another provider's model always can.
With VIA_ADMIN_KEY set, via serve also serves /admin, which does what the
via accounts and via keys commands do, over HTTP. Without it, /admin
doesn't exist and answers 404. The key must be at least 32 characters, or
via serve refuses to start; openssl rand -hex 32 makes one.
Every /admin route needs Authorization: Bearer <VIA_ADMIN_KEY> or a
session cookie from signing in, except signing in
itself and the API's description: its OpenAPI spec at /admin/openapi.json and
a reference page at /admin/docs, where you can also try the routes out. API
keys from via keys create don't work on /admin, and the admin key doesn't
work on /v1.
GET /admin/history/series takes an IANA timeZone in place of the
tzOffsetMinutes it took before, which it no longer accepts; see
Usage history.
| Route | What it does |
|---|---|
POST /admin/session |
Sign in with {"key": "<VIA_ADMIN_KEY>"}; sets a cookie. |
GET /admin/session |
200 while signed in, else 401. |
DELETE /admin/session |
Sign out. |
GET /admin/accounts |
List accounts in the order they are used, without tokens. |
PATCH /admin/accounts/<id> |
Change label and/or enabled; returns the account. |
DELETE /admin/accounts/<id> |
Forget an account and delete its tokens. |
POST /admin/accounts/logins |
Start a device-code login. |
GET /admin/accounts/logins/<id> |
Check on a login: pending, added, updated or failed. |
GET /admin/opencode-go/accounts |
List OpenCode Go keys, each only by its last four characters. |
POST /admin/opencode-go/accounts |
Add a key from {"apiKey": "...", "label": "..."} (label optional), once OpenCode Go accepts it. |
PATCH /admin/opencode-go/accounts/<id> |
Change label and/or enabled; returns the key's account. |
DELETE /admin/opencode-go/accounts/<id> |
Forget an OpenCode Go key. |
GET /admin/ollama |
Where Ollama is: {"address", "fromConfig"}, or null. |
PUT /admin/ollama |
Save Ollama's address from {"address": "..."}; via sends to it at once. 409 if config.yaml sets it. |
DELETE /admin/ollama |
Forget the saved address. 409 if config.yaml sets it. |
POST /admin/ollama/check |
What {"address": "..."} holds: {"address", "version", "models"}, or 422 and why not. |
GET /admin/openrouter |
OpenRouter's key, masked, and the models it enables: {"key", "models", "fromConfig"}, or null. |
PUT /admin/openrouter/key |
Save {"apiKey": "..."} once OpenRouter accepts it; 422 if it refuses. 409 if config.yaml sets it. |
PUT /admin/openrouter/models |
Offer exactly {"models": [...]} of OpenRouter's, by id. |
DELETE /admin/openrouter |
Forget the key and its models. 409 if config.yaml sets it. |
GET /admin/openrouter/catalog |
Every model OpenRouter lists, with its prices per million tokens and context length. |
GET /admin/usage |
How much of each account's limits is used. |
GET /admin/history/series |
Tokens per hour or day, by group (see Usage history). |
GET /admin/history/breakdown |
Requests, tokens, failures, latency and cost over a range, by group. |
GET /admin/history/requests |
The requests in a range, newest first, a page at a time. |
GET /admin/history/conversations |
The conversations in a range, the latest active first, a page at a time. |
GET /admin/history/conversations/:id |
One conversation, every turn of it and its sub-agents', and their attempts; 404 if via kept none. |
GET /admin/history/requests/:id/body |
A failed turn's captured prompt, redacted; 404 if via kept none. |
DELETE /admin/history/bodies |
Delete every captured prompt; answers {"deleted": <count>}. The history stays. |
GET /admin/history/capture |
Whether via keeps failed turns' prompts: {"keepFailedPrompts": false} until you turn it on. |
PUT /admin/history/capture |
Turn keeping failed turns' prompts on or off with {"keepFailedPrompts": true}. |
DELETE /admin/history |
Delete the whole usage history and every captured prompt; answers {"deleted": <requests>}. |
GET /admin/pool |
Each account's and provider's state (see below). |
GET /admin/models |
The models /v1/models lists. |
GET /admin/fallbacks |
The fallback rules, each with how its model and fallbacks stand and which answers. |
PUT /admin/fallbacks |
Set {"model": "...", "fallbacks": [...]}, in place of the model's rule; 400 and why if it can't. |
DELETE /admin/fallbacks?model=<id> |
Remove a model's fallbacks; 404 if it has none. |
GET /admin/events |
The admin state as it changes, history changes and the turns in flight, as server-sent events. |
GET /admin/keys |
List API keys and when each was last used, not the keys. |
POST /admin/keys |
Create a key from {"name": "..."}. It is returned once. |
PATCH /admin/keys/<id-or-name> |
Rename a key from {"name": "..."}; the key stays the same. |
DELETE /admin/keys/<id-or-name> |
Revoke a key. |
Accounts are named by their id from GET /admin/accounts. Unlike the
commands, the admin API doesn't take a label or email, which would otherwise
end up in URLs and access logs. An account, login or key that doesn't exist
answers 404; a key name that is taken
answers 409. A name or label over 200 characters, or a key over 1,024,
answers 400.
A key's lastUsedAt is null until a client first uses it. via serve knows
it to the second, but writes it to keys.json at most once a minute per key
rather than on every request, so after a restart, or in via keys list next
to a running via serve, it can be up to a minute behind.
Before it keeps an OpenCode Go key, via asks OpenCode Go for the key's usage:
a key OpenCode Go refuses answers 422, one via can't check because OpenCode Go
is down answers 502, and one via already has answers 409. The key is never
answered back: key is its last four characters, as …abcd, and the account
imported from the deprecated apiKeyEnv variable has environmentVariable
set to that variable's name while it is still set.
To add an account, start a login, open verificationUrl and enter userCode,
then poll the login until it's no longer pending:
curl -X POST -H "Authorization: Bearer $VIA_ADMIN_KEY" http://127.0.0.1:8317/admin/accounts/logins
# {"id":"<id>","userCode":"ABCD-1234","verificationUrl":"https://auth.openai.com/codex/device"}
curl -H "Authorization: Bearer $VIA_ADMIN_KEY" http://127.0.0.1:8317/admin/accounts/logins/<id>
# {"status":"added","account":{"id":"...","label":"you@example.com",...}}A login fails if it isn't approved within 15 minutes. At most 10 logins can wait for approval at once; starting another answers 429 until one ends. Logins are kept in memory, so restarting via cancels them, and one that has ended is forgotten 5 minutes later, when polling it answers 404.
GET /admin/pool answers {"accounts": [...], "opencodeGo": [...], "providers": [...]}.
It lists every ChatGPT account, and in opencodeGo every OpenCode Go account,
with its id, label, enabled and a state:
{"status":"available"}, {"status":"cooling","until":"<ISO time>","reason":"..."}
while Codex has it rate-limited, or {"status":"auth_error","reason":"..."} once
Codex rejects its tokens even after a refresh, until you log in to it again (for
an OpenCode Go account, once OpenCode Go refuses its key, until via restarts). A
disabled account keeps its state but isn't used.
Next to the accounts it lists every configured provider with its own key, such
as OpenRouter, with its name and {"status":"available"}: requests for
<provider>/<model> go straight to it.
GET /admin/usage answers {"accounts": [...], "opencodeGo": [...], "openrouter": ..., "refreshing": ...}
at once from the usage via last fetched, never waiting on ChatGPT or OpenCode
Go. An OpenCode Go account's windows are named as OpenCode Go names them, such
as rolling, weekly and monthly. openrouter is the OpenRouter key's
budget, as OpenRouter tells it: {"budget": {"limitUsd", "spentUsd", "window", "resetsAt"}}, with budget null for a key without a limit, or {"error"};
it is null while via has no OpenRouter key. A budget resets daily, weekly
(Monday) or monthly at midnight UTC, or never (window and resetsAt null). via fetches every account's usage in the
background: when it starts, every 15 minutes after that, and whenever an answer
would be a minute old or more, or would miss an account. So what you see is at
most about a minute old, and via asks for each account's usage at most once a
minute however often the page refreshes. Each entry in GET /admin/usage says when it was fetched
(fetchedAt), an account via has no usage for yet is left out, and
refreshing is true while a fetch runs.
GET /admin/events is a stream of
server-sent events
that pushes the admin state instead of making you poll. Each event is named
state and carries all of it as JSON: pool and usage as the routes above
answer them, accounts, opencodeGo (keys masked) and keys as their lists
do, models, version (the running via's), capture (whether via keeps
failed turns' prompts, as GET /admin/history/capture
answers it) and session: true. The first
comes as soon as you connect; after that, one comes whenever something in it
changes: an account, OpenCode Go key or API key is added, changed or removed,
an API key is used for the first time in a minute, a cooldown or lockout starts
or is lifted, a cooldown runs out, or a usage fetch starts or ends. Changes
that come together (within about 200 ms) arrive as one event, and a state the
same as the last one isn't sent again. A : keepalive comment keeps a quiet
stream open. Changes another process makes, such as via accounts label,
show within 15 seconds, and while a stream is open via keeps usage from
getting more than about a minute old, as it does for a page that polls.
A stream opened with a session cookie ends when that session does: on sign-out,
12 hours after sign-in, or after an hour unused. An open stream doesn't count
as using the session, so a page left open with nothing done in it is signed out
on the hour as one that polls would be. A stream opened with the bearer key
runs until you close it.
The stream also says when the usage history changes, as when
via keeps a request, captures a prompt, or the history or the captured prompts
are deleted, with an event named history
and nothing more in it: the history is too big to send, so you fetch what you
show from /admin/history. Changes within a second of each other arrive as one
event.
It also shows the turns in flight, so a conversation is live
while a coding agent talks: a request is in flight from when it names its
model until its answer ends, however it ends (served, refused, broken off, or
its client gone). Each event's data is JSON, with the same fields, and the
same rules for what is kept of an upstream's message, as the
usage history:
turn.started: a request started.{"requestId", "at", "model", "conversationId", "parentConversationId", "agentType", "client", "keyName", "attempts"},nullfor what it doesn't have. As you connect, one comes for every turn already in flight, with itsattemptsso far, so a page opened mid-turn shows it.turn.attempt: an upstream attempt started, was answered, sent its first chunk, or failed, as it stands now; the attempt with the samerequestIdandseqit replaces. One still under way has"state": "open", withrequestId,seq,startedAt,provider,model,accountId,accountLabel,status(nulluntil the upstream answers) andfirstChunkMs(nulluntil the first chunk), and no duration or outcome yet. One via moved on from has"state": "ended"and every field of an attempt the history keeps,outcomeanddurationMsincluded.turn.ended: the request ended.{"entry", "attempts"}, the request and its attempts as the usage history keeps them, which has them by the time this comes (unless via couldn't write them).
The turns in flight are kept in memory only, never on disk, and at most 1,000 at once: a request past that isn't shown until it ends and the history has it. A stream that falls more than about a thousand events behind is ended rather than left to miss one, and via never waits for it to serve a request. A page that reconnects gets every turn in flight again, so it should drop what it held when the stream broke.
A browser signs in once with the admin key, and from then on sends a session cookie instead, so the key is never kept in the page:
POST /admin/sessionwith{"key": "<VIA_ADMIN_KEY>"}answers 204 and setsvia_session, anHttpOnly,SameSite=Strictcookie for the whole site (Path=/), so loading the web UI at/uisends it too. The cookie isSecurewhen the browser signed in over HTTPS, which via tells from the request'sOriginheader, so a proxy that ends TLS in front of via needs no setting for it.- A sign-in whose
Originhost isn't theHostit was sent to answers 403 before its key is checked, so another site's page can't make the browser send wrong keys and get the admin's own address refused. Host names are compared without regard to case. A reverse proxy in front of via must pass theHostheader through unchanged, or the web UI can't sign in; it says so. Sign-ins withoutOrigin, as the CLI or curl send them, are taken as before. - Browsers keep cookies by host, not by port, so the browser also sends
via_sessionto every other service on via's host, such as another app onlocalhost:3000, andSameSite=Strictdoesn't stop it: to a browser, every port on a host is the same site. Any such service can read the cookie and act as the admin until the session ends. Serve the web UI from a host name of its own, or sign out when you're done. - A session ends 12 hours after sign-in, after an hour unused, on
DELETE /admin/session, or when via restarts; sessions are kept in memory. - A request that changes something (anything but
GET) with only the cookie must sendx-via-csrf: 1and anOriginwhose host is theHostit was sent to; otherwise it answers 403. A proxy in front of via must pass theHostheader through unchanged. Requests with the bearer key need neither. - A wrong key answers 401 after a one-second delay. After 10 wrong keys
within a minute from one address, sign-ins from that address answer 429,
after the same delay, until the minute has passed, even if the right key was
used in between; other addresses can still sign in. After 1000 wrong keys within a minute from all addresses together,
every sign-in answers 429, which keeps many addresses guessing at once from
getting far. The address is the connection's: via ignores
X-Forwarded-Forand similar headers, so behind a proxy every sign-in shares the proxy's address. Each failed sign-in is logged, without the key. via never logs the key or the cookie. - A wrong
Authorization: Bearerkey on any/adminroute counts as a failed sign-in from its address too, and is logged the same way, though it answers 401 at once. While an address's sign-ins answer 429, so do its bearer requests, even with the right key; sessions already signed in keep working.
With VIA_ADMIN_KEY set, via serve also serves a web UI for the admin API
at /ui/, such as http://127.0.0.1:8317/ui/. Without the key it answers 404,
as /admin does. It's built into the binary and the Docker image, so there is
nothing else to run or download, and it loads nothing from other sites.
Overview shows how the pool stands right now: how many accounts and providers are available, cooling down, locked out or disabled, and how much of each one's limits is used. While a model is falling back, or has no fallback left, it says so at the top, with a link to the Fallbacks page.
Usage: requests, tokens, cache hit rate, cost and time to first token, charted and broken down by model, account or key; and the conversations coding agents had, each turn on a timeline told as a story.
Accounts: the ChatGPT and OpenCode Go accounts in the pool, your Ollama and your OpenRouter key; add, disable or remove them.
Fallbacks: the models each model falls back to, live: standing by, falling back and to which, or with none left; add, reorder, edit and remove them.
Each rule reads as its chain, such as gpt-5.6-sol → kimi-k3 → gpt-5.5, with
a badge for how it stands. While it falls back, the model answering in its
place is ringed and marked "answering now", and the row says why its model
can't, such as "Rate limited · Back in 4:07".
Open it and sign in with the admin key. The page signs in
as above: it keeps only the session cookie, never the key, and a reload or a
link to any page keeps you signed in until the session ends. From there you can
see the pool at a glance, add ChatGPT accounts by device-code login or OpenCode
Go keys by pasting them, rename, disable and remove them, create, rename and revoke API
keys, and list the models. The Accounts page lists the ChatGPT accounts under
Codex and the OpenCode Go keys under their own heading, each key only by its
last four characters. Its Ollama section adds, changes or removes the
address of your Ollama, and shows the version and models via finds there.
Its OpenRouter section takes your OpenRouter key and chooses which of its
models via offers. The Models page shows each model once: a Codex model
with the reasoning efforts its suffixed ids pick, and a provider's models under
its heading without their <provider>/ prefix. Search matches every id.
The Fallbacks page lists the fallback rules and how
each stands right now: Standing by while its model answers, Falling
back with the model answering in its place, why its own can't and when it
is back, or No fallback available when every model in the list can't
answer either, so requests fail. A fallback via skips, such as an OpenRouter
model that isn't enabled or a model via doesn't know, is drawn dashed, with
why. When via can't read fallbacks.json, the page says so in place of the
rules: requests don't fall back until the file is fixed. A screen reader hears a
rule start or stop falling back, not the countdown. Add fallback picks a
model, from a list grouped like the Models page's that leaves out the models
that have a rule, then up to three to fall back to, in order: move each up or
down, or remove it, with the keyboard or the pointer. Under a model, the page
says when it would take a request for an effort it lacks at its default.
Edit… changes a rule's list, and Remove… removes it, after asking.
Settings, in the menu under Admin at the foot of the sidebar, holds the page's preferences:
- Privacy mode, for streaming or sharing your screen: it hides emails (in labels and error messages too, and in a captured prompt's sheet and Markdown report), key endings, the OpenRouter key, the Ollama address and the ChatGPT plan, and shows a new API key or a sign-in code as dots until you choose Show (Copy copies it either way). Renaming an account whose label it hides starts from an empty field. It changes only what the page shows; the admin API answers as always.
- The theme (System, Light or Dark), and Motion: System follows the device's reduced motion setting, Reduced holds animations still whatever it says.
- The time format (Automatic, 12-hour or 24-hour).
- The Start page: Overview or Usage, which
/ui/and the via logo open on, as does signing in. Every page keeps an address of its own, the Overview's being/ui/overview, so a reload stays on the page you're on.
It also deletes the whole usage history, after asking; that one isn't a preference of the browser, so it's gone for every viewer. So are the two settings beside it: Keep prompts of failed requests, off until you turn it on, keeps a failed turn's prompt, redacted, for 7 days (its switch can't be used until via says how it stands, and offers Try again if it couldn't), and Delete captured prompts… deletes every one kept, after asking, while the history stays. Automatic writes times as the browser's language does. Each choice applies at once, to every open tab, and is kept in that browser's local storage, not in via, so another browser starts from the defaults.
The Usage page shows the usage history: requests,
tokens, cache hit rate, API-equivalent cost and time to first token (of
streamed answers that didn't fail) over the
last day, week, 30 or 90 days; tokens per hour or day, stacked by model,
account or key, with the days running from midnight to midnight in your
browser's time zone; a table of each; and the requests themselves, newest first.
A request that a fallback answered shows the model that
answered, with "Fell back from" and the model asked for under it, and the
request count says how many fell back. Filter the whole page to one model,
account or key, to failed requests only, and to requests that fell back only:
pick them from the filter bar, where each one's search lists what the other
filters leave with its request count, or pick a row of the table. The range,
grouping and filters are in the page's address, as in
/ui/usage?range=7d&model=opencode-go/kimi-k3&failed=true or
/ui/usage?fellBack=true, so a reload keeps
them and a link shares them. A request shows up within about a second: the
page fetches its usage again when via says the history changed, and asks every
15 seconds only while that stream is down.
The Usage page's Conversations tab (/ui/usage?tab=conversations) lists
the conversations with turns in the range, the latest active
first, under the same filters: the harness that had each, such as
"OpenCode 1.19.3" or "Claude Code" (from its User-Agent, or else from the
id it sent), with a Guessed badge when via guessed the conversation because
its client sent no id; the key, the models, when it started and was last
active, its turns and sub-agents, how many failed or fell back, its tokens and
its cost. Load more pages on, as the requests do. Open one, at
/ui/usage?tab=conversations&conversation=<id>, for its totals and its
timeline, told as a story. A sentence sums it up ("14 turns over 1.6 min, one
still streaming", then the model time, how many models and how many failed),
and the turns run down a line, each at its time of day on your clock and in
your time format (14:05:09), with how long it took on the right; hover a
time for its full date, time zone and how far into the conversation it came
(2:27 in). Turns in a row that went fine on
one model fold into one line ("Turns 8–10 · deepseek-v4.1-flash · all OK", or
"Turns 2, 4–5" in a sub-agent's thread when other turns came between), with
a small bar per turn, pale while it waited for its first token and solid while
it streamed; click it for each turn's model, who served it and how long it
took. Two turns on their own keep a line each. A turn that failed, fell back,
was left by its client or took more than one attempt gets a card: its model,
how long it waited and streamed, its tokens, who served it and any billed cost,
"Fell back from …" for a fallback, and for a failure its status, why and the
upstream's code and message. Failures in a row that failed the same way, on the
same model, share one card ("2 failed in a row") listing each turn's time and
duration. A stretch longer than 3 seconds with nothing running shows as
"· 4.7 s idle ·", the first turn on a new day on your clock is headed by
the day ("Monday, September 28"), and a change of the model the client asked for as
"Switched to " (a fallback isn't a switch). A sub-agent's turns run as a
thread of their own, named for its type when its client says (else
"Sub-agent"), with its turns, duration and the time it ran. A card's
upstream attempts open beneath it when you click
"2 attempts · 1 failed": each attempt's provider (and the one a router such as
OpenRouter sent it on to), account, model and status, why it didn't serve
("Budget exhausted", "Rate limited", "Downstream unavailable" and so on), the
upstream's code, its message when via kept one, and how long it took. A request
kept before via kept attempts has none to open. On a phone, a line leaves out
who served it and a fold puts its model under its count. Under the timeline,
the turns are listed oldest first. All
conversations goes back to the list.
A turn whose prompt via kept has View request on its
card on the timeline (and beside each turn of a card of failures in a row), and
under its outcome in the turns list. It opens the request in a sheet beside the
conversation (Escape closes it): its parameters (model, max tokens, reasoning
effort, temperature and so on) and the tools it offered, then its messages by
role, as a conversation reads, the system or developer prompt folded away and a
long message, tool call or tool result cut short until you choose Show all,
then what each upstream attempt answered, with the attempt's provider, status
and why it didn't serve. What via redacted ([redacted:openai-key]) and the
images it left out ([image: png, 412 KB]) stand out as chips, and a note says
how many of each it replaced, and when it cut long strings short. A body of a
shape other than Chat Completions' or Responses' shows as JSON. Privacy mode
hides the emails and key endings in all of it, as in the Markdown report.
Copy JSON copies the whole of it, as kept. While keeping failed prompts is on, the Conversations
tab says Capturing failed prompts, a link to the setting.
Export, in a conversation's header, has Download JSON, a file named
via-conversation-<id>-<yyyymmdd-hhmm>.json holding the conversation as the
admin API answers it (summary, turns, attempts) with version,
exportedAt, via.version and its captured prompts under captured, by
request id; and Copy as Markdown, a report to paste into an issue: its
totals, a table of its turns, and for each turn that failed or fell back its
attempts and, when via kept its prompt, the prompt's last five messages (the
system prompt left out, a long one trimmed) and what the upstreams answered.
Privacy mode hides emails and key endings in the report, not in the file. The
menu says when an export holds captured prompts, as redacting by pattern can
miss a secret.
The tab is live while the page's event stream is open: a conversation with a turn in flight, its own or a sub-agent's at any depth, has a Live badge, and one whose first turn is in flight leads the list before the history has it (unless a filter is on, as via can't yet say whether it matches). On a conversation's timeline, a turn in flight has a card of its own: a dot that breathes, how long it has taken so far, a bar of its wait and its streaming with stripes drifting along it (both still when you ask for less motion, as below), whether it has had its first token, and its attempts so far, the one under way counting up. The time counts up ten times a second, or once a second when you ask for less motion (your device's reduced motion setting, or Motion set to Reduced). When the turn ends, it takes its place in the story at once, on a line or a card as it went, and the history's turn takes its place without a gap. Should the page miss a turn's end, the turn stops showing as in flight once the history has it, or after an hour at most.
The overview shows each account's usage as via last fetched it in the background (see the admin API), at most about a minute old, and says how long ago that was. OpenRouter's card shows its key's budget as a meter, like an account's limits, when the key has a limit. A provider reports no usage of its own, so its card shows what via counted of its requests over the last 24 hours: requests, tokens and cost (Free for a local one such as Ollama), with a link to them on the Usage page. The overview fetches those figures once it has painted.
The page shows the pool as it is right now, without reloading or polling. When
a signed-in browser opens or reloads it, via puts the current state in the
page itself, so it paints straight away without asking /admin for anything.
The page then listens to GET /admin/events, and a cooldown,
a lockout, new usage or a change made in another tab shows up the moment via
knows it. If that stream drops, the page asks every few seconds instead until
the browser reconnects. The state in the page is the viewer's, so via tells
browsers and proxies not to cache it.
When via restarts on a new version while the page is open, the page reconnects, notices that it was built for the old one, and says "via was updated. Reload to get the new version." until you reload it.
The page is the admin key's reach in a browser, so give it the same care:
- Keep via on
127.0.0.1, as it is by default, or put it behind TLS before you open it anywhere else. Over plain HTTP the key and the cookie cross the network readable. - The page runs only via's own code: its Content Security Policy allows scripts, styles and requests from via alone, and it can't be framed by another site.
| Command | What it does |
|---|---|
via accounts add |
Log in to a ChatGPT account with a device code. |
via accounts add --provider opencode-go |
Add an OpenCode Go API key as an account. |
via accounts list |
List accounts in the order they are used. |
via accounts status |
Show each account's and provider's limits and how much is used. |
via accounts label <account> <label> |
Rename an account. |
via accounts disable <account> |
Stop using an account without removing it. |
via accounts enable <account> |
Use it again. |
via accounts remove <account> |
Forget an account and delete its tokens. |
via providers openrouter set-key |
Add or replace the OpenRouter API key. |
via providers openrouter models [<model>...] |
Offer exactly these OpenRouter models, by id. |
via providers openrouter remove |
Forget the OpenRouter key and its models. |
via providers ollama set <address> |
Add Ollama at <address>, or change its address. |
via providers ollama remove |
Forget Ollama's address. |
via keys create --name <name> |
Create an API key. It is printed once. |
via keys list |
List keys, when each was created and when it was last used. |
via keys rename <id-or-name> <new-name> |
Rename a key; clients keep using it. |
via keys revoke <id-or-name> |
Revoke a key. |
via fallbacks set <model> <fallback>... |
Set the models <model> falls back to, in order. |
via fallbacks list |
List each model's fallbacks. |
via fallbacks remove <model> |
Remove a model's fallbacks. |
via serve [--host <addr>] [--port <n>] |
Serve the API in the foreground. |
<account> matches an account's id, label or email.
A running via serve sees what these commands change from its next request
on: a revoked key is refused, a disabled account is skipped and a
fallback is used once the command has finished. It keeps the accounts and keys in memory and checks each request
whether their files changed (stat, no read), reading them again only then.
Adding a ChatGPT account that is already in the pool signs it in again: via replaces its tokens and keeps its label and whether it's enabled, rather than adding it twice. Adding an OpenCode Go key via already has is refused.
via accounts add --provider opencode-go asks for the key without echoing it,
or reads it from standard input when that isn't a terminal, so a script can
pipe it in: via accounts add --provider opencode-go < key.txt. It never takes
the key as an argument, which would end up in your shell history. Like the web
UI, it asks OpenCode Go whether the key works first, and keeps it only if
OpenCode Go takes it: a key OpenCode Go refuses, or one it can't check because
OpenCode Go can't be reached, isn't stored, and the command exits with 1 and
says why. list and status show only a key's last four characters.
via accounts status asks every configured provider at once whether it can be
used, as the web UI does: Ollama for its version, OpenRouter about its key, and
any other provider for its models, each for up to 30 seconds. Its line ends in
available, key refused (HTTP <status>) or unavailable: <reason>.
status only reads: it changes no account or file.
via providers sets up OpenRouter and Ollama as the web UI does, and keeps
them in the same files. openrouter set-key reads the key as
accounts add --provider opencode-go does, checks it with OpenRouter first,
and keeps the models chosen before; a key OpenRouter refuses, or one it can't
check, isn't stored. openrouter models takes the models' OpenRouter ids,
such as openai/gpt-5; with none, it offers none. ollama set keeps the
address even when nothing answers there yet, and says what it finds. Neither
changes a provider config.yaml sets up. A running via serve sees these
changes only once it restarts.
- Accounts are used fill-first, in the order you added them: via stays on the first account until it can't serve a request.
- A conversation stays on the account that last answered it, so its prompt cache stays warm, as long as that account is still available; a new conversation, or one whose account failed over, uses fill-first. This is kept in memory only, capped at 10,000 conversations, and forgotten after an hour of inactivity.
- Plans differ, so a model goes only to accounts whose model list includes it,
such as
daybreakordaybreak-highto the one account that offersdaybreak. A model no account's list includes is tried on every account anyway, as it may be newer than the list. - A rate-limit or usage-limit answer puts that account on a cooldown until the
reset time the upstream gives or its
Retry-After(seconds or a date), whichever is later, or 30 minutes if it gives neither, and via retries on the next account. Codex's reset time is the error'sresets_at, else itsresets_in_seconds, else thex-codex-primary-reset-atorx-codex-secondary-reset-atheader of the window that is used up. Codex sometimes starts a response and then fails it with a rate or usage limit (response.failed); that counts the same. A client that isn't streaming gets its answer from the next account; a streaming client has already been sent the start of the response, so it gets the failure, and the next request goes to the next account. - A server error (5xx) or an overloaded Codex is an outage, not the account's
fault: via cools no account down and doesn't try the others, which would
meet the same outage. The client gets
503if Codex is overloaded, else502, as an OpenAI error, with Codex'sRetry-Afterwhen it sends one. - A 401 makes via refresh the account's token and retry once. If that fails, the
account is locked out: taken out of use until it signs in again. Sign it in
from the web UI's Add account and it's back in rotation at once. With
via accounts addinstead, restartvia serve: the CLI can't reach the running server's lockouts. A login lifts only a lockout, never a cooldown. - A 403 means Codex bars the account (suspended, its workspace deactivated, or
blocked by Cloudflare): via cools it down for 30 minutes and tries the next
one. A 403 for a request Codex's policy refuses
(
misalignment_policy_violation) goes back to the client as-is. - A Codex backend via can't reach at all answers
502without trying the next account. A token refresh that fails because the sign-in server can't be reached, or because its file inauth/stays locked by another via process for 3 minutes, or is corrupt or unreadable, rests that account for 1 minute, and via tries the next one; an account removed meanwhile is skipped. Only a refused refresh locks an account out. - One via process refreshes an account at a time, so
via accounts statusnext to a runningvia servenever spends a refresh token twice. If the new tokens can't be saved (a full disk, say), via keeps them in memory and saves them the next time it uses the account; they are lost if via stops first. - File locks use persistent, owner-only
<path>.lock.dbfiles. The operating system keeps a live process exclusive, even when it is paused, and releases its lock when the process exits. Do not delete these files while via is running. Stop all via server and CLI processes before upgrading to this lock format: older processes use a different lock and cannot coordinate with it. The lock databases contain no account tokens or API keys. - When no account is left, the client gets
429with aRetry-Afterheader (or503if waiting won't help). For a model only some accounts offer, only those accounts count. - Running cooldowns are saved in
state.json, so a restartedvia servekeeps them. Lockouts aren't saved: a restart gives a locked-out account one more try, once a cooldown it was locked out in has run out. - A cooldown's or lockout's reason, which
state.json, the logs andGET /admin/poolshow, is always a code such asusage_limit_reached. When an upstream's code is anything other than a short identifier (letters, digits,_,.,:and-, up to 64 characters), via names the reason after the answer instead:rate_limited,upstream_<status>orforbidden. An older via may have saved such a code instate.json; it is read asunknown. - In the background, via also asks ChatGPT for each account's usage when it starts and every 15 minutes after that, a few accounts at a time, so an already-exhausted account cools down before its next request would hit a 429. The web UI shows the same usage. This never ends a cooldown early, only starts one or extends it to a later reset that ChatGPT has confirmed.
OpenCode Go accounts, for opencode-go/ models, make a pool of their own that
works the same way: fill-first in the order you added the keys, a conversation
kept on the key that last answered it, and a 429 (or every key resting)
handled as above. A key's cooldown lasts until its used-up usage window resets,
or as long as the answer's Retry-After asks, whichever is later. via doesn't
wait for the usage before trying the next key: the key rests at once for its
Retry-After, or 1 minute without one, while via asks its usage in the
background and then lengthens the cooldown to match. A key OpenCode Go refuses
or forbids (401 or 403) is taken out of use until via serve restarts. via
asks OpenCode Go for each key's usage every 15 minutes too.
When no account can serve a request, a fallback rule can
send it to another model instead of answering 429 or 503.
via keeps everything in ~/.config/via, or in $VIA_HOME if it's set and not empty.
| File | Contents |
|---|---|
config.yaml |
Optional settings; you write it, via only reads it. |
keys.json |
Your API keys' SHA-256 hashes and last use. |
auth/<id>.json |
One account's OAuth tokens. |
state.json |
Running cooldowns; safe to delete. |
opencode-go.json |
Your OpenCode Go API keys, as accounts. |
ollama.json |
The address of the Ollama added in the web UI. |
openrouter.json |
Your OpenRouter API key and the models it enables. |
fallbacks.json |
Your fallback models. |
usage.db |
The usage history, in SQLite. |
capture.json |
Whether to keep failed turns' prompts. |
bodies.db |
The captured prompts, in SQLite. |
An auth/<id>.json via can't read is skipped with a warning naming it, so the
other accounts keep working. Delete it and sign that account in again.
config.yaml, with the defaults:
host: 127.0.0.1
port: 8317
codex:
# Present requests to Codex as the official Codex CLI; false sends via's own name.
cloak: truevia serve --host and --port override the file. via refuses a key it doesn't
know, at any level, such as a misspelt prot: or apiKeyENV:, and names it.
The usage history prices tokens with a snapshot of two
price tables that ships with via: models.dev for what
OpenCode Go charges for each of its models, and
LiteLLM's
for the rest. The snapshot's header says on which day it was taken, and from
which commit of LiteLLM's table. For a model neither lists, or at another price, add it under
prices, in USD per million tokens. cachedInput and cacheWrite are
optional: input read from the cache, and input written to it, cost what other
input does unless they're set. A price that grows past a long context
is taken at its base rate.
prices:
opencode-go/kimi-k3:
input: 0.6
cachedInput: 0.1
cacheWrite: 0.75
output: 2.5A model is looked up as it's asked for, then without its <provider>/ prefix,
then without a reasoning-effort suffix such as -high, ignoring case; a price
in config.yaml wins over the snapshot at each step.
Add OpenAI-compatible providers under providers. Each one reads its API key
from the environment variable apiKeyEnv names; via serve won't start while
that variable is unset. A provider without apiKeyEnv, such as a model server
on your own machine, is sent no key. OpenRouter still needs one: from
apiKeyEnv, or from the web UI or via providers (see OpenRouter).
OpenCode Go is the exception: it needs no entry at all. Add its keys as
accounts with via accounts add --provider opencode-go, and via pools them
like ChatGPT accounts (see How the pool picks an account).
Deprecated: reading OpenCode Go's key from
apiKeyEnv(such asOPENCODE_API_KEY). While that variable is set,via serveadds its key as an account namedOpenCode Go (imported), once, and logs a warning at startup. Until then,via accounts statusshows the key by the variable's name. Remove the variable (and theopencode-goentry, unless you set itsbaseUrlorsessionHeader); a later release stops reading it.
providers:
openrouter:
apiKeyEnv: OPENROUTER_API_KEY
# Ollama needs no key, and is looked for at http://localhost:11434/v1.
ollama: {}
# Any other OpenAI-compatible endpoint needs its baseUrl.
vllm:
baseUrl: http://gpu-box:8000/v1
apiKeyEnv: VLLM_KEY
# Optional: the header the provider reads a session id from.
sessionHeader: x-session-idAdd your OpenRouter API key on the Accounts page of the web UI. via
checks it with OpenRouter first, then keeps it in openrouter.json, and shows
it only by its last four characters. OpenRouter lists hundreds of models, so
none is offered until you choose some: Choose models… in its row lists
them all, with OpenRouter's prices, to search and switch on. A model whose
price OpenRouter doesn't fix, such as openrouter/auto, which costs what the
model it routes to does, shows "Price unknown". Only those show
in /v1/models; a request for another answers 404 (model_not_found),
saying to enable it. Replacing the key keeps the models chosen. It all takes
effect at once, with no restart.
via providers openrouter set-key and via providers openrouter models do the
same from the command line (see Commands); via serve uses what
they save once it restarts.
An openrouter entry in config.yaml with apiKeyEnv wins: the web UI shows
its key, and via offers every model OpenRouter lists. One without apiKeyEnv
only moves where the web UI's key is sent, with baseUrl or sessionHeader.
OpenRouter key replacements keep concurrent changes to the enabled models. Routes change only after the settings file is saved.
Add Ollama on the Accounts page of the web UI: give its address,
such as http://192.168.1.20:11434, and via sends to it at once, with no
restart. The page shows the Ollama version there and its models, and changes
or removes the address later. via keeps it in ollama.json.
via providers ollama set <address> saves it from the command line, for
via serve to use once it restarts.
Or set it up in config.yaml, which then wins: the web UI shows that address
but leaves changing it to config.yaml. With ollama: {}, via uses the Ollama
on the same machine, and lists its local models as ollama/<model>, such as
ollama/llama3.2. When Ollama runs somewhere else, give its address as
baseUrl, ending in /v1:
- on another machine:
baseUrl: http://192.168.1.20:11434/v1, with Ollama listening beyond its own machine (OLLAMA_HOST=0.0.0.0); - when via runs in Docker and Ollama on the host:
baseUrl: http://host.docker.internal:11434/v1(Docker Desktop), aslocalhostin the container is the container itself.
Ollama's System One answers choice,
yes/no and scoring questions about a state with a local model such as
nimble, and needs Ollama 0.35.0 or later. via serves it at
POST /v1/systemone with the same body, the model prefixed:
curl http://127.0.0.1:8317/v1/systemone \
-H "Authorization: Bearer $VIA_KEY" \
-d '{"model": "ollama/nimble", "state": "Checkout has returned 500s since 9am.",
"questions": {"label": {"type": "choice", "instructions": "Which label fits?",
"criteria": {"billing": "Payments", "bug": "Software errors"}}}}'It goes to the provider the model names, as it is, and is kept in the
usage history like any other request. A local model's tokens
are counted there, but cost nothing. A model with no
provider prefix, which would go to Codex, is refused with 400.
Prefix a model with the provider's name to use it, as in
openrouter/qwen/qwen3-coder or opencode-go/kimi-k3. via passes
/v1/chat/completions and /v1/responses requests on as they are, with only
the prefix taken off the model, and passes the provider's answer back the same
way, errors included. Of the answer's headers, the client gets its
content-type, Retry-After and x-ratelimit-* ones. A streamed answer the
provider breaks off ends in an upstream_incomplete error, as an event of
the API the client asked in.
Of the client's request headers, via passes on only those a provider may act
on: anthropic-beta, anthropic-version, openai-beta, and OpenRouter's
http-referer and x-title. A Messages request goes out with the client's
anthropic-version, or 2023-06-01 without one. Every other header,
authorization, cookies and host included, stays with via, which sends the
provider its own key.
OpenCode Go serves each model in one API of its own choosing: Chat
Completions, the Responses API, or Anthropic's Messages, and refuses a
request in another with ModelProtocolUnsupported. For a
/v1/chat/completions request, via then asks again in the next of those,
translating the request and the answer, streamed or not, tool calls and
usage included, and remembers which one the model answered in, so later
requests go straight there. A restart forgets, at the cost of one refused
request per model. A request translated to Messages asks for a temperature
of at most 1, the highest Messages takes, and goes without its
response_format, which Messages has no field for. A /v1/responses request still goes only as it came, so
a model OpenCode Go serves only in Chat Completions or Messages refuses it.
/v1/models lists every provider's
models with their prefix and the details the provider gives, such as
context_length; a provider that can't be reached is left out, as is OpenCode
Go while none of its accounts is enabled.
To keep a conversation on a warm prompt cache, via gives each request a session
id: the one the client sent, in x-parent-session-id (so OpenCode's sub-agents
share their parent's), x-opencode-session-id, x-opencode-session,
x-claude-code-session-id, session-id (which Codex's sub-agents share),
session_id, thread-id, x-session-id, the body's session_id or
prompt_cache_key, x-task-id or x-kilocode-taskid;
otherwise one derived from the conversation's first system and user message.
OpenCode Go gets it in x-opencode-session, OpenRouter in the body's
session_id and, unless the client set one, prompt_cache_key, and Codex in
its session_id header.
OpenCode also writes its session id into the <env> block of its system
prompt, which would keep its sub-agents from sharing a prompt cache with each
other and their parent. via moves that one line, for Chat Completions, to the
start of the first user message; the model still sees it.
via serve logs one line per request once its answer has been sent, as
key=value pairs:
timestamp=2026-09-25T16:32:34.464Z level=INFO fiber=#27 message="Sent HTTP response" request_id=592f9436-3ad2-4e0f-a239-d4803bdb9925 http.span=3607ms http.method=POST http.url=/v1/chat/completions http.status=200 key=laptop model=opencode-go/deepseek-v4.1-flash served_by=go-work input_tokens=812 output_tokens=194 headers_ms=2712 first_chunk_ms=3433 stream_end=completed
timestamp=2026-09-25T16:32:37.464Z level=INFO fiber=#28 message="Sent HTTP response" request_id=2a374005-22ec-40ed-a5e5-1dfa5aa6a807 http.span=5ms http.method=POST http.url=/v1/chat/completions http.status=429 key=laptop model=gpt-6-astra error=rate_limit_exceeded retry_after=120
request_idis the request's own id, a UUID via generates for every request: therequestIdthe usage history, the turns in flight and a captured prompt name it by. via stamps it on any warning logged while handling the request (a cooldown, say), even across a retry onto another account, and sends it back asx-request-id.client_request_idis the client's ownx-request-idheader, when it sent a valid UUID (lower-cased). via echoes that one back asx-request-idin place of its own, so the client can correlate its answer, but never names a request by it: a client can send the same one with many requests.http.spanis the whole time via took, a stream included.keyis the name of the API key the client used,modelthe model asked for, andserved_bythe provider, or the Codex or OpenCode Go account, that answered. When a fallback answered,modelis the fallback,requested_modelthe model asked for andfallback_reasonwhy it couldn't serve, such asrate_limit_exceeded; anINFOline before it says which model was asked instead, and why.input_tokensandoutput_tokensare the token counts the upstream reported for an answered request, andcached_tokensjoins them when the upstream reports a cache hit, andcache_write_tokenswhen it reports input written to its cache (OpenRouter does, as do models OpenCode Go serves in Messages).reasoning_tokensis the part of the output the model spent reasoning, andcost_usdwhat the upstream says it billed, when it reports either (OpenRouter reports its cost). Absent usage stays absent, never a zero.- For an answer the client asked to stream,
headers_msandfirst_chunk_mssay when its headers and the first chunk of its answer went out (a: keepalivecomment doesn't count), andstream_endhow it ended:completed,client_aborted(the client went away first) orfailed(it broke off, such as when the upstream dropped the connection). - For an error via answers itself,
erroris its code andretry_afterthe seconds until an account frees up. For an error the upstream answered,upstream_erroris the code it gave, such asModelProtocolUnsupported, when that is an identifier (letters, digits and_.:-, at most 64). This line never has the upstream's message. Nor does theINFOline for a fallback, which gives the upstream's code on the same terms and its status, such ashttp_503, otherwise. - A request the client gave up on before via answered is logged with
http.status=499, as nginx does. The client never sees that status. Like a404for a path via doesn't serve, it is only that line, not an error. - A request that fails on a bug in via, or on a file via can't read or write,
answers
500and also logs anERRORline, "Request failed unexpectedly", with the cause. - Opening a page of the web UI logs one line for the page; the scripts, stylesheet and icon it loads aren't logged.
It also warns when an account cools down, is locked out, or has its token rejected, and says until when; and when an account's file can't be read or its new tokens can't be saved.
via writes its log to standard output and keeps no log file. Where the lines
go from there is up to you: a terminal, docker logs, journald. They hold no
prompt or answer. When an upstream fails a request or sends an event via can't
read, the log says so in via's own words, with the status, the error code or
where in the event the problem was, but not the event's content or the
upstream's message.
via serve keeps every request that asks for a model in usage.db, a SQLite
database: when it came in, the API key's id and name, the model, the provider
and account that served it, the model asked for and why it couldn't serve when
a fallback answered instead, its status, why it failed if it did (the error
code, via's own or the upstream's when that is an identifier, and via's own
message; never the upstream's message, which can quote the request, though an
attempt the upstream answered 401, 402, 403, 429 or 5xx
keeps it), its input, cached, cache-write,
output and reasoning tokens as the upstream reported them, any cost the
upstream billed (OpenRouter reports one), and how long it took to answer and,
for a streamed answer, to send its first chunk. It also keeps which
conversation the request belongs to: the session or thread id
the client sent, the id of the conversation that started it for a sub-agent's,
which header or body field the id came from, the sub-agent's type when the
client says, and the client, as the first product of its User-Agent and the
version among its segments (such as opencode/1.19.3, or opencode/2.0.32
from opencode/latest/2.0.32/cli). It never keeps an answer, nor a prompt unless you turn on
keeping failed turns' prompts, which keeps those apart, in
bodies.db. It also keeps each
request's upstream attempts. Requests older
than 90 days are deleted at startup and once a day after that, with their attempts. Starting two via
processes together does not overwrite an existing usage database. A streamed answer
that ends with an SSE error is recorded as failed, including an answer that via
marks incomplete after the upstream cuts it off.
The web UI's Usage page and the /admin/history routes of the
admin API read it. Each takes a range, from and to in epoch
milliseconds; series and breakdown also take groupBy (model,
account, key or provider), and series takes a bucket (hour or
day) and timeZone, an IANA time zone such as Europe/Amsterdam, so days
start at your midnight, also across a change to or from summer time, when a day
lasts 23 or 25 hours. An hour starts on the hour of your clock, or when the
clock changes: the hour it repeats when summer time ends is a bar of its own,
and a change of half an hour, as on Lord Howe Island, leaves a half hour. An
unknown time zone answers 400. timeZone replaces the tzOffsetMinutes that
series took before: a request that sends only tzOffsetMinutes now answers 400. A request no
account served, such as one to a plain provider, counts under
provider:<name> when grouped by account. All three also take filters:
model (the model that answered), accountId (an account's id, or
provider:<name>), keyId, outcome (error for failed requests only, ok
for the rest), and fellBack (true for requests a
fallback answered, false for the rest). The breakdown's
groups and totals count those in fellBack, beside errors.
requests answers {"requests": [...], "next": ...}, 50 requests a page, or
limit of them (1 to 500). next is null on the last page; otherwise pass
its at and requestId back as afterAt and afterId, with the same range
and filters, for the page after it:
RANGE="from=1790000000000&to=1790086400000"
curl -H "Authorization: Bearer $VIA_ADMIN_KEY" \
"http://127.0.0.1:8317/admin/history/requests?$RANGE&limit=100"
# {"requests":[...],"next":{"at":1790040000000,"requestId":"<id>"}}
curl -H "Authorization: Bearer $VIA_ADMIN_KEY" \
"http://127.0.0.1:8317/admin/history/requests?$RANGE&limit=100&afterAt=1790040000000&afterId=<id>"Cost is worked out when you ask, from the tokens and the model prices, so a price change applies to old requests too:
- A request whose upstream reported a cost counts at that cost, as billed.
- Every other request counts at API prices, as the API-equivalent cost: what its tokens would have cost pay-as-you-go. For a ChatGPT or OpenCode Go subscription that's what the plan saved, not money spent.
- A model of a provider sent no key, such as Ollama, runs on your own
hardware, so its tokens cost nothing. A price for it under
pricesstill counts. - Any other model with no known price is named rather than counted as free.
Token totals leave out answered requests whose upstream reported no usage, and the page says how many. A failed request has no tokens to report, so it isn't counted there; the Requests list says why it failed.
A request can go to more than one upstream before it is answered: the next
Codex or OpenCode Go account when one is rate limited, out of budget or
refused, the next protocol an OpenCode Go model may speak, and the
fallback models. via keeps each attempt beside the request,
in order: its seq (from 0), startedAt (epoch milliseconds), durationMs
(until it failed, or, for the one that answered, until the answer ended),
firstChunkMs (from its start to the first chunk of a streamed answer, for the
one that answered), provider, model, accountId and accountLabel (for a
pooled account), and status, the upstream's HTTP status (null when it was
never reached or never answered). Its outcome is served (its answer went to
the client), refused (the upstream refused the request itself, and the client
got that refusal) or failed (it couldn't serve, so via tried the next one, or
gave up). One that didn't serve also has:
category, why:budget_exhausted(a 402, or a code such as Codex'susage_limit_reachedorinsufficient_quota),rate_limited(a 429),auth(a 401 or 403),downstream_unavailable(a 502, 503 or 504, an overloaded upstream, or an error a router such as OpenRouter says came from the provider it sent the request on to),context_too_long,content_filtered,bad_request(any other 4xx),timeout,network(the connection failed, or the answer broke off, as with an event via can't read, whoseupstreamCodeis thenupstream_incomplete) orunknown. A code via knows outranks the status, so a 429 that says the quota ran out isbudget_exhausted.upstreamCode, the upstream's own error code or type, when it is an identifier.downstreamProvider, the provider a router names in its error (OpenRouter'serror.metadata.provider_name), such asTogether.message, the upstream's error message, cut to 500 characters, only for a 401, 402, 403, 429 or 5xx. Those answers are about the account (its key, its budget, its rate limit) or the upstream itself (down or overloaded), so they don't quote the request, and their words are what tells you why: "the provider says the budget ran out". A 400, 413 or 422, or any other status, judges the request itself and can quote the prompt back, so its message is never kept, as the request's ownerrornever keeps one. A secret the kept message quotes, such as a key or a private key, is replaced first, as in a captured prompt.
conversations/<id> gives every turn's attempts as attempts, in the order of
the turns and then by seq, each naming its turn by requestId. Requests kept
before this release have no attempts.
via groups requests into conversations by the ids coding agents send. A
request's conversation is its own, not its parent's: Codex's thread-id (or
the body's client_metadata.thread_id, or conversation_id from older
builds), Claude Code's x-claude-code-session-id, or for its sub-agent
x-claude-code-agent-id within that session, OpenCode's
x-opencode-session-id, and then the ids the session id above reads. A
sub-agent's conversation sits under the one that started it, from
x-codex-parent-thread-id, x-claude-code-parent-agent-id (else its session),
x-opencode-parent-session-id or x-parent-session-id; its type comes from
x-openai-subagent or x-claude-code-agent-type. A request whose client sent
no id, as Aider and Continue do, gets a hash of the conversation's opening, the
same for every turn, and the conversation is marked guessed. An id longer than
100 characters is kept as its SHA-256 hash. Requests kept before this release
belong to no conversation.
conversations lists the conversations no other started, the latest active
first, that have turns in the range the filters keep. A conversation is a
sub-agent's once any of its turns names its parent, and is listed on its own
when via kept no turn of that parent. Each counts those turns,
its sub-agents' included, with its id, source (the header, body.<field>
or hash), guessed, client, keyName, models, firstAt, lastAt,
turns, subagents, its tokens, failed, fellBack and cost, priced as
above. It takes the range, the filters and limit as requests does, and
pages the same way: pass next's at and id back as afterAt and
afterId. conversations/<id> answers
{"summary": {...}, "turns": [...], "attempts": [...], "captured": [...]}:
every request of the conversation and its sub-agents', oldest first, whenever
they came in, their upstream attempts, and the
requestIds of the turns whose prompt via captured, in
the turns' order.
curl -H "Authorization: Bearer $VIA_ADMIN_KEY" \
"http://127.0.0.1:8317/admin/history/conversations?$RANGE"
# {"conversations":[{"id":"ses_...","source":"x-opencode-session-id","guessed":false,...}],"next":null}
curl -H "Authorization: Bearer $VIA_ADMIN_KEY" \
"http://127.0.0.1:8317/admin/history/conversations/ses_..."When a turn fails, the history says how, not what was asked. Turn on keep
prompts of failed requests in the web UI, or
PUT /admin/history/capture with {"keepFailedPrompts": true}, and via also
keeps, for each turn that fails (an error status other than a client giving up,
or a stream that broke off) or that a fallback answered:
- the request body the client sent to
/v1/chat/completionsor/v1/responses. Chat and Responses clients send the whole conversation each turn, so this is the conversation up to the turn that failed, system prompt and tool results included; - what every upstream attempt that failed answered, its body as JSON or as text: up to its first 64 KiB, or 16 KiB of one via passed on to the client as it came.
It is off until you turn it on, and turning it off stops keeping more without
deleting what is kept. A turn that goes well is never kept, nor one served by
the next account after the first was rate limited, since no other model
answered. The setting is kept in capture.json, and the prompts in
bodies.db, a SQLite database of their own beside usage.db, readable only
by you, overwriting what it deletes (secure_delete) and emptying its
bodies.db-wal after each delete, so a deleted prompt is left in neither. A prompt is kept for 7
days: via deletes older ones at startup and every hour, and never answers
with one older than that in between. Delete captured
prompts in the web UI, or DELETE /admin/history/bodies, deletes them all
and keeps the history; deleting the history deletes them too.
Before anything is written, via redacts it:
- Secrets it has a pattern for become a marker naming their kind: OpenAI
(
sk-…,sk-proj-…,[redacted:openai-key]), Anthropic (sk-ant-…) and OpenRouter (sk-or-v1-…) keys, GitHub tokens (ghp_,gho_,ghs_,ghu_,ghr_,github_pat_), AWS access keys (AKIA…,ASIA…) and a secret access key assigned by name, Google API keys (AIza…), Slack tokens (xox…), JWTs, PEM private keys (to theirENDline, or to the end of the text when one was cut off before it), the token afterBearer, the credential of anAuthorizationorProxy-Authorizationheader (its scheme, such asBasic, is kept) and the value of aCookieorSet-Cookieheader. A JSON value under anAuthorizationorCookiekey, such as in aheadersobject, is replaced whatever it looks like. A value assigned to a name such asAPI_KEY=,"secret":,password:or"accessToken":(snake_case or camelCase, and each item of an array under such a name) is replaced too when it looks random: 12 characters or more, letters and digits, no spaces; after a scheme such asBasicorToken, it always is. - Images and files sent inline, as a data URL anywhere, as base64 in
image_url,file_dataor a Responsesinput_imageorinput_file, or as any string of 1 KB or more of nothing but base64, become a note of what they were, such as[image: png, 412 KB]. An image sent as a link is kept as its link. - A request still over 4 MB has its longest strings cut until it fits, each
marked
…[truncated <n> characters].
GET /admin/history/requests/<requestId>/body answers what was kept:
requestId, capturedAt (epoch milliseconds), endpoint
(chat/completions or responses), request (the redacted body),
upstreamErrors (each attempt's seq, status, null when it never
answered, and body), redactions (how many of each kind were replaced, such
as [{"kind": "openai-key", "count": 2}]), truncated, and sizeBytes, the
size of request as kept. It answers 404 for a turn via kept no prompt of.
Redaction works by pattern, so it can't promise a prompt holds no secret: a
password such as hunter2, a key of a kind it has no pattern for, or a secret
written in a way it doesn't expect is kept as written. Treat bodies.db as
you would the conversations themselves, and turn the setting on only while you
need it.
Set OTEL_EXPORTER_OTLP_ENDPOINT to an OpenTelemetry collector's OTLP/HTTP
address, such as http://localhost:4318, and via serve exports a trace of
each request it serves. The other standard variables work too:
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, OTEL_EXPORTER_OTLP_HEADERS,
OTEL_BSP_SCHEDULE_DELAY, and OTEL_SDK_DISABLED=true to turn it off.
Back up first. Until 1.0, a release may change commands and file
formats. via upgrades usage.db and bodies.db itself when it starts, and leaves its other
files as they are; the release notes say when you need to change something,
such as a config.yaml key. An older via may not read a newer via's files, so
a backup is how you go back.
From source, pull and build again, then replace the binary on your PATH and
restart via serve:
git pull
bun install
bun run --cwd apps/web build
cd npm && bun build.ts --hostWith Docker, pull the new image and start a new container on the same volume:
docker pull ghcr.io/nkootstra/via:latest
docker rm -f via
docker run -d --name via --restart unless-stopped \
-p 127.0.0.1:8317:8317 -v via-data:/data \
ghcr.io/nkootstra/via:latestWith Compose, docker compose pull && docker compose up -d. If you pinned a
version, change the tag first.
These files in via's home are worth keeping:
auth/,opencode-go.jsonandopenrouter.json: your accounts' tokens and keys. Without them you sign every account in again.keys.json: your API keys. Without it every client needs a new key.config.yaml, if you wrote one,ollama.jsonandfallbacks.json.usage.db: the usage history.bodies.dbholds captured prompts, which are deleted after 7 days anyway; leave it out to keep your backups free of prompts.
state.json only holds running cooldowns, so it can go.
The backup holds tokens and keys in plaintext: keep it as private as the folder itself. A token in it goes stale: via replaces an account's refresh token each time it refreshes, so an account restored from an old backup may be locked out until you sign it in again.
usage.db is SQLite in WAL mode: recent writes sit in usage.db-wal until
SQLite moves them into usage.db. Copying usage.db alone while via runs can
lose them or give you a damaged copy. Stop via before you copy the folder, or
let SQLite make the copy, which is safe while via runs:
sqlite3 ~/.config/via/usage.db ".backup usage-backup.db"With Docker, stop the container and archive the volume:
docker stop via
docker run --rm -v via-data:/data:ro -v "$PWD":/backup alpine \
tar czf /backup/via-data.tgz -C /data .
docker start via- Stop
via serve. - Delete the binary from your
PATH. - Delete via's home:
~/.config/via, or$VIA_HOMEif you set it. That removes the accounts' tokens, the API keys and the usage history.
With Docker, remove the container, the volume with everything in it, and the image:
docker rm -f via
docker volume rm via-data
docker image rm ghcr.io/nkootstra/via:latestDeleting the files, like via accounts remove, doesn't revoke anything. To
end the access for good, log out of the sessions in your ChatGPT account's
security settings, and revoke the OpenCode Go and OpenRouter keys on their
sites.
error: ... port 8317 ... in use. Something else listens on the port,
often another via serve. Stop it, or start via on another port with
via serve --port <n> or port: in config.yaml.
error: <path> has a problem: <reason>. config.yaml doesn't parse,
or has a key via doesn't know or a value it can't take; the reason names it.
Fix that line, or move the file away to start from the defaults.
error: VIA_ADMIN_KEY must be at least 32 characters; it has <n>. Make a
longer key with openssl rand -hex 32, or unset VIA_ADMIN_KEY to serve
without the admin API and web UI.
An account is locked out. via logs
<account> is locked out until it logs in again (<reason>), the web UI's
Overview shows it as Locked out, and GET /admin/pool as auth_error.
Codex refused its tokens even after a refresh, as when its sign-in was revoked
or it was restored from an old backup. Sign it in again with the
web UI's Add account, which puts it back in use at once, or with
via accounts add and then a restart of via serve. via replaces its tokens
and keeps its label (see
How the pool picks an account).
- Account tokens are stored as plaintext JSON, readable only by you (files
0600, directory0700). Anyone who can read them can use your ChatGPT accounts. - OpenCode Go API keys are stored the same way, in plaintext in
opencode-go.json, readable only by you, and an OpenRouter key added in the web UI inopenrouter.json. - The usage history,
usage.db, holds API key names, account labels and models, the session, thread and parent ids clients send and the first product of theirUser-Agent, never prompts or answers, and is readable only by you. Of an upstream's error it keeps the status and the code, when the code is an identifier, and the name of the provider a router says it sent the request on to. It keeps the message only for an attempt the upstream answered 401, 402, 403, 429 or 5xx (see Upstream attempts), as those are about the account or the upstream, not the request; never for a 400, 413, 422 or any other, which could quote the request the upstream refused. A secret a kept message quotes is redacted as a captured prompt's is. Upgrading cleared the messages an older via kept, via's own included, as the two can't be told apart. What via deletes or clears in it is overwritten (SQLite'ssecure_delete), not left in the file's free space, and via emptiesusage.db-walafter each delete, so it isn't left there either. - With keeping failed turns' prompts turned on (it is off
until you do),
bodies.dbholds the request bodies of turns that failed or fell back, the conversation up to them, and what the upstreams answered, readable only by you, for 7 days. via redacts the secrets it has a pattern for and the images and files sent inline before it writes anything, but a secret it has no pattern for is kept as written. Delete them with Delete captured prompts orDELETE /admin/history/bodies. state.jsonholds account ids, when their cooldowns end and a reason code for each, never an upstream's message. The log, on standard output only, holds no prompt or answer (see Logs).- API keys are stored only as SHA-256 hashes.
VIA_ADMIN_KEYcan add, change and remove accounts and API keys. Keep it out of clients; only its SHA-256 hash is compared, in constant time. It can also point via at an Ollama address, which via then sends requests to, so whoever holds it can make via reach any address it can.- Admin sessions are kept in memory, each only as the SHA-256 hash of its cookie, and all end when via restarts.
- The server speaks plain HTTP. Keep it on
127.0.0.1, or put your own TLS in front of it before listening on another interface. That goes double for the web UI, where the admin key is typed in. - The web UI runs only first-party code, under a Content Security Policy that refuses scripts, styles and requests from anywhere but via.
To report a vulnerability, see SECURITY.md.
via is not affiliated with or endorsed by OpenAI or OpenCode. It talks to the
backend the Codex CLI uses and, by default, presents itself as that CLI
(codex.cloak in config.yaml turns that off). Pooling
subscriptions this way may conflict with OpenAI's terms of use, and accounts
could be limited or banned; OpenCode Go's terms apply to its keys. Use only
accounts you own, at your own risk.
See CONTRIBUTING.md.






