> Raw Markdown twin (generated at build time from the source Markdown). Rendered page: https://docs.gatellm.io/en/howto/configure-identity-model-mapping · Doc index: https://docs.gatellm.io/en/llms.txt


# Configure identity model mapping

When you want **different clients requesting the same client-side model name to be routed to different upstreams**, use identity-scoped model mapping: declare `from → to` rules on an access key or key group, and the gateway automatically rewrites the model name after the ingress gate and before routing, with the egress gate then validating the target. For example, `team-a`'s `claude-opus-5` actually goes to Tongyi Qianwen's `qwen3.7-max`, while clients without that rule go through the original route as usual. Rules can also route by request header: a `claude-opus-5` carrying `X-Tenant: vip` goes to the premium line, and one without it goes to the normal line.

Entry point: Console → **Access Keys** (Access Keys / Key Groups tabs, only admin can edit mappings).

> **Ingress gate / egress gate** (recurring below): two allowlist validations. The **ingress gate** checks whether "the model name the client requested" is in that identity's `models` / `load_balancers` allowlist, and rejects if not; the **egress gate** checks whether "the model to actually route to" (including the mapping-rewritten target) is in the corresponding allowlist. A request is let through only if both gates pass. The mapping rule on this page does not itself grant permission; the target must still pass the egress gate.

## How it differs from switch-route / script useModel

| Mechanism | Scope | Configuration | Trigger | Target passes access control? |
|------|---------|---------|---------|----------------|
| `switch-route` | Global | Declarative | Request body content | No (admin reroute) |
| Script `context.useModel()` | Global | Programmatic | Script logic | No (admin reroute) |
| **Identity model mapping** | **Per key / group** | **Declarative** | **Request identity + request header** | **Yes (egress gate)** |

All three are "model-name swaps", but only identity mapping takes effect per tenant and **its target must be in that client's permission allowlist** — configuring a mapping rule is not the same as granting permission. The targets of `switch-route` / `useModel` are admin-forced reroutes, not subject to client permission (and their signatures don't take identity context).

## Configuration steps

1. Open the **Access Keys** page and edit the target access key or access key group.
2. At the bottom of the form find the **Model Mappings** area and click **Add rule**.
3. Each row selects two fields: **Source model** (`from`, the name the client requests) and **Target model** (`to`, the name actually routed). The `to` dropdown only lists names that this identity's egress gate is authorized to reach; `from` is an input-with-suggestions dropdown — models / load balancers / aliases that actually exist in the gateway can be picked from the dropdown, while fictional access names (access labels that don't exist in the gateway but that clients habitually send) are typed in. A group-level `from` you type is automatically merged into that group's `models` list on save (so the ingress gate allows it); a key-level `from` not in any of that key's groups' lists shows an inline warning "the ingress gate will reject". A rule that can be configured in the UI will always pass the egress gate at runtime. You can add multiple rows, and remove one by clicking the delete icon at the row's end.
4. (Optional) Click **Add header condition** to give a rule a `when`: **header name** can be input or picked from a dropdown, **operator** picked from a dropdown, and depending on the operator decide whether to fill the **condition value** (input box). See "Header conditions" below.
5. Save. Rules hot-reload and take effect immediately, no restart needed.

> Saving without touching the mapping area equals "keep existing rules"; only after you add/remove/modify within that area does it write the current list back (clearing all rows = explicitly clear the mapping). A new identity (not yet saved) has an empty dropdown — save the identity and its model / load balancer allowlist first, then come back to configure mapping, and the dropdown will populate.

## Wildcards

`from` and `to` each have at most one `*`. `from`'s `*` captures a middle segment, and `to`'s `*` fills it back in:

- `claude-opus-5 → qwen3.7-max`: exact match, whole-name replacement.
- `claude-* → qwen-*`: `claude-opus-5` becomes `qwen-opus-5`, `claude-sonnet-4` becomes `qwen-sonnet-4`.
- When `to` contains `*`, `from` must contain `*`; neither `from` nor `to` can have two `*`. Otherwise saving reports 422.

