fix(claude): grey out models the organization has restricted - #8652
fix(claude): grey out models the organization has restricted#8652whoisaldo wants to merge 2 commits into
Conversation
Claude Code silently substitutes the org default for a model the account's organization has not entitled, so an org that restricts Fable 5 left T3 Code offering it as selectable, answering from Opus 5, and still labelling the thread "Claude Fable 5". The only hint was an unknown-subtype error row, because the CLI reports the swap as a `system:informational` message the pinned SDK's union does not declare. Read the resolved per-model entitlements Claude Code caches in `.claude.json` (`modelAccessCache`) during the existing capabilities probe, and carry an `unavailableReason` on restricted models so the web and mobile pickers render them disabled with the reason instead of letting a pick land on a different model than the label promises. Also handle `informational`, surfacing its warning level as a proper runtime warning so the substitution notice reads as itself rather than as an unknown subtype. Entitlements are read fail-open: a missing, unreadable, or malformed cache restricts nothing, and only an explicit `entitled: false` marks a model. The SDK init model catalog is deliberately not used for this — it is the CLI's curated picker list, so `claude-opus-4-8` is absent from it yet runs normally, and absence there cannot be read as "restricted". Written by Claude Opus 5 in T3 Code.
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
There was a problem hiding this comment.
One consistency finding on the web changes: the new isUnavailable flag reaches every ProviderModelPicker call site, but the matching disabled treatment only exists in ChatView. Details inline.
Posted via Macroscope — UI Consistency
| // A model the environment can't run (today: not entitled by the Claude | ||
| // account's organization) reuses the unavailable treatment, so the composer | ||
| // trigger stops presenting it as the model that will answer. | ||
| if (model.unavailableReason) option.isUnavailable = true; |
There was a problem hiding this comment.
Marking restricted models isUnavailable here reaches every ProviderModelPicker, but the disabled treatment only exists at the ChatView call site, so the same badge now means two different things.
getCustomModelOptionsByInstance feeds the settings pickers too (SettingsPanels.tsx text-generation model, SourceControlWritingSettings.tsx, ProjectSettingsPanel.tsx default model). None of them pass getModelDisabledReason, so an org-restricted Claude model renders the Unavailable badge (ModelListRow line 80, trigger badge in ProviderModelPicker line 183) while ModelListRow stays enabled and handleModelSelect still fires — the pick is persisted and the turn silently runs the org default, which is exactly what this PR fixes in the composer.
The reason string is also dropped here and re-derived in ChatView.getModelDisabledReason by re-scanning providerStatuses, which is why the behavior can't travel with the option. Suggest carrying unavailableReason on AppModelOption/ModelEsque and having ModelPickerContent fold it into disabledReason (still OR-ing the caller's getModelDisabledReason for the thread-state reasons), so every picker disables the row and shows the same tooltip; alternatively pass a shared getModelDisabledReason at the three settings call sites.
Posted via Macroscope — UI Consistency
There was a problem hiding this comment.
Fixed in 8fab205. You were right that the badge meant two different things.
Carrying unavailableReason on the option was the better of the two suggestions, so the behavior travels with the data instead of each call site opting in. ModelPickerContent folds it into the row's disabled reason and still ORs the caller's getModelDisabledReason for thread-state blocks, and the same helper gates handleModelSelect and the keyboard jump targets so a restricted row is not a reachable jump target either. ChatView no longer re-derives the reason from providerStatuses.
resolveModelPickerDisabledReason is exported next to shouldIncludeModelPickerOption and unit tested, including the settings-picker case where no caller reason exists.
There was a problem hiding this comment.
Sorry, I'm unable to act on this request because you do not have permissions within this repository.
…poser Marking a restricted model isUnavailable reached every ProviderModelPicker, but the disabled treatment lived in ChatView's getModelDisabledReason, which the three settings pickers do not pass. Those rendered the Unavailable badge while staying selectable, so the badge meant two different things. Carry unavailableReason on the option instead and fold it into the row's disabled reason inside ModelPickerContent, still OR-ing the caller's reason for thread-state blocks. ChatView no longer re-derives it from providerStatuses.
|
Context for this change is in #8657, per CONTRIBUTING's note that non-trivial changes should start as a discussion. Leaving this in draft until the mobile row has a before/after capture to match the web ones. |
|
Out of draft. Correcting my earlier note about the mobile capture. I did attempt it. The Expo prebuild and The mobile edit is 16 lines in |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR introduces cross-cutting runtime behavior that reads Claude organization entitlements and changes model availability across server, web, and mobile pickers, while also altering runtime warning handling. Because entitlement decisions and shared picker behavior are sensitive product paths, human validation is warranted. You can add or adjust custom eligibility rules. Learn more. |
Problem
An organization can disallow individual Claude models. Claude Code does not fail those requests. It swaps in the org default and reports the swap partway through the turn.
T3 Code never read that state, so on an account whose org restricts Fable 5:
Fable 5 stayed fully selectable in every model picker. Choosing it ran Opus 5 while the composer and the thread still read "Claude Fable 5". The only signal was an unknown-subtype error row that named no model, because the CLI sends the substitution as
system:informational, a subtype the pinned SDK 0.3.170 does not declare, so it fell through the exhaustiveness branch inhandleSdkSystemMessage.Reproduced against a restricted account on CLI 2.1.228, well past the 2.1.169 version gate for Fable 5:
Fix
Claude Code caches resolved per-model entitlements in
.claude.jsonundermodelAccessCache, and greys its own/modelrows out from that list. The capabilities probe now reads it, so this costs no extra spawn and shares the probe's existing cache. A restricted model keeps its catalog row and gains anunavailableReason. Web and mobile render that row disabled and show the reason.informationalis handled now too. Its warning level becomes a runtime warning, so the substitution notice reads as itself rather than as an unknown subtype. Quieter levels stay consumed.The reason travels on the model option rather than on a callback, so a picker disables the row whether or not its call site passes
getModelDisabledReason.Why not the SDK model catalog
Treating "absent from the init
modelscatalog" as restricted looks right and is wrong. That catalog is the CLI's curated picker list, not an entitlement list. I checked both cases before discarding the approach.claude-fable-5is absent from the catalog, carriesentitled: false, and falls back to Opus 5.claude-opus-4-8is also absent from the catalog, carriesentitled: true, and answers normally. Keying on absence would have greyed out a model that works.Safety
Reading fails open at every step, so this restricts less than reality rather than more. A missing, unreadable, or malformed config restricts nothing. A
modelAccessCachethat is absent or not an array restricts nothing. Only an explicitentitled: falserestricts, since an absent verdict is unknown rather than disallowed. No capabilities probe means no entitlement list and no restrictions.Config path resolution matches what the spawned CLI reads: the instance
homePathexported asCLAUDE_CONFIG_DIR, then an ambientCLAUDE_CONFIG_DIR, then~/.claude.json.Review findings addressed
UI changes
Composer trigger, before and after:
Model picker, before and after. Note the
⌘1and⌘2jump shortcuts in the before shot: the restricted rows were selectable keyboard targets.Captured on a real org-restricted account against a disposable
.t3seeded from live data. Both Claude instances report the restriction, including the custom "Claude Northeastern" instance.The mobile row is not captured. I tried: the Expo prebuild and
pod installboth completed, but the iOS build stops atclerk-ios1.3.6, which needs Swift tools 6.2 and therefore Xcode 26. The machine I built on has Xcode 16.4 and Swift 6.1.2, so the block is the toolchain version rather than the change.The mobile edit is 16 lines and covered by typecheck and unit tests only. It disables the row, dims it, and puts the reason where the subtitle goes. Happy to add the capture if you would rather see it before this merges.
Verification
ClaudeEntitlements.test.ts, new. Parsing, both config-path precedences, and each fail-open path.ProviderRegistry.test.ts. A restricted model is marked and an entitled one is untouched.ClaudeCapabilitiesProbe.test.ts. The probe surfaces entitlements. This test would otherwise have started reading the developer's real~/.claude.jsononce the probe took on entitlements, so it now points at an isolated fixture directory.ClaudeAdapter.test.ts. Warning-levelinformationalsurfaces and quiet levels stay silent.ModelPickerContent.test.ts. A model states its own disabled reason without a caller-supplied one.tsgo --noEmitclean onpackages/contracts,apps/server, andapps/web.tsc --noEmitclean onapps/mobile.vp lintandvp fmtclean on every changed file. The oneno-useless-spreadwarning inClaudeAdapter.tspredates this branch and is untouched.Surfaces
Server detects restrictions on the Claude provider snapshot. Contracts gain an optional additive
ServerProviderModel.unavailableReason. Web disables the row, shows the reason on hover, and marks the composer trigger. Mobile disables the row, dims it, and shows the reason in place of the subtitle. Providers other than Claude are unaffected, since nothing else setsunavailableReason.Note
Medium Risk
Touches Claude provider snapshots, config path resolution, and runtime message handling; parsing fails open so restrictions may be understated, but wrong reads could still confuse model selection UX.
Overview
Claude org-restricted models are now surfaced end-to-end instead of staying selectable while the CLI silently substitutes the org default.
The server reads Claude Code’s
.claude.jsonmodelAccessCacheduring the existing capabilities probe (fail-open parsing) and sets optionalunavailableReasonon restricted catalog models. Web and mobile disable those picker rows, show the reason (mobile replaces the subtitle), and block selection—including settings pickers that never passed thread-level disable callbacks.Claude adapter treats warning-level
system:informationalmessages as runtime warnings so org model substitution notices are visible instead of unknown-subtype noise; quiet informational levels stay swallowed.Contracts add
ServerProviderModel.unavailableReason;resolveModelPickerDisabledReasoncentralizes disable logic on web.Reviewed by Cursor Bugbot for commit 8fab205. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Grey out org-restricted Claude models in model picker
modelAccessCachefrom.claude.json(viareadClaudeRestrictedModels) and annotates restricted models withunavailableReasonin ClaudeProvider.tsunavailableReasontoServerProviderModelin server.ts; web and mobile clients propagate it and disable restricted model rows with dimmed styling and accessibility stateresolveModelPickerDisabledReasonin ModelPickerContent.tsx prefers the model's own reason over caller-supplied reasonsClaudeAdapternow handlesinformationalmessage subtypes: warning-level notices surface as runtime warnings, others are consumed silently instead of raising unknown-subtype errorsunavailableReasononServerProviderModelMacroscope summarized 8fab205.