Bring your own key¶
A person holding the byok capability can add their own providers (with
their own API key) and models, exactly the way an administrator adds the
deployment's, and use them privately. Nobody else sees or uses them. Their usage
is recorded for their own information and nothing else: it is never counted
by a quota and never appears in a group, billing or administrator report.
This page has two halves: for administrators (the capability, the settings, the network guard, what is excluded) and the API (endpoints, payloads, naming, errors), written for whoever builds a client or a screen against it.
For administrators¶
The capability¶
byok is a per-group flag, managed exactly like api_keys and
local_connectors: a person holds it as an administrator, or through an active
membership in an active group with the flag on (Identity).
It needs no chat (unlike code_agents): it is reported on /v1/me and
/api/me either way.
| Where | What |
|---|---|
| Console, Groups | the per-group byok_allowed flag (PATCH /api/admin/groups/{id}); a group with it on and console_allowed off is flagged with a warning |
| Console, Settings | the default new groups get, and a bulk on/off (POST /api/admin/groups/capabilities with "capability": "byok") |
ACCESS_BYOK / GATEWAY_ACCESS__BYOK |
the default byok flag new groups are created with: on or off. Default off, unlike the other capabilities: it lets a person send traffic, with their own credential, to a host of their choosing, which is a decision for the deployment. Existing groups are never changed by it. The console's Settings page decides once it saves (byok_mode on PUT /api/admin/oidc/policy) |
A person holding the capability needs no billing group to use their own
models: those bill nothing, so a missing, disabled or no-longer-member default
group does not stop my/.... Deployment models still need one and are refused as
before, and /v1/models lists only their own models while they have none.
Switching it off for a person does not delete anything. Their private
providers and models stay in the database and stop working (my/... answers
403 byok_not_allowed, and the models drop out of /v1/models); switching it
back on restores them as they were.
The network guard¶
A private provider's base URL is typed by a member, and the gateway then calls
it with a credential and a body they chose. Unguarded that is a way to reach
everything the gateway can reach: the cloud metadata address
(http://169.254.169.254/), http://postgres:5432, http://gateway:8000/api/admin,
a laptop on the office network. So for a private provider (deployment
providers are untouched) the host must resolve only to public addresses.
Refused: loopback, private (RFC 1918 and fc00::/7), link-local (including
169.254.169.254 and fe80::/10), carrier-grade NAT (100.64.0.0/10),
multicast, unspecified, reserved and every other not-globally-routable range;
IPv4-mapped IPv6, NAT64 and 6to4 forms are judged by the IPv4 address inside;
this deployment's service names (gateway, postgres, valkey, redaction,
authelia, caddy, extractor, cerea, ...), any single-label host,
localhost and the .local, .internal, .lan, .home.arpa and .localhost
suffixes. A URL with credentials in it, or a scheme other than http/https,
is refused too.
It is enforced three times: at create and update (a 400,
provider_url_blocked, naming the rule and the address), before every
request and probe (so the caller is told which rule applied), and at connect
time, which is the one that cannot be raced. Connect-time works by pinning:
the gateway resolves the host itself, requires every address it got to be
allowed (a mixed answer is refused outright, not filtered), and opens the socket
to the validated IP, not to the name. The name is never resolved a second time,
so a DNS answer that changes between "check" and "connect" (rebinding) has no
window to act in. TLS is unaffected: the handshake still uses the real hostname
for SNI and certificate verification. Redirects are never followed by any
provider client, and HTTP_PROXY in the environment is ignored for these calls
so a proxy cannot route around the guard. A catalogue URL override
(?url=) is refused for a private provider.
byok_private_networks (ACCESS_BYOK_PRIVATE_NETWORKS /
GATEWAY_ACCESS__BYOK_PRIVATE_NETWORKS, or Settings; default off) lifts
the guard for private providers, for a deployment whose members legitimately run
endpoints on its network (a lab's vLLM). It never opens this deployment itself:
loopback, link-local (the cloud metadata address), this stack's service names,
and every subnet the gateway's own interfaces are on (in a container, the compose
networks: its database, cache and siblings) stay refused, so a member can reach
their LAN but never the gateway's own services. It applies to the very next request,
and turning it back off refuses the next request even over a connection opened
while it was on. It never needs setting for providers on the public internet.
What a member may add¶
Language-model providers only: a plugin of kind provider or router speaking
the OpenAI or Anthropic protocol. Not a search backend, not the document
extractor, not a decision provider. Models of kind chat, embedding, image
and transcription; not ocr, decision, rerank or search. Never public, never
granted to a group, never the target of an alias or a route. A member may hold
at most 25 providers and 500 models. Their credential is encrypted exactly like
a deployment provider's and never returned, by any endpoint, to anyone.
What is excluded, and where¶
BYOK usage is written to the ledger (tokens, cost at the member's own price
rows; an unpriced private model has no cost) with usage_records.byok = true,
the member's user_id and no billing group, under the model name
my/<name>. From there:
- Quotas. No rule is evaluated and no counter moves: the request reserves
nothing (an empty reservation, so every exit still settles). A group cap of
zero does not stop a BYOK request. The ledger-derived counters (cold-cache
rebuilds, quota health, reconcile) skip BYOK rows, so a rebuild cannot
resurrect usage the live path never counted.
x-bill-tois irrelevant. - Reports.
GET /api/admin/reports/usage(and its CSV),/api/admin/usage,/api/admin/requests(and its CSV), the billing-group totals and the reconciliation block all leave BYOK rows out. - Where it does show. In the owner's own usage only:
GET /v1/pystino/usageandGET /api/me/usagecarry it as a separateusage.own_keystotal (every existing field keeps its meaning), andGET /api/me/reports/usageand/api/me/requeststakeown_keys=trueto report only it. Administrators see per-provider and per-model totals on their own view. - Redaction applies. BYOK traffic goes through the same redaction as any request, under the same effective policy. There is no exemption.
Administrators' view¶
GET /api/admin/byok/providers and /models list every user's private rows:
owner (email and name), the provider's name, type and host (never the full
URL, whose path can carry a token), models, enabled, last used and usage totals.
Never the credential: not the key, its hint, its ciphertext or header
values. Rows can be held and released:
| Call | Effect |
|---|---|
POST /api/admin/byok/providers/{id}/disable / /enable |
hold or release a provider |
POST /api/admin/byok/models/{id}/disable / /enable |
hold or release a model |
A hold is its own flag (admin_disabled), separate from the owner's own
is_active, so the owner cannot undo it; the row is kept. Both calls are
written to the audit log (byok.provider.disable,
byok.provider.enable, byok.model.disable, byok.model.enable). A member's
own edits are not: the audit log records administrators' acts, and would
otherwise carry one person's private endpoint names to everyone who reads it.
The ordinary admin catalogue routes (/api/admin/providers, /models, grants,
aliases) see deployment rows only; a private id is a 404 there.
Deleting a user deletes their private models and providers with them (the delete preview counts them), and a merge drops the merged-away account's. The ledger keeps its rows either way, like every other usage row.
The API¶
Everything under /api/me/ is cookie-session only, like the rest of /api.
/v1 is where the models are used, with an API key or an access token.
Capability and errors¶
GET /v1/me and GET /api/me report capabilities.byok (boolean). It is
false without console access (Identity): BYOK
is managed in the console, so a group's byok_allowed only takes effect for
members who also hold console (administrators hold both).
Every /api/me/providers*, /api/me/models*, /api/me/provider-plugins and
/api/me/provider-catalogue call needs it: without it the answer is
403 byok_not_allowed ("Bring-your-own-key is not enabled for this
account..."). Using a my/... model on /v1 without it is the same 403. A row
that is not the caller's (another user's, or a deployment's) is 404, never
403. Other codes:
| Status / code | When |
|---|---|
400 provider_url_blocked |
the base URL fails the network guard |
400 byok_plugin_not_allowed |
the provider type is not a language-model provider |
400 byok_kind_not_allowed |
provider kind other than provider/router, or a model kind other than chat, embedding, image, transcription |
400 byok_header_not_allowed |
extra_headers names Host, Content-Length, Transfer-Encoding, Connection, X-Forwarded-* and similar |
400 byok_not_shareable |
is_public: true |
400 byok_catalogue_url |
a catalogue url= override |
400 byok_limit_reached |
25 providers or 500 models already |
| 400 | a model name starting with my/ (reserved) |
| 409 | a name the caller already uses (provider names and model names are unique per owner), or deleting a provider that still has models |
Endpoints¶
Same operations and payloads as the administrators' (ProviderCreateRequest,
ProviderUpdateRequest, ProviderResponse, ModelCreateRequest,
ModelUpdateRequest, ModelAdminResponse, PriceCreateRequest,
PriceResponse, CatalogueDiscoveryResponse, ModelImportRequest ... in
openapi.json), on the caller's own rows:
| Call | |
|---|---|
GET /api/me/provider-plugins |
the provider types a member may pick: language-model types only |
GET /api/me/provider-catalogue |
the deployment's catalogue entries a member could use, to prefill type and endpoint |
GET /api/me/providers |
the caller's providers (paginated envelope: items, total, limit, offset) |
POST /api/me/providers |
create. name, base_url (omit when the type has a default), api_key (write-only), plugin, kind (provider/router), extra_headers, prefix, billing_mode, catalogue_id |
PATCH /api/me/providers/{id} |
change. api_key: omitted keeps, a value rotates, "" clears |
DELETE /api/me/providers/{id} |
204; 409 while models use it |
POST /api/me/providers/{id}/test |
probe the provider with its stored key; always 200, ok says how it went |
GET /api/me/models |
the caller's models (?q=, ?provider_id=, ?include_inactive=) |
POST /api/me/models |
create. name (without my/), upstream_model, provider_id (one of the caller's), kind, limits, modalities |
GET / PATCH / DELETE /api/me/models/{id} |
read, change, remove (prices go with it; the ledger keeps its rows) |
GET /api/me/models/{id}/prices |
price history, newest first |
POST /api/me/models/{id}/prices |
append a price row (never edited). Cost is computed at these rows; none means no cost |
GET /api/me/models/discover?provider_id= |
compare the provider's own /models with the caller's models; fill_missing_prices as for administrators; always the provider's whole catalogue; no url |
POST /api/me/models/import?provider_id= |
adopt selected models (with the listed price or a manual_price); only the allowed kinds import |
Credentials: ProviderResponse carries has_api_key and a masked
api_key_hint and never the key.
Naming on /v1¶
A private model is addressed my/<name> on every /v1 surface (chat
completions, responses, messages, embeddings, images, transcriptions,
/v1/models, /v1/models/{id}), and the response's model is the name the
caller sent. The prefix belongs to the API and is not stored: the same row is
name in the owner's console. my/<name> always means the caller's own model,
so it can never shadow a deployment one (and a stored name may not begin with
my/).
GET /v1/models lists the caller's own models with "byok": true (deployment
entries carry false); older clients that ignore or .nullish()-parse the field
are unaffected. Another user's private models are never listed.
Usage¶
GET /v1/pystino/usage and GET /api/me/usage: the existing figures are what
counts against quotas and group spend; usage.own_keys is {window_seconds,
requests, total_tokens, cost, currency, estimated_requests} for the caller's own
keys. GET /api/me/reports/usage?own_keys=true and
GET /api/me/requests?own_keys=true (and .csv) report only that usage; without
the flag it is left out. Rows are named my/<name>.
Administrators¶
GET /api/admin/byok/providers, GET /api/admin/byok/models
(?q=, ?owner_id=, models also ?provider_id=), and
POST /api/admin/byok/{providers,models}/{id}/{disable,enable}: see
the administrators' view. The admin group, user and
policy payloads gain byok_allowed (groups, users) and byok_mode,
byok_private_networks (policy).