> `from` accepts any input (a specific name, a wildcard pattern like `claude-*`, a fictional access name); `to` can only be picked from the dropdown (a specific name this identity's egress gate is authorized to reach, no wildcard). If you need a `to` with a wildcard, configure it directly via config file / API — it takes effect and displays normally, but cannot be created in the console's `to` dropdown.

> **Two wildcard dialects (mind the difference)**:
>
> | Where written | Meaning of `*` | Example |
> |--------|-----------|------|
> | Mapping rule `from` | **Middle-segment capture** | `claude-*-5` matches `claude-opus-5` (captures `opus`) |
> | Key group allowlist `models` / `load_balancers` | Exact name, `"*"` select-all, **trailing-`*` prefix match**; middle `*` unsupported | `claude-*` matches everything starting with `claude-`; `claude-*-5` is only treated literally |
>
> So for a `from = "claude-*-5"` rule to pass the ingress gate, the allowlist must contain the **specific name** (e.g. `claude-opus-5`) or a trailing wildcard (`claude-*`). Writing `claude-*-5` in the allowlist is treated literally and only exactly matches that string. This is fail-closed: a `from` name that can't be written into the allowlist 403s at the ingress gate.

## Header conditions (route by request header)

Add a `when` to a rule to make the same `from` go to different targets by request header. Pick one of five operators:

| Operator | Semantics | Fill condition value? |
|--------|------|------------|
| `exact` | Header value exactly equals | Yes |
| `prefix` | Header value starts with this prefix | Yes |
| `regex` | Header value matches the regex | Yes |
| `exists` | The header exists (value ignored) | No |
| `absent` | The header doesn't exist | No |

Header names are case-insensitive, condition values are case-sensitive. When the condition isn't satisfied, **skip this rule** and keep looking for the next `from`-matching rule in the same bucket — so put **conditional rules first and unconditional rules as fallback**:

```text
1. claude-opus-5 → glm-4.6        when X-Tenant exact vip      # conditional specific rule first
2. claude-opus-5 → qwen3.7-max                                 # unconditional fallback
```

A request carrying `X-Tenant: vip` hits rule 1 → `glm-4.6`; one without it skips rule 1 and hits rule 2 → `qwen3.7-max`.

> The header name is an **input-with-suggestions dropdown**: built-in common headers ∪ headers used by existing request-header access rules ∪ headers this identity has used, as candidate suggestions, and you can also directly type any header name not in the candidates. At runtime there is **no allowlist** on header names — when a header referenced by a rule is missing in the request, that rule is **skipped** (no 403), continuing to the next rule in the same bucket.

> `when` targets the request headers carried **at the moment the request is made**. A few reverse-inference landings (DashScope async task status query `GET /v1/services/{model}/tasks/{task_id}`) cannot obtain the original request headers at the mapping point and pass an empty query — in that case only **unconditional rules** match, and conditional rules are skipped. This is fail-closed: if the only matching rule carries a `when` that isn't satisfied, the model name isn't rewritten and is resolved by the original name (possibly 404). The egress gate runs as usual.

## Priority

Key-level rules > group-level rules (**in the order of that access key's groups list**) > passthrough. The access key's own rules are looked at first; if none match, each of its groups' rules are tried in order; if none match, the model name is unchanged. Within a bucket, **order wins**: the first `from`-matching rule whose header condition is satisfied wins (first-match-wins), so conditional rules must come before unconditional rules.

**The order between groups = the order of that access key's groups list, and this order is constant between two saves.** When multiple groups configure a mapping for the same source name (`from`), the group earlier in the list wins — the gateway does not merge all groups' rules into one big table, but tries group by group, returning on the first group that produces a match, and doesn't look at later groups at all. Because it is "group-by-group serial, first-matching group returns", **group order beats within-bucket order**: e.g. group A's rules are `[m→X (vip only), m→Y (unconditional)]` and group B's is `[m→Z (unconditional)]`; when the request carries no vip, the result is **Y**, not Z (A skips its first rule, hits its second, the whole group returns, and B never gets a turn).

> This order is deterministic when the configuration is unchanged (every request hits the same group). But currently there is **no** conflict validation or warning for "multiple groups (or key vs group) configured rules for the same `from`" — who takes effect is entirely determined by the implicit convention of the groups-list order. When you need determinism, the safest approach is to configure a rule for the same `from` in only one place (or directly on the access key — key-level always comes first and is clearest); if it must be spread across multiple groups, put the group that should win earlier in that key's groups list.

## Three operational contracts you must know

1. **The mapping target must be in the allowlist, and in the list of the correct kind.** The egress gate determines the kind of the real target name inside the mapping primitive: a target that is an ordinary model is checked against the group's `models`; a target that is a load balancer is checked against the group's `load_balancers`. A target not in the corresponding list → 403. So when configuring `claude-opus-5 → my-lb` (where `my-lb` is a load balancer), write `my-lb` into the group's `load_balancers`.

2. **The source name (`from`) must be in the allowlist.** The ingress gate checks, for names that exist in the routing table, the corresponding list by kind (`load_balancers = ["*"]` only allows load-balancer names, not ordinary model names); for fictional client names (access labels that don't exist in the gateway) it takes the union of the two lists as fallback, so a fictional name can be written into either `models` or `load_balancers`. When configuring a mapping rule at the **group level**, a fictional `from` you type is **automatically merged into that group's `models` list** on save (so the ingress gate allows it); when configuring at the **key level**, the key has no list of its own, so a fictional `from` must be manually written into one of that key's groups' `models` or `load_balancers` (either list works), and the console warns when `from` is not in any owning group's list. This is an explicit authorization action, not a bypass.

3. **The `/v1/models` list does not reflect the mapping.** The list only lists models that actually exist in the configuration; a mapping's source name (e.g. `claude-opus-5`, `gpt-4o`) is usually not among them, so you won't see it in the dropdown — but a client directly requesting that name succeeds. This is expected behavior.

## Which name do statistics and billing see?

**The final, post-mapping model name.** The mapping is fully transparent to statistics and billing — the logs, statistics page, and bill record the target model (e.g. `qwen3.7-max`), and a source-name row never appears. If you need per-client-source-name attribution, record it on the client side yourself.

## How to confirm the mapping took effect in logs

Look at the **`MM`** tag in the logs page's model column: a request that hit a mapping rule gets `MM` before its model name, and hovering shows `original → mapped` (e.g. `gpt-4o → glm-4.6`), with the same mapping-source entry visible in the log details page's Overview area. No need to dig through the request body — the tag is the proof. When the mapping target is a load-balancer name, the same row carries both the `MM` and `LB` tags; when also rerouted by a script's `useModel`, both `MM` and `SW` appear.

## Troubleshooting

- **Save reports 422 "invalid model mapping rule"**: check whether the row's from/to is empty, whether `from` has more than one `*`, whether `to` has `*` while `from` doesn't; if it carries a `when`, check whether the header name is empty, whether `exact`/`prefix`/`regex` filled a value, whether `exists`/`absent` wrongly filled a value, and whether the `regex` compiles.
- **Request 403**: either the source name (`from`) isn't in any of that identity's lists (ingress gate rejects), or the target (`to`) isn't in the list of the corresponding kind (egress gate rejects). The 403 error body writes the **client's original name**.
- **Request 404 but the server warned the real target name**: the mapping target model / load balancer doesn't exist or is disabled; the message returned to the client is redacted back to the source name.
- **Header condition didn't route**: confirm the conditional rule is ordered **before** the unconditional rule; confirm the client actually carried that header with a matching value.
- **Mapping didn't take effect**: confirm the rule is configured on the correct identity (key-level vs group-level) and that the client actually authenticated as that identity; being overridden by a higher-priority rule also makes it "look like it didn't take effect".

**Next**: [Access key & key group fields](/en/reference/access-keys-groups-fields.md) for the `model_mappings` field definition and the allowlist lists; [Access control design](/en/practices/access-control.md) for the overall allowlist access-control approach; [Logs viewer](/en/console/logs-viewer.md) for how to read the `MM` / `SW` / `LB` tags.
