Skip to content

fix(claude): grey out models the organization has restricted - #8652

Open
whoisaldo wants to merge 2 commits into
pingdotgg:mainfrom
whoisaldo:fix/claude-org-restricted-models
Open

fix(claude): grey out models the organization has restricted#8652
whoisaldo wants to merge 2 commits into
pingdotgg:mainfrom
whoisaldo:fix/claude-org-restricted-models

Conversation

@whoisaldo

@whoisaldo whoisaldo commented Aug 29, 2026

Copy link
Copy Markdown

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 in handleSdkSystemMessage.

Reproduced against a restricted account on CLI 2.1.228, well past the 2.1.169 version gate for Fable 5:

requested   = claude-fable-5
init.model  = claude-opus-5[1m]
answered    = claude-opus-5
notice      = [warning] Model "claude-fable-5" is restricted by your
              organization's settings. Using claude-opus-5[1m] instead.

Fix

Claude Code caches resolved per-model entitlements in .claude.json under modelAccessCache, and greys its own /model rows 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 an unavailableReason. Web and mobile render that row disabled and show the reason.

informational is 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 models catalog" 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-5 is absent from the catalog, carries entitled: false, and falls back to Opus 5. claude-opus-4-8 is also absent from the catalog, carries entitled: 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 modelAccessCache that is absent or not an array restricts nothing. Only an explicit entitled: false restricts, 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 homePath exported as CLAUDE_CONFIG_DIR, then an ambient CLAUDE_CONFIG_DIR, then ~/.claude.json.

Review findings addressed

UI changes

Composer trigger, before and after:

Composer trigger before, reading Claude Fable 5 with no badge

Composer trigger after, reading Claude Fable 5 with an Unavailable badge

Model picker, before and after. Note the ⌘1 and ⌘2 jump shortcuts in the before shot: the restricted rows were selectable keyboard targets.

Model picker before, both Fable 5 rows selectable with jump shortcuts

Model picker after, both Fable 5 rows disabled with the reason Restricted by your organization

Captured on a real org-restricted account against a disposable .t3 seeded 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 install both completed, but the iOS build stops at clerk-ios 1.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.json once the probe took on entitlements, so it now points at an isolated fixture directory.
  • ClaudeAdapter.test.ts. Warning-level informational surfaces and quiet levels stay silent.
  • ModelPickerContent.test.ts. A model states its own disabled reason without a caller-supplied one.
  • 184 tests across 9 files.
  • tsgo --noEmit clean on packages/contracts, apps/server, and apps/web. tsc --noEmit clean on apps/mobile.
  • vp lint and vp fmt clean on every changed file. The one no-useless-spread warning in ClaudeAdapter.ts predates 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 sets unavailableReason.


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.json modelAccessCache during the existing capabilities probe (fail-open parsing) and sets optional unavailableReason on 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:informational messages as runtime warnings so org model substitution notices are visible instead of unknown-subtype noise; quiet informational levels stay swallowed.

Contracts add ServerProviderModel.unavailableReason; resolveModelPickerDisabledReason centralizes 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

  • Server reads modelAccessCache from .claude.json (via readClaudeRestrictedModels) and annotates restricted models with unavailableReason in ClaudeProvider.ts
  • Adds optional unavailableReason to ServerProviderModel in server.ts; web and mobile clients propagate it and disable restricted model rows with dimmed styling and accessibility state
  • resolveModelPickerDisabledReason in ModelPickerContent.tsx prefers the model's own reason over caller-supplied reasons
  • ClaudeAdapter now handles informational message subtypes: warning-level notices surface as runtime warnings, others are consumed silently instead of raising unknown-subtype errors
  • Risk: restricted models remain listed but are non-selectable; callers relying on every listed model being runnable should check unavailableReason on ServerProviderModel

Macroscope summarized 8fab205.

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.
@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 31532a77-a72b-4e32-b6d1-45de311b8ff5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Aug 29, 2026

@macroscopeapp macroscopeapp 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.

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

Comment thread apps/web/src/modelSelection.ts Outdated
Comment on lines +114 to +117
// 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;

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.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

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.

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.
@whoisaldo

Copy link
Copy Markdown
Author

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.

@whoisaldo
whoisaldo marked this pull request as ready for review August 29, 2026 14:21
@whoisaldo

Copy link
Copy Markdown
Author

Out of draft.

Correcting my earlier note about the mobile capture. I did attempt it. The Expo prebuild and pod install both completed, but the iOS build stops resolving packages because clerk-ios 1.3.6 needs Swift tools 6.2, which means Xcode 26. I had Xcode 16.4 and Swift 6.1.2. That matches the iOS 26.5 and iOS 27 simulators in your recent mobile PRs, so I assume this is expected and only affects me.

The mobile edit is 16 lines in ThreadSettingsSheet and modelOptions, covered by typecheck and unit tests. If you would rather it land separately, I am happy to pull it into its own PR and keep this one to server, web, and contracts.

@macroscopeapp

macroscopeapp Bot commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: 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.

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

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant