Model aliases¶
An alias is a model name you choose that resolves to one model, or to several that a routing policy chooses between. Callers send it like any other name and never learn what is behind it.
The reason to want one is that access to a model and access to the name it is
sold under do not have to be the same thing. Without aliases, anyone who can use
gpt-4o-mini can use it. With them, you can say: everyone in the company may
use fast, and only Research may call the model behind it.
Names like fast, default-chat and eu-reasoning also stop being something
every client has to be told about when the model behind them changes. Retargeting
is one edit in the console and takes effect on the next request — there is no
cache to invalidate, and nothing a client has to change.
An alias is a row in the same models table as everything else, which is what
lets all of this work without any of it being special:
- One name namespace. A name is unique across models and aliases alike, and a caller cannot tell the two apart.
- One access mechanism.
is_public, group grants and personal grants (group_model_access,user_model_access) apply to an alias exactly as they apply to a model, with the same console controls. - One catalogue. Aliases appear in
GET /v1/modelsand in a client's model picker with no second source.
What access to an alias does and does not give¶
Access is decided on the name the caller sent, and only on that name. If
fast is granted to a group, that group can use fast — whether or not anyone
ever granted them the model behind it. That is the whole point: an alias is the
grant.
The corollary is worth being explicit about, because it is the part that is easy
to get wrong when you are reading a ledger: billing follows the target. The
usage row records the model that served the request, so prices, quota rules and
reports behave as if the target had been called. A quota rule on
gpt-4o-mini counts every request that arrived as fast, and fast has no
price of its own to differ by.
The ledger keeps both names. Every usage row carries the served model in
model_id and model_name, and the name the caller sent in
requested_model — null for an ordinary request. Reports show a "via alias"
count beside each row, so you can see how much of a model's spend arrived under
a name callers chose. It is a copy of a string rather than a foreign key, so a
retarget or a deletion never rewrites what a past request was.
The card a caller sees¶
An alias appears in GET /v1/models like any other model:
idis the alias's name;display_nameanddescriptionare the alias's own, and null when you did not write them;- the capabilities —
kind, modalities, supported features, context window, output and input limits — are the target's, because the target is what will run the request and a card that under-promises is a request that fails.
Nothing in the card names the target: no upstream name, no provider, no id. Retargeting has to be invisible to clients by design, and the model behind an alias may be one the caller cannot see at all.
The one thing the target's provider decides is the local flag, which says
whether bytes stay on this server. That is a statement about where data goes
rather than an identity, and answering "no" for an alias onto this deployment's
own extractor would tell a client to send a document off-box that never had to
leave.
When an alias cannot serve¶
An alias is unavailable when none of its targets can serve: every target was deleted, is inactive, or sits behind an inactive provider. (An alias with several targets stays available while any one of them can serve; the Routing tab marks the ones that cannot.) A request naming an unavailable alias is refused with:
It is 503 and not 404 on purpose. The caller's access was already decided against the name they sent and it is fine; the alias is reachable and its target is not. Answering 404 would tell a client to give up on a model that is there.
An unavailable alias is also left out of GET /v1/models — a card is a promise
that the name works — and shown as Unavailable with its reason in the
console, where it stays visible so you can fix it.
An alias with one target is never quietly pointed at something else: it fails with the 503 above. Falling back to another model is what you opt into by giving an alias several targets, and the ledger then records which model served each attempt (see below).
Deleting a model does not delete the aliases pointing at it. Each one keeps
its name and its grants and becomes unavailable with the reason
target_deleted; you can then retarget it or remove it.
Routing: several targets per alias¶
An alias may point at several models. Three things decide what happens to a request, in this order.
1. The selection rule¶
Set per alias. It puts the available targets (active, behind an active provider, of the right kind for the route) in the order they will be tried; the first is tried and the rest are the fallback order.
| Rule | Order |
|---|---|
first_available (default) |
The order you listed the targets in. |
weighted |
A random draw in proportion to each target's weight (1 to 1000). The draw also sets the fallback order: the next target is drawn from what remains with the same odds. |
cheapest |
Lowest list price first: input rate × the alias's input price weight plus output rate × its output price weight, per million tokens (1:1 by default, which adds them; 10:1 suits long prompts with short answers), or the per-image or per-page rate for models priced that way. Prices in another currency are converted at the day's rate. A target with no price is tried last, never first, because an unpriced model reserves nothing and bills nothing. Ties keep your order. |
The rules are one interface in gateway/routing.py (SelectionRule): given the
available targets, return them in preference order, possibly fewer. A rule that
asks a decision model, or one that excludes targets by region or retention, is
another implementation of the same thing; stickiness, quota admission and
fallback are applied around any rule and never inside it.
2. Session stickiness¶
A conversation should keep hitting one upstream (its prompt cache lives there). When a request carries a session key, the target that served it is remembered, and later requests with the same key go to that target first for as long as it is still available and admitted.
The session key is the first of these that is present:
- the
x-session-idheader; - the
x-session-affinityheader (opencode 1.19.3 sends this, andX-Session-Idwith its session id, to providers other than opencode's own; verified, so a coding agent on opencode is sticky with no configuration); prompt_cache_keyin the request body;userin the request body.
A request with none of them is not sticky: the rule decides every time. There is no shared bucket for requests without a key.
The memory is a row per (alias, session) in the database (route_sessions),
so it survives a restart and is shared by every gateway process. Details that
matter:
- The key is hashed together with the caller's id before it is stored. The table holds no raw session id, and one person cannot reach another's memory by guessing an id.
- The window slides. Each alias has a stickiness window (default 60 minutes;
0turns stickiness off). A use renews it, written at most once per half window so a busy conversation does not cost a write per request. - A pin never blocks service. If the pinned target is deleted, inactive, not admitted by quota, or fails, the request moves on like any other and the session is re-pinned to the target that serves it. A pinned target that recovers is not returned to; the session stays where it landed until the window ends.
- A pin is made only by an answer that was actually served (status below 400). A request the upstream refuses for being malformed does not pin anything.
- Deleting an alias, or a target model, forgets the sessions pinned to it.
Stickiness applies to aliases with more than one target, even while all but one are down, so a session pinned to B while A was out is still on B when A returns.
3. Fallback¶
If an attempt fails for the target's own reasons, the next target in the order is tried, up to three attempts per request.
| What happened | Falls back? |
|---|---|
| connection refused or reset, DNS failure | yes |
| timeout | yes |
upstream 5xx (including 529 "overloaded") |
yes |
upstream 429 |
yes (the provider is refusing our key or quota, not judging the request) |
upstream 408 |
yes |
upstream 401 or 402 |
yes (the caller already authenticated to the gateway, so this is our credential or balance at that provider; one provider answers "Insufficient Balance." with a 401). Logged at warning level naming the provider |
upstream 403 whose body says auth, billing, quota, balance, credit or an API key |
yes, with the same warning |
| a provider whose credential cannot be read (misconfigured) | yes |
| quota refusal for this attempt | yes (see below) |
any other upstream 4xx (400, a 403 that says nothing about our account, 404, 413, 422 ...) |
no: the answer is returned as is |
| a request blocked by redaction policy | no: it is the same on every target |
| quota state unavailable | no: 503, as for any request |
The reasoning for the 4xx line: those say the request is wrong, and another
target would be asked the same question and either refuse it too (doubling what a
bad request costs) or accept what the first correctly refused. 403 is judged by
its body because a content-policy refusal is the request's and a suspended
account is ours. If the last attempt fails this way, the caller gets that answer
in the same form as for a direct call.
Streaming. The fallback window closes when the upstream's stream has opened with a success status. Once the first byte of a stream is on its way to the client, a failure ends that stream and the client sees it end; the gateway never splices a second model's text onto a half-sent answer. A stream that cannot even be opened (connection error, 5xx, 429) does fall back.
Quota admission is per attempt. Each attempt is admitted on its own and
reserves its own worst case, priced for that target. An attempt that is not
admitted writes no ledger row and moves on to the next target. If no attempt is
admitted, the caller gets the last 429. A failed attempt settles at what it
used, which is nothing, so it hands its reservation back before the next begins.
One thing to know: a requests limit counts attempts, because each attempt is
an upstream call and a ledger row, and counters are rebuilt from the ledger. A
failover therefore uses two of the caller's requests.
Redaction runs per attempt, under the policy of the target being tried, so a target with a stricter rule is never sent text redacted for a laxer one.
If every attempt fails, the caller gets the last attempt's failure, in the same form as for a direct call.
What the ledger and the request log say¶
Billing follows the target that served. A request that fell back leaves one
row per attempt, all under one request id: the failed attempts with status
upstream_error and cost zero against the target that failed, the last against
the target that served. Every row carries the alias in requested_model and its
position in route_attempt (1 is the first target chosen). route_attempt is
null for a direct call and for an alias with one target. The request log shows
it as a "try n" badge and as the attempt column in the CSV.
Where routing applies¶
Selection, stickiness and fallback apply to chat completions, responses, messages, embeddings, decisions and image generation. OCR and search serve an alias from the rule's first choice and do not fall back.
Rules that stay¶
Two rules follow from "targets of one alias must be interchangeable":
- an alias cannot point at another alias (a chain). It is refused when you save it, and again at request time for anything that got in another way;
- all targets of an alias are of one kind. A chat alias stays a chat
alias, because a caller who was promised
/v1/chat/completionsmust not get a 400 naming a model they never asked for. The console offers only targets of the kind of the first one.
A model may appear once in an alias's route. At most 16 targets are accepted.
Quota rules that name an alias are not supported: rules name a scope and a metric, and an alias's usage is the target's, so the target is what you write the rule against.
In the console¶
Routing, next to Models. It lists every alias — available ones and unavailable ones, with the reason — and creates, edits and deletes them. The form takes the name, display name, description and the route: an ordered list of targets (with a weight each under the weighted rule), the selection rule and the stickiness window. It reuses the model page's visibility controls rather than a second set. The list shows each alias's targets in order, with the rule and window, and marks any target that cannot serve right now.
The model screens stay about served models: the Models list does not show aliases, and a model's own page has an "Aliases pointing here" section, so deleting or deactivating it shows which names will stop working before you click.
Admin API¶
All of it reads and writes the session cookie like the rest of /api.
| Method | Path | What |
|---|---|---|
GET |
/api/admin/aliases |
every alias, with its targets, rule, stickiness window, visibility and status |
POST |
/api/admin/aliases |
create one: name, display_name?, description?, either target_model_id or targets ([{model_id, weight?}], in order), route_rule?, route_sticky_ttl_seconds?, is_active?, is_public? |
PATCH |
/api/admin/aliases/{id} |
rename, edit the text, replace the route (targets, or target_model_id for exactly one model), change route_rule or route_sticky_ttl_seconds, or toggle active/public |
DELETE |
/api/admin/aliases/{id} |
remove it, with its grants and its route |
Grants are not set through these routes. An alias row uses the same tables a model does, so the existing model grant endpoints apply unchanged:
| Method | Path |
|---|---|
PUT / DELETE |
/api/admin/groups/{group_id}/models/{model_id} |
PUT / DELETE |
/api/admin/users/{user_id}/models/{model_id} |
model_id there is the alias's id — it is a row of the same table.
Refusals, each with a code an operator can act on:
| Code | Status | When |
|---|---|---|
already_exists |
409 | the name is taken by a model or another alias |
unknown_target |
400 | no model with that id |
alias_target_is_alias |
400 | an alias cannot target an alias |
alias_target_inactive |
400 | the target is deactivated |
alias_target_provider_inactive |
400 | the target's provider is deactivated |
alias_kind_mismatch |
400 | a target of another kind than the alias |
alias_duplicate_target |
400 | the same model listed twice in one route |
unknown_route_rule |
400 | a route_rule the gateway does not have |
Malformed input (no targets, a weight outside 1 to 1000, a stickiness window
outside 0 to 2,592,000 seconds, more than 16 targets, both target_model_id
and targets) is a 400 too. The response's targets lists each target with
position, weight, available and, when not available, unavailable_reason;
target is the first of them, kept for clients written before routing.
The model endpoints (GET/PATCH/DELETE /api/admin/models/{id}, its prices)
refuse an alias id with 400 model_is_alias, and the models listing excludes
aliases. GET /api/admin/models/{id} for a real model gains
aliases_pointing_here, which is empty on the listing and populated on the
detail response.
Every create, retarget and delete is written to the audit trail
(alias.create, alias.update, alias.delete), because a retarget is the one
model-affecting change whose consequences are invisible by design: every client
keeps sending the same name and a different model answers. The route is recorded
as the target names in order (a, b, c), and the rule and window as changed
fields.
Compatibility notes¶
- Cerea needs no change. An alias arrives as an ordinary model card. A
conversation stores the alias name, so retargeting changes what new turns run
on — which is the point. A card with
display_name: nullhas been handled since Cerea 0.15.6. - A card gained a
descriptionfield, which is new in the model card as well as the alias one. OpenAI clients ignore unknown fields. - The migration (
0063) makesmodels.provider_idandmodels.upstream_modelnullable and adds a check constraint holding the two shapes apart. Its downgrade refuses to run while an alias exists, rather than deleting your aliases to make the rollback succeed. - Routing (
0066) addsmodel_route_targets.weight,models.route_ruleandmodels.route_sticky_ttl_seconds, theroute_sessionstable andusage_records.route_attempt. The defaults leave every existing alias behaving as it did. Its downgrade refuses while any alias has more than one target. - The request log's JSON rows gain
route_attemptand its CSV gains a finalattemptcolumn.