What changes
New kinds:
Account, Pool and Budget, with their CRUD routes (/api/accounts, /api/pools, /api/budgets) and RBAC kinds (accounts, pools, budgets, granted by the built-in roles). Relay makes a team, project, personal, member and service account for each matching row, and you create provider accounts; see Accounts and lanes. Teams may now own host keys and rate limits as well. New reads: GET /api/me/accounts, GET /api/accounts/{ref}/limits, GET /api/keys/{ref}/limits and GET /api/plan-issues; see Control API. A request can run in another of its credential’s accounts with X-WR-Account, a /a/<account>/ base URL, or RELAY_ACCOUNT_DOMAIN.
Behaviour that changes at request time:
- Reach without a policy widens. In 0.11 a project-scoped credential that resolved no policy was refused with
no_policy. In 0.20 a request whose ladder links no pools reaches the host keys owned by its project, its team and the system, or for a personal account the user’s own and the system’s. Host keys created with the admin token are system-owned, so every such request can use them. To keep a team or project on specific keys, bind its account a policy that links the pools it should use; see Reach without a linking rung. - Bindings on users apply to their member accounts. A 0.11 binding owned by a project and naming a user becomes a binding of that user’s member account in the project. It governs requests in that account: the user’s inference tokens for the project, and relay keys created in the account. The user’s existing keys run in the personal account and are not governed by it.
- Inference tokens run in a member account. A token runs in its user’s member account in the token’s project. A user who holds a role in that project directly, or whom a 0.11 project binding names, has one after the upgrade. A user whose access to the project comes only through a group or a team-level role has none until the next mint creates it: tokens minted before the upgrade answer
403 account_not_held, and the user mints new ones. - Rate-limit and budget counters start over. They are now kept per account, so the counts and spend of the current windows and periods begin at zero at the upgrade.
- Every host key has an account. The upgrade puts each host key without one into an account per owner and host, marked
groupedByRelay. A key saved later must name its account (400 account_requiredotherwise): create the account, then add keys. A429from the provider on a key in a grouped account still goes to that key’s breaker and the request fails over to the next key, as in 0.11; a429in any other account cools the account for the requested model. Set a grouped account’sorgIdonce you know its keys share one provider organization, or split its keys into an account per organization. See Accounts grouped by the upgrade. - Deprecated models are granted like any other. A
providerreference covers them, and a request whose ladder lists no models may call them. - Usage events carry
lane_account_id, the account the request ran in, andrefusalon the requests Relay refused itself, which are now recorded with the status and message the caller received.policy_idrecords the innermost caller policy: the lane’s, else the project’s, else the team’s. See Usage. - Proxy mode refuses a model that is not in the catalog, also for a key that had no policy, instead of forwarding it unpriced.
- Batch items run under the ladder as it is when each item runs, not as it was at submission.
- Error codes.
no_policy,policy_not_found,policyless_disabledandproject_unavailableare gone;subject_disabled,duplicate_binding,unpriced_under_budget,budget_exceeded,account_closedandaccount_not_heldare new. A request whose every host key is out of rotation answers503 no_healthy_keys, where 0.11 answered502 upstream_error. Relay’s own refusals carryerror.refusal(model_not_allowed,host_not_reachableorno_capacity).model_not_allowednames the rung and the policy that refused. A disabled, revoked, expired or rotated-out relay key answers401 invalid api key. See Errors.
What the migrations do
Migration 35 adds theaccounts, pools, pool_credentials and budgets tables and the junctions linking a policy to pools, host keys, rate limits and budgets. Policy bindings gain team and system owners and more subject kinds. Every existing binding stays owned by its project.
It then enforces one binding per subject: a subject other than a user may appear in one binding, and a user in one binding per owning project. If the database holds a subject bound twice, the migration stops and nothing of it is applied:
policy_models and policy_host_keys tables, the policies.rate_limit_id column and the policy_bindings.priority column, and removes modelIds, hostKeyIds, rateLimitId, rlBindings and keySelection from every policy, policyId from every relay key, service account and host key, policies and defaultPolicy from every host, and budget from every team and project. Policies keep their name, owner, models, payloadLoggingEnabled and enabled for migration 39. Policy bindings, rate limits, host keys, relay keys, teams, projects and service accounts are kept.
Migration 36 refuses to run if any still-bound policy would lose its only access restriction. A policy with rows in
policy_models and an empty models field, or rows in policy_host_keys and no pools or credentials, becomes unrestricted once those rows are gone. If any such policy is bound to a non-group subject—disabled bindings count—the migration stops and lists each policy and its bindings. Adding models or pools before upgrading is not possible; the pools table exists only in 0.20. Unbind those policies (or delete their bindings) on the 0.11 database first, upgrade, then add models or pools following Re-creating the dropped links. See step 5 of Before you upgrade for the check query.relay migrate up: one log entry per link, with the message migrate: postgres notice and the line below in its notice field. A target whose row no longer exists is named by its id:
-2, -3, … when the name is taken). A key created with the admin token is system-owned and joins the system’s account. Keys whose host no longer exists are left without one. Migration 41 marks these accounts groupedByRelay unless they already name an orgId, groups the same way any key still without an account whose host exists, and stops Relay from creating accounts for keys.
After migration 41, every provider account blocks deleting its host, its team or its project, including an account grouped by relay that no host key names any more: before 0.20 such an account went with its owner. Deleting one of those answers 409 listing the accounts; delete them first (DELETE /api/accounts/by-id/{id}), or list them in the same apply’s prune.
The catalog’s tier policies lose their rate limits with this migration. They get them back, as rateLimits entries scoped to models, when a catalog release with the v1alpha3 schema is seeded.
Migration 37 adds the owner of a user’s own policy binding; deleting the user deletes it. Migration 38 moves every binding subject that names a host key to the key’s account. A 0.11 database has no bindings either one changes.
Migration 39 makes accounts what policies bind to:
- Accounts. It creates a team account per team, a project account per project, a personal account per user and an account per service account, and a member account for each user an enabled role binding names directly at a team or project. They are named as in Accounts Relay makes.
-
Relay keys. Every key gets
accountId: a service account’s key its service account’s account, a user’s key the user’s personal account. -
Binding subjects become accounts. A service account becomes its account. A user named by a binding owned by a project becomes the user’s member account in that project, created when no role binding made one; such an account serves inference tokens only. A binding owned by a team, the system or the user fans a user out to every account of theirs it covered: the user’s member accounts in the team and its projects, the personal account and every member account, or the personal account alone. A team or project subject becomes its account, and a host key’s subject its provider account. Group subjects, and host key subjects whose key has no account, bind nothing and are dropped, each logged:
Bindings lose
projectIdandsubjectsand keep their owner. -
Policy models.
@hostis stripped from every reference inmodelsand in rate-limit links’models, duplicates are removed, and each policy whosemodelschange is logged:000039: policy "ci": models ["openai/gpt-4o-mini@openai", "openai/gpt-4o-mini"] become ["openai/gpt-4o-mini"]. A policy no binding uses that named only hosts is left withmodels: [], which allows every model its pools serve: set its models before you bind it. -
Links. Each budget link becomes a
budgetsentry, and every rate-limit and budget link isshared: true. A host key a policy linked directly becomes a pool of that one key, named after the key and owned like it, linked after the policy’s pools; each is logged as000039: host key "<key>" linked directly by a policy is now pool "<pool>". -
Payload logging. Each account an enabled binding ties to a policy gets
payloadLogging: onwhere the policy hadpayloadLoggingEnabled: true, andoffwhere it did not, so the policy’s decision over the keys’ own flags stands.includeDeprecated,payloadLoggingEnabled,credentialIdsandbudgetIdsare removed from every policy.
-
A bound policy names only hosts in
models(such as["@ollama-self"]). Stripping the hosts would leave it allowing every model.Fix: replace the references with the providers or models the policy allows (ollama,ollama/llama-4), or unbind the policy. -
A bound policy pins a host for a provider served at other hosts (such as
anthropic/claude-haiku-4-5@anthropicwhile the provider’s models are also bound at another host the policy can reach), and does not grant the same reference without the host. Dropping the pin would let it reach that provider elsewhere.Fix: remove the@hostfrom the reference yourself, or the reference, and after the upgrade link the policy only to pools at that host; or unbind the policy before the upgrade and bind it again once its pools are set. -
Two bindings reach one account. For example, a binding owned by the system and the user’s own binding on one user both become the user’s personal account, and a team’s binding on a user and a binding of one of its projects on the same user both reach the user’s member account in that project. A 0.11 database cannot reach this case, since its bindings are owned by projects and name a user once per project.
Fix: remove the subject from all but one of the bindings listed.
Before you upgrade
-
Export the current configuration with the 0.11 CLI, as the reference for the rewrite:
-
Export the links migration 36 drops and the bindings it changes, by name. The query only reads; run it against the 0.11 database and keep the result for Re-creating the dropped links:
With the query saved as
dropped-links.sql:Host keys are the rows of thesecretstable. Forbinding -> groupandbinding -> userrows themodelscolumn holds the policy name and project name; these bindings are not dropped but change: group subjects bind nothing after the upgrade, and a user subject becomes the user’s member account in that project. The migration logs the same dropped links as it drops them. - Back up Postgres. Rolling back needs it; see Rolling back.
-
Find subjects bound twice, which would stop migration 35. Run this against the 0.11 database; it lists what the migration would refuse:
For each row, decide which binding keeps the subject and remove it from the others, on 0.11, before upgrading.
-
Check for policies that migration 36 would refuse. A policy with rows in
policy_modelsand an emptymodelsfield, or any rows inpolicy_host_keys(a 0.11 database has no pools or credentials to keep a restriction), would become unrestricted once those tables are dropped; if it is bound to a non-group subject—disabled bindings count—the migration stops. Run this against the 0.11 database to find them:For each row, the migration stops and reports:migration 000036: bound policies would lose their only model or host-key restriction and widen; …: policy <name> loses its <what> (bound by <bindings>). Addingmodelsor pools before upgrading is not possible—the pools table exists only in 0.20. On the 0.11 database, delete those bindings or remove those policies from them, then upgrade. After upgrading, addmodelsor pools following Re-creating the dropped links. -
Check for policies that migration 39 would refuse: bound policies whose
modelsname only hosts, and pinned references whose provider is served at another host. Run this against the 0.11 database:Fix each row on 0.11 withPUT /api/policies/by-id/{id}, as described for each case under Migration 39, or unbind the policy. Callers stay off the inference port until your new pools are linked (step 1 of Upgrade steps), so dropping a pin before the upgrade does not widen live traffic. -
Write the new manifests in
relay.wyolet.dev/v1alpha3, from the exports; Re-creating the dropped links maps each dropped link to its replacement:- An
Accountfor each provider organization, andaccounton each of its host keys. A key you leave without one stays in the account the upgrade grouped it into. - A
PolicyBindingof a tier preset (or your own tier policy) on each provider account, replacing the host keys’policyId. List a host’s presets withGET /api/hosts/{host}/policies. - A
Poolfor each set of host keys a policy listed, with the oldkeySelectionas itsselection. - Each policy with
pools,rateLimitsand, where you want spend caps,budgets([{ref}]entries).modelsnames providers and models, never hosts. - A
Budgetlinked by the team’s or project’s policy in place of a team or projectbudget. - Policy bindings on the accounts the keys and service accounts pointed at, and on the team’s or project’s account in place of group and
system:authenticatedsubjects. Bindings nameaccounts, with the owner inmetadata.owner. payloadLoggingon the accounts whose traffic you capture, in place of a policy’spayloadLoggingEnabled.- No
priorityon bindings, and nopoliciesordefaultPolicyon hosts.
- An
-
Pick the catalog release. Set
RELAY_CATALOG_VERSIONto the relay-catalog release with the v1alpha3 schema that is published for 0.20. A release in the older schema is refused by 0.20.
Upgrade steps
- Keep callers off the inference port until step 5. Between the migration and your new bindings, accounts reach what their scopes own, which may be more than before.
- Stop every 0.11 pod: scale the deployment to 0, as in Stop-and-start upgrades.
-
Deploy 0.20 with the new
RELAY_CATALOG_VERSION. Migrations 35 to 39 run, and the catalog release is seeded. Keep the log of the pod that ran the migrations, or ofrelay migrate upif you migrate in a step of its own: it holds the000036: dropping linkand000039:lines. A migration that stops applies nothing of itself but leaves its version marked dirty (see Recovering from a dirty schema version):- Migration 35 stopped: run
relay migrate force 34, start the 0.11 image again, fix the bindings, and repeat. - Migration 39 stopped: migrations 35 to 38 are applied, and no image serves that schema. Either run
relay migrate force 38, fix the listed rows in Postgres (for exampleUPDATE policies SET models = '["ollama"]' WHERE name = '<policy>';), and deploy 0.20 again; or restore the backup, fix the rows on 0.11, and repeat the upgrade.
- Migration 35 stopped: run
-
Apply the manifests with the 0.20 CLI, dry run first:
-
Check representative credentials, then let callers back in:
Watch
relay_inference_auth_rejected_totaland therefusalvalues in the usage events for refusals you did not expect. - Re-run token revocations, see The state store. Tell users whose project access comes through a group or a team-level role to mint new inference tokens.
Re-creating the dropped links
Each row of the export, and each000036: dropping link line, has a replacement in 0.20:
The catalog’s tier policies, owned by their host, get their rate limits back when the v1alpha3 catalog release is seeded, so their
policy → rate limit rows need nothing from you, unless you edited those policies.
A host key’s tier
The upgrade put each host key in an account of its owner at its host, named after the host and markedgroupedByRelay. List the accounts with GET /api/accounts?kind=provider and an account’s keys with GET /api/accounts/{ref}/keys. Bind the tier to the account with a binding owned by the system:
429, unlike grouped ones; set orgId on a grouped account to make it cool the same way. See Accounts grouped by the upgrade.
A relay key’s policy
A policy now reaches a relay key through the account the key runs in: its service account’s account, or its user’s personal account. Every key in one account has that account’s policy, and an account has one binding, so keys of one principal that pointed at different policies need either one policy for all of them or an account each, such as a service account per use. For a service account’s key, bind the policy to the service account’s account with a binding owned by the service account’s project. The policy may be owned by that project, its team or the system. Keys created without a user before service accounts existed belong to generated service accounts in thelegacy project, named legacy-<key name>- and the last 8 characters of the key’s id; their accounts are sa- followed by that name:
alice-project-dev for the user alice in the project dev, with a binding owned by the project. It governs the user’s inference tokens for that project and any relay keys created in that account.
The state store
The Helm chart and the Compose stack bundle Dragonfly as the state store in place of Valkey. Upgrading with the chart’s defaults replaces the bundled Valkey with an empty Dragonfly, and the chart README has the values that keep Valkey. An external store run as Dragonfly needs--cluster_mode=emulated --lock_on_hashtags.
An empty store at the cutover means:
- Control-plane sessions end: users sign in again.
- Rate-limit windows and key breaker state start fresh.
- Revoked inference tokens are accepted again until they expire.
{revoked}:tokens key, which 0.11 did not have: tokens revoked one by one under 0.11 are accepted again after the upgrade, whether or not the store changes. Revoke them again with DELETE /api/auth/token/{jti}, or revoke all of a user’s tokens with POST /api/users/by-id/{id}/revoke-tokens, which survives any store change because the user’s token version is kept in Postgres. See Revoking a token.
When a 0.20 deployment later moves to another store, copy the revocation list across before switching. The key expires with the last token on it, so it is absent when no revoked token is still valid, and there is nothing to copy:
Rolling back
relay migrate down 34 first reverses migration 39: bindings get subjects back (the account of a team, project or service account becomes that team, project or service account; a personal or member account becomes its user; a provider account stays an account), policies get payloadLoggingEnabled: true back where every account bound to them had payloadLogging: on, and caller accounts, keys’ accountId and the shared flags are dropped, so links counted per lane come back shared. Pools made from directly linked host keys stay pools, and stripped @host suffixes stay stripped. The older schema holds a user once per owner scope, and several accounts of one user (a personal and a member account, or member accounts one binding reached) map back to that user: the oldest binding keeps the user and the others drop it, each logged as 000039 down: binding "<binding>" no longer binds user <id> in the older schema; binding "<keeper>" keeps the user.
It then recreates the tables and column migration 36 dropped, empty, and deletes what 0.20 added: accounts, pools, budgets, their links, team-, user- and system-owned bindings, and binding subjects of the new kinds. It does not bring back the policy links migration 36 removed, so a 0.11 pod on that schema runs with no policy links at all.
To return to 0.11, stop every pod, restore the Postgres backup taken before the upgrade, and deploy the 0.11 image. See Rolling back for the general procedure.