Skip to content

fix: apply and display mid-session model changes (#981) - #1047

Open
MacLeod92 wants to merge 7 commits into
siteboon:mainfrom
MacLeod92:fix/model-select-resolution
Open

fix: apply and display mid-session model changes (#981)#1047
MacLeod92 wants to merge 7 commits into
siteboon:mainfrom
MacLeod92:fix/model-select-resolution

Conversation

@MacLeod92

@MacLeod92 MacLeod92 commented Jul 20, 2026

Copy link
Copy Markdown

Summary

Fixes #981. Changing the model mid-session from the /models modal showed "applies next response," but the model never actually changed, and reopening the modal showed the old model.

This PR is deliberately scoped to that issue. It fixes the runtime bug, fixes the display bug that made it hard to diagnose, and closes the persistence regression that fixing the runtime bug would otherwise introduce.

Reproducing

  1. Start a chat on sonnet and send a message.
  2. Run /models and change to haiku.
  3. Send another message, expecting it to run on Haiku.

It doesn't change. Run /models again and it still reports the old model; the transcript for the new turn also still records Sonnet. Same failure as #981's repro, without needing an account that lacks Opus access.

Root cause

Two bugs, the second of which made the first hard to diagnose:

  1. Runtime: the change never applied. resolveResumeModel looked up the stored per-session override keyed by the provider-native session id, but overrides are written keyed by the app session id. The lookup never matched, so the override was ignored on every subsequent turn and the session carried on with its original model.

  2. Display: the modal showed the wrong model. getCurrentActiveModel matched transcript events against the app session id, but the JSONL records the provider-native id. For sessions created through the app the two ids differ, so every event was rejected and the lookup fell back to default. For sessions imported from the CLI the two ids coincide (the sessions table keys both columns on the provider-native id), so matching succeeded, but the transcript stores the raw model id (claude-opus-4-8), which was never mapped back to the catalog alias (opus) the modal highlights, so nothing was highlighted. Both populations saw a wrong display, through two different halves of the same resolver.

Changes

Backend: make the change take effect and read the real model back

  • resolveResumeModel resolves the override with the app session id, falling back to the provider-native id. The fallback is centralized inside resolveResumeModel, so the four provider runtimes (claude-sdk, cursor-cli, openai-codex, opencode-cli) just pass both ids through instead of each repeating it.
  • getCurrentActiveModel matches transcript events by provider-native session id and normalizes raw JSONL model ids to catalog aliases before returning them, so every reader that goes through it (/models, /cost, /status) is correct once a turn has run.
  • Family-alias normalization is derived from the catalog itself rather than a hard-coded list: every plain-word option value in CLAUDE_FALLBACK_MODELS.OPTIONS other than the default sentinel is a family alias, so a newly released model is recognized as soon as it has a catalog entry. Matching accepts the alias as a whole word anywhere in the id, so Bedrock-style ids (us.anthropic.claude-haiku-4-5-...-v1:0) also resolve.
  • The stale sonnet[1m] and opus[1m] catalog entries are removed as part of this fix, not as a drive-by cleanup. The display fix maps every raw transcript id to its bare family alias, so decorated variants would need dedicated resolution code to ever be highlighted, and any such code would be wrong: on Sonnet 5 and Opus 4.8 the 1M context window is standard, not a separate beta-gated mode, and the [1m] suffix is unhandled everywhere in the codebase, so those cards duplicated the entries above them and on main legitimately show models that do not exist as distinct choices. Their removal also fixes the stale "Sonnet 4.6" descriptions (the label symptom reported in Model picker never shows new Claude models — getSupportedModels() is disabled, returns a hardcoded stale list #1043; the dynamic-discovery request in that issue is out of scope here).

Frontend: apply the pick locally without introducing a persistence regression

  • Apply the session-scoped pick to React state so the composer reflects it immediately, and invert the reconcile effect to prefer the current in-memory model over the stored default. Without the inversion the fresh pick is reverted on the next reconcile pass. The four per-provider reconcile effects are consolidated into one storage-key-keyed loop at the same time.
  • Those two changes together create a hazard that does not exist on main: with the pick now held in memory and preferred over storage, the reconcile effect would persist it into the provider's localStorage default key, seeding every future new chat. A sessionScopedModelRef records the session-scoped value and suppresses the write until it genuinely diverges (a real default change, or the model falling out of the catalog). The per-provider reconcile body is extracted into a hook-free reconcileProviderModel module so this is unit-testable.

Behaviour, per the design in #762

The session-override design landed in #762 (commit 9aa92700, "feat: support session-scoped model overrides"), and its commit message states the two principles this PR follows:

"Active model" means the model the session is currently running on. That commit says the modal "shows when a selected model will apply on the next response": a pending pick is displayed as pending, not reported as active. Accordingly, after a mid-session pick, /models and /status keep reporting the model the session is actually on until a turn has run on the new one, and the modal marks the pick beneath the current model, e.g.:

Active model · Claude
opus
→ sonnet next

One consequence: that marker is currently modal-local, so reopening the modal before the next message shows the running model with no pending indicator. Persisting the marker across modal opens is a worthwhile follow-up, but it is UI work beyond #981. Unlike the original bug, the next message genuinely runs on the picked model.

A session pick must not become the provider default. The same commit describes the prior behaviour, "model selection was acting like a provider-level preference," as the bug it fixed, moving session choices into app-owned state precisely so they stop mutating the default. This PR keeps that boundary: a new chat's selection prefills from React state with the model just used, but the persisted provider default is never written and a reload returns to it.

Known limitation, pre-existing on main: interrupted or errored turns record placeholder model values like <synthetic> in the transcript, and the backwards scan stops at the first model it finds, so a trailing placeholder masks the session's real model. On main it surfaces as the raw sentinel string; here the normalization renders it as default. It is a distinct pre-existing defect, left out of scope (#998 fixes it by skipping placeholders).

Tests

Of the 520 additions in this diff, 326 are two new test files (165 and 161 lines); the code change itself is about 194 additions and 109 deletions across the other 12 files.

Under the repo's existing node:test convention (no new test framework):

  • claude-models.provider.test.ts: active-model resolution against the provider-native id, raw-id to catalog-alias normalization, Bedrock-style ids, and unrecognized-id fallback.
  • useChatProviderState.test.ts: the session-scoped model being applied but not leaked to the provider default across reconcile re-runs, and persisting correctly on genuine divergence, across all four providers.

npm run build, npm run typecheck, and npm run lint pass. This PR also adds an npm test script, which main lacked: test:client covers src/, and test:server runs the server suites under server/tsconfig.json (they need it for the @/ path aliases), mirroring the existing build:client and build:server naming. Both are node:test unit suites. All 19 client and 123 server tests pass.

Relationship to #996 and #998

All three PRs target #981; this one was developed independently and converges with the others where there is one correct answer.

Credit to #996 (@Kennems) for identifying root cause (1), the app-session-id vs provider-native-id override mismatch; their work is what pointed me at it. This PR fixes the same mismatch, centralized inside resolveResumeModel rather than repeated per runtime. Where #996 stops short is the display path: it surfaces the pending override for /models, /cost and /status but does not touch getCurrentActiveModel itself, so app-created sessions whose model was not set through the UI still resolve to default, and raw transcript ids are never normalized to catalog aliases, so CLI-started sessions report an id no card can highlight. It also does not apply the pick to frontend state, so the composer does not reflect it. #996 additionally strips ANTHROPIC_MODEL and ANTHROPIC_DEFAULT_*_MODEL from the SDK subprocess env, a genuinely useful fix, but a distinct root cause affecting hosts that set those variables, deliberately not duplicated here.

Credit to #998 (@Sunjaieks) for the catalog-derived family matcher concept, which this PR adopts with an independent implementation (whole-word matching anywhere in the id, covering Bedrock-style ids that a claude-<alias> prefix match misses). #998 has the most complete backend of the three: provider-native id matching, normalization, pending-override surfacing, and <synthetic> handling. The differences are scope and the frontend. It bundles two unrelated fixes (env-var stripping and override-cache cleanup on delete; both reasonable, neither #981). On display semantics, it reports a pending pick as active before any turn has used it, which #762's design explicitly distinguishes from the active model. On the frontend, it routes a mid-session pick through setStoredProviderModel, so picking a model for one session rewrites the user's persisted provider default; that reintroduces exactly the behaviour #762 describes as the bug it fixed ("model selection was acting like a provider-level preference"). It has no frontend tests and does not address the reconcile-revert problem, which only stays hidden there because the pick overwrites the stored default it would otherwise be reverted to.

Why this PR: it is the only one of the three that fixes the display for both session populations, reflects the pick in the composer, and preserves the boundary #762 drew between a session pick and the user's default, while staying scoped to #981. The pieces of #996 and #998 that are genuinely valuable and out of scope here (env stripping, cache cleanup on delete, placeholder skipping, persisting the pending marker) would make good follow-ups regardless of which PR lands.

Summary by CodeRabbit

  • New Features

    • Improved resume model selection across providers by using app session identifiers for stored overrides.
    • Centralized chat model “reconciliation” so session-scoped model changes don’t unintentionally persist as provider defaults.
    • Enhanced Claude model handling by mapping raw transcript model IDs to available model options (with safe fallbacks).
  • Bug Fixes

    • Fixed incorrect active-model resolution when application and provider session identifiers differ.
    • Refreshed Claude model option descriptions and API documentation (removed legacy *[1m] variants).
  • Tests

    • Added/updated automated coverage for model reconciliation and resume behavior.
    • Added npm scripts to run client and server tests.

Resolve the Claude active model against the provider-native session id
(not the internal session id), and normalize raw JSONL model ids to
catalog aliases before returning them.
…viderModel

Consolidate the four per-provider reconcile effects into one loop keyed
by a storage-key lookup table, and make it prefer the current in-memory
model over the stored default (previously reversed, so a freshly-applied
session-scoped model got immediately overwritten by the last persisted
default). Track session-scoped changes via setProviderModelState so
selectProviderModel can apply the resolved model to local state right
away instead of waiting on a future reconcile.
…vider-native id

resolveResumeModel looked up a session's stored model override keyed by
the provider-native session id, but overrides are written keyed by the
app-facing session id — so a picked model would only actually apply
because the client also resends it on every message, not because the
override lookup succeeded. A reconnect or a second client on the same
session would silently miss it.

Credit to PR 996 (Kennems) for identifying this mismatch. This applies
the same fix but centralizes the appSessionId-or-sessionId fallback
inside resolveResumeModel itself, so the four provider runtimes
(claude-sdk, cursor-cli, openai-codex, opencode-cli) just pass both ids
through instead of each repeating the fallback logic.
1M context is now default/automatic for Sonnet 5 and Opus 4.8, not a
separate beta-gated mode, so sonnet[1m]/opus[1m] no longer represent a
real choice and were never translated into a valid SDK model id when
selected. Also updates the stale "Sonnet 4.6" wording now that the
sonnet alias resolves to Sonnet 5.
setProviderModelState (added for mid-session model switching) claimed to
apply the resolved model "in memory only," but the reconcile effect's
pickStoredOrCurrent change (preferring `current` over `stored`) caused
the very next reconcile pass to persist that session-scoped value into
the provider's localStorage default key — seeding it into future new
chats and contradicting PR 762's intent that model selection not act
as a provider-level preference.

sessionScopedModelRef tracks which model was applied session-scoped per
provider. The reconcile effect skips persisting to localStorage while
the current value still matches the recorded session-scoped value, and
only clears the flag (resuming normal persistence) once the value
genuinely diverges — e.g. a real default change via the pre-session
picker, or the model falling out of the catalog on refresh. This is
sticky until divergence, not just for the single pass immediately after
the switch — an initial delete-on-match version was found to reopen the
leak on the very next unrelated reconcile trigger.

The reconcile effect's per-provider body (the pickStoredOrCurrent
resolution plus the ref-check/clear and localStorage persist) is
extracted into a standalone, hook-free reconcileProviderModel module.
It takes a minimal storage interface (localStorage in the app, an
in-memory stand-in in tests), so the sticky-until-divergence logic can
be unit-tested with the repo's existing node:test convention instead of
introducing a jsdom/@testing-library DOM-rendering test framework. The
effect's timing, dependency array, and per-provider loop are otherwise
unchanged; it still owns the React state setter and passes the live
sessionScopedModelRef.current in by reference.

Includes a parametrized regression test (all four providers) driving
reconcileProviderModel directly: the initial leak, the delayed-
reoccurrence case (repeated calls with the session-scoped ref still
set, verifying suppression is sticky rather than one-shot), and correct
persistence on genuine divergence (real default change and model
falling out of the catalog). Note this exercises sticky-until-divergence
at the pure-function level, not by re-firing the effect through a
rendered multi-provider React tree.

Does not affect /models Active-model, /status, or /cost, which remain
driven by the backend JSONL/provider-native read and correctly wait
for a turn to complete.
…ire up npm test

matchClaudeModelOptionFromRawId kept its own alias/regex list alongside
the catalog, so a new Claude model would need recording twice: a catalog
entry to appear in /models, and a pattern entry to be recognized in raw
transcript ids. Left to drift, a catalog-only model would list and
select fine but resolve to 'default' on display.

Family aliases are now derived from CLAUDE_FALLBACK_MODELS.OPTIONS
itself (every plain-word value other than the 'default' sentinel), so a
new model matches as soon as it has a catalog entry, the same source
of truth /models is populated from. Concept credit to PR 998
(Sunjaieks); implemented independently: matching is on the alias as a
whole word anywhere in the id rather than only as a 'claude-<alias>'
prefix, so Bedrock-style ids (us.anthropic.claude-haiku-...-v1:0) still
resolve, with the longest alias winning on multiple hits.

Adds a regression test for the Bedrock-style id; behaviour is otherwise
unchanged and pinned by the existing alias-mapping and fallback tests.

Also adds an npm test script, which main lacked. 'test' runs both
suites; 'test:client' and 'test:server' split them by tsconfig (the
server tests need server/tsconfig.json for the @/ path aliases),
mirroring the existing build:client/build:server naming. Both are
node:test unit suites, the only kind of test in the repo. Without a
runner the new server-side provider tests here could not be exercised
by npm.
@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 39a98e50-0e84-43ac-81bd-e884c956346a

📥 Commits

Reviewing files that changed from the base of the PR and between d99126f and b25c198.

📒 Files selected for processing (1)
  • server/modules/providers/list/claude/claude-models.provider.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/modules/providers/list/claude/claude-models.provider.ts

📝 Walkthrough

Walkthrough

The PR adds session-aware resume model resolution, maps Claude transcript identifiers to catalog options, separates session-scoped client selections from persisted defaults, adds regression coverage, and introduces npm commands for client and server tests.

Changes

Model resolution and persistence

Layer / File(s) Summary
Client model reconciliation
src/components/chat/hooks/reconcileProviderModel.ts, src/components/chat/hooks/useChatProviderState.ts, src/components/chat/hooks/__tests__/*
Provider models now reconcile through shared helpers, preserve session-scoped selections separately from persisted defaults, and clear stale overrides when catalogs change.
Claude catalog and transcript mapping
server/modules/providers/list/claude/claude-models.provider.ts, server/modules/providers/tests/claude-models.provider.test.ts, server/routes/agent.js
Claude catalog descriptions and options were updated; raw transcript model IDs now map to catalog aliases, use provider-native sessions, and fall back to the catalog default when unmatched.
Session-aware resume flow
server/modules/providers/services/provider-models.service.ts, server/*-cli.js, server/openai-codex.js, server/claude-sdk.js, server/modules/websocket/services/chat-websocket.service.ts, server/modules/providers/tests/provider-models.service.test.ts
Resume resolution now accepts app-facing and provider-native session IDs, with provider runtimes receiving the updated shape.
Test runner commands
package.json
Npm scripts now run client and server test suites through tsx.

Sequence Diagram(s)

sequenceDiagram
  participant ChatWebSocket
  participant ProviderRuntime
  participant ProviderModelsService
  participant ClaudeModelsProvider
  ChatWebSocket->>ProviderRuntime: Pass sessionId and appSessionId
  ProviderRuntime->>ProviderModelsService: Resolve resume model
  ProviderModelsService->>ClaudeModelsProvider: Read changed active model
  ClaudeModelsProvider-->>ProviderModelsService: Return catalog model
  ProviderModelsService-->>ProviderRuntime: Return resolved model
Loading

Possibly related PRs

Suggested reviewers: viper151

Poem

I’m a rabbit with models tucked neat in my hat,
Session paths mapped where the transcripts sat.
Defaults stay steady, overrides hop free,
Claude aliases bloom like carrots to see.
Tests now spring forth with a cheerful “run!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: fixing mid-session model changes so they apply and display correctly.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/chat/hooks/useChatProviderState.ts`:
- Around line 297-302: Reset or rehydrate sessionScopedModelRef whenever
selectedSession?.id changes, so it reflects the newly active session’s provider
models rather than retaining values from the previous session. Update the
session-change handling alongside the reconciliation logic near
sessionScopedModelRef and preserve suppression of persistence only for models
belonging to the current session.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9bacfa3c-9f50-4481-b9f0-c3ae051c595c

📥 Commits

Reviewing files that changed from the base of the PR and between 27eaf01 and d99126f.

📒 Files selected for processing (14)
  • package.json
  • server/claude-sdk.js
  • server/cursor-cli.js
  • server/modules/providers/list/claude/claude-models.provider.ts
  • server/modules/providers/services/provider-models.service.ts
  • server/modules/providers/tests/claude-models.provider.test.ts
  • server/modules/providers/tests/provider-models.service.test.ts
  • server/modules/websocket/services/chat-websocket.service.ts
  • server/openai-codex.js
  • server/opencode-cli.js
  • server/routes/agent.js
  • src/components/chat/hooks/__tests__/useChatProviderState.test.ts
  • src/components/chat/hooks/reconcileProviderModel.ts
  • src/components/chat/hooks/useChatProviderState.ts

Comment thread src/components/chat/hooks/useChatProviderState.ts
@nico-stever

Copy link
Copy Markdown

I hit this same bug and arrived at the same root cause independently, so first: confirming your diagnosis. The pin is written under the app-facing session id and read with the provider-native one, and it only appears to work for sessions discovered on disk where the two happen to be equal — 1 of 6 sessions in my install, which is why it reads as intermittent.

Two things from running an equivalent fix on my own instance that might be worth folding in.

1. The opus[1m] removal looks inverted

This PR drops opus[1m] and sonnet[1m] and keeps plain opus and sonnet. I queried the installed CLI (2.1.220) for its actual supported list, and for the opus pair it's the other way around:

[
  { "value": "default",             "resolvedModel": "claude-opus-5[1m]",           "displayName": "Default (recommended)" },
  { "value": "opus[1m]",            "resolvedModel": "claude-opus-5[1m]",           "displayName": "Opus (1M context)" },
  { "value": "claude-fable-5[1m]",  "resolvedModel": "claude-fable-5",              "displayName": "Fable" },
  { "value": "sonnet",              "resolvedModel": "claude-sonnet-5",             "displayName": "Sonnet" },
  { "value": "haiku",               "resolvedModel": "claude-haiku-4-5-20251001",   "displayName": "Haiku" }
]

So on 2.1.220:

  • opus[1m] is the advertised alias and resolves to claude-opus-5[1m]
  • plain opus is not in the supported list at all
  • the sonnet side of your change is correct — sonnet is listed, sonnet[1m] is not

Removing opus[1m] would delete the entry that works and keep one the CLI no longer advertises. Worth re-checking against whichever CLI version you're targeting, since this clearly moved recently (on 2.1.218 opus still resolved, to claude-opus-4-8).

Same caveat applies to the alias-normalisation helper: opus and opus[1m] map to different resolved models, so collapsing them to one family label loses the distinction a user needs in order to tell whether their pick applied.

2. Stored pins start taking effect the moment this merges

This one bit me. While the pin was being ignored, users could leave stale entries behind without noticing — the picker looked like it did nothing, so people click around. On my instance one session had a pin pointing at a model the user had tried once while debugging; the moment the lookup started matching, that pin silently took over on the next turn and changed the model out from under them.

Nothing in the PR is wrong here, but it's a migration hazard for the release: it may be worth clearing or migrating the stored pins as part of the change, or at least calling it out in the release notes. Same applies to any pin referencing an alias the current CLI no longer advertises — after this merges, those become live and will be passed straight through as --model.


Diagnosis and the variant I'm running were produced with Claude Code (Opus 5). The CLI model list above is raw output from supportedModels(), not hand-written.

@MacLeod92

Copy link
Copy Markdown
Author

So on 2.1.220:

  • opus[1m] is the advertised alias and resolves to claude-opus-5[1m]
  • plain opus is not in the supported list at all
  • the sonnet side of your change is correct — sonnet is listed, sonnet[1m] is not

Hey @nico-stever, so this is a misconception that I also fell for and has taken me like an hour or so to work out why our two outputs are different.

My guess would be you are using a Max plan or similar? At the very least you are not using a Pro plan.

Currently the Max plan reports the default as opus and as you have correctly pointed out it's actually opus[1m].

The [1m] suffix on the opus alias is gated on subscription tier inside the CLI itself. I'd paste the relevant code but I'm told I might be wandering into copyright territory quoting Anthropic's bundle verbatim, so here's how to find it on your own install instead:

CLI=~/.local/share/claude/versions/<your-version>
grep -a -o -E 'function KO\(\)\{[^}]{0,400}' "$CLI"
grep -a -o -E 'function W6e\(\)\{[^}]{0,100}' "$CLI"
grep -a -o -E '.{80}"\[1m\]".{80}' "$CLI" | head

You'll find the opus alias is built as "opus" + (KO() ? "[1m]" : ""), where KO() returns false if W6e(), which is subscriptionType === "pro", or if CLAUDE_CODE_DISABLE_1M_CONTEXT is set, or you're not on the first-party API. So Max/Team/Enterprise see opus[1m]; Pro sees plain opus. That's the difference between our two supportedModels() outputs. It's per-account, which is why neither of our lists is "the" supported list. (Poorly named function, if you ask me. It's the advertised models, not the supported ones.)

The important part: that gate only decorates the alias label. It isn't a capability gate. Both strings are in the CLI's static catalog and both resolve. I checked /context on this account for claude-opus-5 with and without the suffix and both report a 1M window. As I touched on in the PR, the 1M context window is now standard rather than a separate beta-gated mode, and it's not just Sonnet 5 and Opus 4.8, it's every current model save for Haiku. The old beta header (context-1m-2025-08-07) is retired. So opus and opus[1m] are the same thing, and dropping opus[1m] from the picker doesn't remove any capability from a Max user. They get the identical 1M window selecting plain opus.

My knee-jerk reaction is that this is now a bug on Anthropic's side, and a hold-over from when 1M context genuinely was gated to Max accounts. Judging by issues like anthropics/claude-code#39841 even that gating is fairly littered with bugs, with Max users being told 1M is "included with subscription" while the CLI demands extra usage credits to actually select it. I hit the same thing testing claude-opus-4-6[1m], which errors with Usage credits required for 1M context.

All this being said, CloudCLI doesn't currently use supportedModels() at all. The call is commented out in claude-models.provider.ts and a hardcoded list is used instead. The default entry in that list is just the sentinel string default, which gets passed through to the SDK as-is, so the CLI is the thing resolving it per account. That's exactly why yours lands on claude-opus-5[1m] and mine lands on claude-sonnet-5 from identical code. As it happens my PR will resolve claude-opus-5[1m] to opus and so the correct 'opus' box in /models will be highlighted for sessions spawned with default. So while that's out of action the only reason to touch this file is for humans to read a correct description, i.e. it says 4.8 but I've got 5, or when something genuinely new lands like Fable did a few weeks back. Ironically together we have now discovered that default is not static and so CloudCLI's current implementation to just describe it as Sonnet is a bad choice and probably should be changed to something like 'use Claude Code's assigned default'. But I'm afraid that has nothing really to do with the issue I was fixing and so I won't be so bold as to add it to this PR. I already felt changing version numbers a bit of a stretch!

TL;DR: plain opus should work for you exactly as it does for me, resolving to the most recent Opus available to your CLI version. That's why the removal is in this PR rather than being a regression. The [1m] entries duplicated the plain aliases above them, and the suffix isn't handled anywhere in this codebase, so selecting one only ever worked by accident.

That said, I don't have a Max account, so I would love it if you could confirm that opus resolves just fine for you. That bare alias is exactly what the picker passes as --model, so just do claude --model opus, type any old message, and check /context. It should resolve to claude-opus-5 and have 1M context.

If on the other hand it doesn't work, or you are on a Pro account, please let me know so I can cry some more.

For a real "supported models" list you can trawl the binary. Here's what I came up with on 2.1.220, every model entry the CLI code knows about, complete and unfiltered:

  • Sonnet: claude-sonnet-4-6 (previous), claude-sonnet-5 (sonnet)
  • Opus: claude-opus-4-1 (opus41), claude-opus-4-6 (opus46), claude-opus-4-7 (opus47), claude-opus-4-8 (opus48), claude-opus-5 (opus)
  • Fable: fable / claude-fable-5
  • Haiku: haiku (claude-haiku-4-5), plus legacy claude-3-5-haiku
  • opusplan, which is Opus in plan mode, Sonnet otherwise

[1m] variants exist as literals for: sonnet[1m], claude-sonnet-4-6[1m], opus[1m], claude-opus-4-6[1m], claude-opus-4-7[1m], claude-opus-4-8[1m], fable[1m], and opusplan[1m]. Notably not haiku[1m] or claude-opus-4-1[1m]; those don't appear at all.

Worth noting claude-fable-5[1m] isn't a literal in the binary either. It's assembled at runtime by the suffix helper, which is why it turns up in both our supportedModels() outputs as a value that resolves back to plain claude-fable-5.

In regards to your second point:

Stored pins start taking effect the moment this merges

Agreed, though I'd say the practical risk is low: sessions are throwaway working context, not something a user expects to stay pinned to a model weeks later. It's also worth noting main already has a related bug where picking a model for a new session can bleed into older sessions when you switch back to them, so this user base is probably already fairly cautious about checking which model a given session is actually on, rather than trusting it to stay put. Worth a mention in the release notes if whoever cuts the release wants to, but I don't think it needs a migration or any change to this PR.

Opus was updated to 5; update the flavor text so it doesn't fall out of date.
@nico-stever

Copy link
Copy Markdown

Confirmed on Max — and you were right on all three points. No crying required.

1. Plain opus resolves fine. claude --model opus on this account (Max, CLI 2.1.220) returns without error, and modelUsage reports claude-opus-5. So the removal isn't a regression here.

2. The gate is exactly where you said it is. Same helper on my install:

function Vkt(){return"opus"+(KO()?"[1m]":"")}

3. The 1M window belongs to the plain model id, not to the suffix. Rather than read it off /context, I went at the static catalog:

grep -a -o -E '.{0,120}"claude-opus-5".{0,200}' "$CLI"
# → ... context:{window:1e6} ...

claude-opus-5 carries context:{window:1e6} in the catalog itself. I think that's firmer evidence than a per-account /context reading, precisely because it's account-independent: the suffix decorates the alias label, it doesn't grant the window. Literal counts on 2.1.220 line up with your list too — claude-opus-5 appears 39 times, claude-opus-5[1m] twice.

So dropping the [1m] entries costs Max users nothing, and my objection was based on reading supportedModels() as supported rather than advertised. Your naming complaint is well earned.

On the stored pins: fair, I'll withdraw it. Your point that sessions are throwaway context is the right frame, and a release note is proportionate.

One aside that supports your default observation from this end: with identical code, my sessions spawned with default land on claude-opus-5[1m] while yours land on claude-sonnet-5. So the hardcoded "Sonnet" description in claude-models.provider.ts is actively wrong for a chunk of users, not just out of date. Agreed it doesn't belong in this PR — worth its own issue if you feel like opening one, otherwise I'm happy to.


Measurements run on my own instance; the binary greps and the write-up were done with Claude Code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Can not change model from Chat UI

2 participants