[BUG] Many-to-one modelOverrides give a non-Claude session the first key's identity ("Claude Fable 5")

Status Open
Reported on v2.1.287
Maintainer reply None cached
Activity 0 comments · opened Oct 1, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

When several modelOverrides keys map to the same provider model (e.g. the setup for GLM-5.3-Flash[1m]) ID and the session runs on that ID, Claude Code treats the session as the model of the first matching key in the map. It sends that model's identity paragraph in the system prompt, shows that model's name in the header and uses that model's default effort. The model ID sent on the wire is correct throughout.

With {"claude-fable-5": "my-model", "claude-sonnet-5-5": "my-model"}, a session on my-model gets This iteration of Claude is Claude Fable 5, ... in its system prompt, the header reads Fable 5, and requests carry effort: high. Swapping the two keys makes the same session Sonnet 5.5 (effort: medium, header Sonnet 5.5). With no modelOverrides, the header shows my-model and no Claude identity paragraph is sent.

This affects gateway setups that map every Anthropic model ID to one non-Claude model, so that saved /model choices and resumed sessions also go to the gateway. With such a map listing claude-fable-5 first, a GLM model is told in its system prompt that it is Claude Fable 5.

What Should Happen?

A value that several keys map to should not be resolved to one of them based on key order. Either such a value stays unrecognised, as it is with no overrides, or Claude Code warns that the mapping is ambiguous.

The documented display setting ANTHROPIC_DEFAULT_SONNET_MODEL_NAME changes neither the header nor the system prompt here, so no setting currently keeps a Claude identity paragraph away from a non-Claude model in this configuration.

Error Messages/Logs

== no-overrides
"my-model" isn't described by this version's model catalog; update Claude Code, or map it with behavesAs on a modelPicker row (or modelOverrides, if it is a provider id of a model this version knows). Until then auto-compact keeps this session within 200k tokens (the context window it assumes); if the model accepts more, append [1m] to the model name for 1M, or set CLAUDE_CODE_MAX_CONTEXT_TOKENS to its real window; CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT=1 restores the previous wait-for-the-API behavior.
[claude-code:unrecognized_model] {"model":"my-model","query_source":"sdk"}
my-model high (no identity paragraph)
my-model high (no identity paragraph)
API Error: 400 stub
== fable-first
my-model high This iteration of Claude is Claude Fable 5
my-model high This iteration of Claude is Claude Fable 5
API Error: 400 stub
== sonnet-first
my-model medium (no identity paragraph)
my-model medium (no identity paragraph)
API Error: 400 stub

Steps to Reproduce

Paste the block below into a terminal (bash or zsh) on a machine with claude installed. It needs no account: it writes a local stub server that prints the model, effort and identity paragraph of each request and answers 400, then runs claude -p once per settings file, each with a fresh CLAUDE_CONFIG_DIR.

(
cd "$(mktemp -d)"
cat > stub.py <<'EOF'
import http.server, json, re
class H(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        j = json.loads(self.rfile.read(int(self.headers['content-length'])))
        s = j.get('system', '')
        s = ' '.join(b['text'] for b in s) if isinstance(s, list) else s
        idm = re.search(r'This iteration of Claude is [^,.]*', s)
        print(j['model'], (j.get('output_config') or {}).get('effort'), idm.group(0) if idm else '(no identity paragraph)', flush=True)
        body = b'{"type":"error","error":{"type":"invalid_request_error","message":"stub"}}'
        self.send_response(400); self.send_header('content-length', str(len(body))); self.end_headers(); self.wfile.write(body)
    def log_message(self, *a): pass
http.server.HTTPServer(('127.0.0.1', 8899), H).serve_forever()
EOF
cat > no-overrides.json <<'EOF'
{}
EOF
cat > fable-first.json <<'EOF'
{"modelOverrides": {"claude-fable-5": "my-model", "claude-sonnet-5-5": "my-model"}}
EOF
cat > sonnet-first.json <<'EOF'
{"modelOverrides": {"claude-sonnet-5-5": "my-model", "claude-fable-5": "my-model"}}
EOF
python3 stub.py & stub=$!
trap 'kill $stub' EXIT
sleep 1
for s in no-overrides fable-first sonnet-first; do
  echo "== $s"
  ANTHROPIC_BASE_URL=http://127.0.0.1:8899 ANTHROPIC_AUTH_TOKEN=dummy CLAUDE_CONFIG_DIR="$(mktemp -d)" \
    claude -p hi --model my-model --settings "$s.json" < /dev/null
done
)

| settings | stub prints (model, effort, identity) | interactive header |
|---|---|---|
| no-overrides.json | my-model high (no identity paragraph) | my-model |
| fable-first.json | my-model high This iteration of Claude is Claude Fable 5 | Fable 5 |
| sonnet-first.json | my-model medium (no identity paragraph) | Sonnet 5.5 |

Each stub line appears twice because -p sends two requests. The header column comes from an interactive claude --model my-model --settings <file> with the same environment.

Removing the Fable key ({"modelOverrides": {"claude-sonnet-5-5": "my-model"}} alone) gives the sonnet-first.json row: the next key is used, so the session is still identified as a Claude model.

Claude Model

Other

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.287 (Claude Code)

Platform

Other

Operating System

Ubuntu/Debian Linux on arm64 (Docker on macOS)

Terminal/Shell

Devcontainer bash

Additional Information

Claude Model is "Other" because the session runs a non-Claude model behind an Anthropic-compatible gateway (ANTHROPIC_BASE_URL); the repro replaces the gateway with a local stub. Platform is the same gateway. OS: Ubuntu in a linux/arm64 Docker container on macOS (Apple Silicon). The repro runs in -p mode; the header column was read from an interactive session in a pseudo-terminal.

The model configuration docs (Customize pinned model display) say a pinned ID is recognised when a modelOverrides entry maps to it. They do not say that recognition also sets the system-prompt identity and the default effort, or which key is used when several keys map to the same value.

<details>
<summary>Other entry points, other keys, exact matching, versions checked</summary>

  • Entry points. The same identification happens when the session's model ID equals an override value through ANTHROPIC_DEFAULT_SONNET_MODEL=my-model with --model sonnet, through ANTHROPIC_MODEL=my-model, or through "model": "my-model" in settings. Selecting an Anthropic ID explicitly (--model claude-sonnet-5-5 with fable-first.json) gives the Sonnet identity, as expected.
  • Other keys. A single-key map shows each key's effect: claude-fable-5 → Fable identity paragraph, effort: high; claude-fable-5-1 → the same paragraph naming "Claude Fable 5"; claude-sonnet-5-5 or claude-opus-5-5 → no identity paragraph, effort: medium; claude-haiku-4-5 → no identity paragraph and no effort field in the request. With claude-haiku-4-5 first and claude-fable-5 second, the request has no effort field.
  • Display name. ANTHROPIC_DEFAULT_SONNET_MODEL_NAME=GLM (with or without ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES=effort) leaves the Fable 5 header, the identity paragraph and effort: high unchanged.
  • Exact string match, [1m] included. Running my-model[1m] with overrides mapping to my-model, or running my-model with overrides mapping to my-model[1m], does not trigger recognition. Pinning the session model with [1m] and leaving it off the override values avoids the misidentification. The request's model field is the same either way, but [1m] still adds the context-1m-2025-08-07 beta header and raises the context budget Claude Code assumes, so this workaround has side effects.
  • Versions. 2.1.200, 2.1.250 and 2.1.287 behave the same: the first matching key decides. 2.1.280 identifies sonnet-first.json as Fable as well; that version does not recognise claude-sonnet-5-5 on its own, so the Fable key is the first one it knows. Versions before 2.1.200 were not checked.

</details>

View original on GitHub ↗