From fe08c38bffd7294661a27fb13a2c07ed4f0b447c Mon Sep 17 00:00:00 2001 From: whoisaldo Date: Sun, 6 Sep 2026 02:11:06 -0400 Subject: [PATCH 1/2] fix(claude): leave out models the organization has restricted 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 picker had no way to know: the SDK's init model list drops restricted rows rather than flagging them, so absence there cannot be read as "restricted" (Opus 4.8 is absent from it and runs normally), and the field that would carry them is internal to the VS Code extension. Read the resolved per-model entitlements Claude Code caches in its global config (`modelAccessCache`, the list its own `/model` menu is built from) during the existing capabilities probe, and leave restricted catalog models out of the provider snapshot, the way a model the installed CLI is too old for is already left out. Clients then resolve a selection that pointed at a restricted model to the provider default, so the label matches what answers. The provider's status detail names the withheld models, as the version upgrade message names a model the CLI cannot run yet. Entitlements apply only on the first-party and gateway backends, as in the CLI, so a cache left behind by an earlier claude.ai login cannot hide models a Bedrock, Vertex, or Foundry account can run. Custom models stay listed. Reading fails open otherwise: a missing, unreadable, or malformed cache withholds nothing, one odd entry costs only itself, and only an explicit `entitled: false` counts. Written by Claude Fable 5.1 in Claude Code. --- .../features/threads/NewTaskDraftScreen.tsx | 4 +- apps/mobile/src/lib/modelOptions.test.ts | 75 ++++++++ apps/mobile/src/lib/modelOptions.ts | 11 +- .../src/state/use-thread-composer-state.ts | 4 +- .../src/state/use-thread-outbox-drain.ts | 8 +- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../Drivers/ClaudeEntitlements.test.ts | 166 ++++++++++++++++++ .../provider/Drivers/ClaudeEntitlements.ts | 103 +++++++++++ .../Layers/ClaudeCapabilitiesProbe.test.ts | 27 ++- .../src/provider/Layers/ClaudeProvider.ts | 92 +++++++++- .../provider/Layers/ProviderRegistry.test.ts | 139 +++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 14 +- docs/user/providers-claude.md | 17 ++ 13 files changed, 638 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts create mode 100644 apps/server/src/provider/Drivers/ClaudeEntitlements.ts diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 6badb4da33ee..ff3d3cb3a5c9 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -1205,8 +1205,8 @@ export function NewTaskDraftScreen(props: { isModelSelectionUnavailable(selectedEnvironmentServerConfig, modelSelection) ) { Alert.alert( - "Antigravity model unavailable", - "Set up Antigravity on web or desktop, or choose another model.", + "Model unavailable", + "Set up the provider on web or desktop, or choose another model.", ); return; } diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 98896b990ec4..eb75fd408769 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -197,6 +197,81 @@ describe("mobile model options", () => { expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); }); + describe("Claude catalog restrictions", () => { + const selection = { + instanceId: ProviderInstanceId.make("claude_work"), + model: "claude-fable-5", + }; + const allowedModel = { + slug: "claude-opus-5", + name: "Claude Opus 5", + isDefault: true, + isCustom: false, + capabilities: null, + }; + const provider = { + instanceId: selection.instanceId, + driver: "claudeAgent", + enabled: true, + installed: true, + status: "ready", + auth: { status: "authenticated" }, + models: [allowedModel], + }; + const config = { providers: [provider] } as unknown as ServerConfig; + + it("keeps a saved restricted model disabled instead of making it selectable again", () => { + expect(buildModelOptions(config, null).map((option) => option.selection.model)).toEqual([ + "claude-opus-5", + ]); + expect(buildModelOptions(config, selection)).toMatchObject([ + { selection: { model: "claude-opus-5" } }, + { selection, isUnavailable: true }, + ]); + expect(isModelSelectionUnavailable(config, selection)).toBe(true); + expect(resolveSelectableModelSelection(config, selection)).toBeNull(); + expect(resolveDefaultableModelSelection(config, selection)).toBeNull(); + expect( + resolveNewTaskModelSelection({ + draftSelection: resolveSelectableModelSelection(config, selection), + projectDefaultSelection: resolveDefaultableModelSelection(config, selection), + stickySelection: resolveDefaultableModelSelection(config, selection), + modelOptions: buildModelOptions(config, null), + }), + ).toMatchObject({ instanceId: selection.instanceId, model: "claude-opus-5" }); + }); + + it("allows the model again when access returns or it is explicitly configured", () => { + for (const isCustom of [false, true]) { + const restored = { + providers: [ + { + ...provider, + models: [allowedModel, { ...allowedModel, slug: selection.model, isCustom }], + }, + ], + } as unknown as ServerConfig; + expect(isModelSelectionUnavailable(restored, selection)).toBe(false); + expect(resolveSelectableModelSelection(restored, selection)).toBe(selection); + expect( + buildModelOptions(restored, selection).find( + (option) => option.selection.model === selection.model, + )?.isUnavailable, + ).not.toBe(true); + } + }); + + it("does not infer restrictions from a pending or failed probe or an offline environment", () => { + for (const status of ["warning", "error"]) { + const unchecked = { providers: [{ ...provider, status }] } as unknown as ServerConfig; + expect(isModelSelectionUnavailable(unchecked, selection)).toBe(false); + expect(resolveSelectableModelSelection(unchecked, selection)).toBe(selection); + } + expect(isModelSelectionUnavailable(null, selection)).toBe(false); + expect(resolveSelectableModelSelection(null, selection)).toBe(selection); + }); + }); + describe("Antigravity selections", () => { const selection = { instanceId: ProviderInstanceId.make("google_work"), diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 4e8295733141..9a0f60978b49 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -6,6 +6,7 @@ import type { import { buildExplicitProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, + resolveSelectableModel, } from "@t3tools/shared/model"; export type ModelOption = { @@ -61,7 +62,7 @@ function normalizeSelectionOptions( }; } -/** Whether a known Antigravity selection needs setup or a different model. */ +/** Whether a saved selection needs setup or a different model before sending. */ export function isModelSelectionUnavailable( config: T3ServerConfig | null | undefined, selection: ModelSelection | null | undefined, @@ -74,6 +75,11 @@ export function isModelSelectionUnavailable( ); const driver = provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; + // A completed Claude probe omits version-gated and organization-restricted + // models. A saved selection must not make those models selectable again. + if (provider?.driver === "claudeAgent" && provider.status === "ready") { + return resolveSelectableModel(provider.driver, selection.model, provider.models) === null; + } return ( driver === "antigravity" && (!provider || @@ -105,6 +111,9 @@ export function resolveSelectableModelSelection( if (driver === "antigravity") { return selection; } + if (isModelSelectionUnavailable(config, selection)) { + return null; + } return provider && provider.enabled && provider.installed && diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index d21af7d79cc3..78eb3f448244 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -379,8 +379,8 @@ export function useThreadComposerState() { isModelSelectionUnavailable(serverConfig, modelSelection) ) { Alert.alert( - "Antigravity model unavailable", - "Set up Antigravity on web or desktop, or choose another model.", + "Model unavailable", + "Set up the provider on web or desktop, or choose another model.", ); return null; } diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1147e9fd47af..3a624c83e152 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -698,7 +698,7 @@ export function useThreadOutboxDrain(): void { if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { return restoreQueuedMessage( queuedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + "Model unavailable. Set up the provider on web or desktop, or choose another model.", ); } const { reportFailure } = makeDeliveryHelpers(queuedMessage); @@ -791,7 +791,7 @@ export function useThreadOutboxDrain(): void { if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { return restoreQueuedMessage( persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + "Model unavailable. Set up the provider on web or desktop, or choose another model.", ); } const sendSettings = resolveQueuedThreadSettings( @@ -875,7 +875,7 @@ export function useThreadOutboxDrain(): void { if (isModelSelectionUnavailable(serverConfig, settings.modelSelection)) { return restoreQueuedMessage( queuedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + "Model unavailable. Set up the provider on web or desktop, or choose another model.", ); } let prepared: PreparedTurnAttachments; @@ -919,7 +919,7 @@ export function useThreadOutboxDrain(): void { if (isModelSelectionUnavailable(currentConfig, settings.modelSelection)) { return restoreQueuedMessage( persistedMessage, - "Antigravity model unavailable. Set it up on web or desktop, or choose another model.", + "Model unavailable. Set up the provider on web or desktop, or choose another model.", ); } const sendSettings = resolveQueuedThreadSettings( diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 324284a3a4c7..2f7f7ac814e5 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -169,6 +169,7 @@ export const ClaudeDriver: ProviderDriver = { lookup: () => probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(Path.Path, path), + Effect.provideService(FileSystem.FileSystem, fileSystem), ), }); const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); diff --git a/apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts b/apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts new file mode 100644 index 000000000000..06fb8212c3fb --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts @@ -0,0 +1,166 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { readClaudeRestrictedModels } from "./ClaudeEntitlements.ts"; + +const writeClaudeConfig = Effect.fn(function* (configDir: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(configDir, { recursive: true }); + yield* fs.writeFileString(path.join(configDir, ".claude.json"), contents); +}); + +const makeConfigDir = Effect.fn(function* (name: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-entitlements-" }); + return path.join(tempDir, name); +}); + +it.layer(NodeServices.layer)("readClaudeRestrictedModels", (it) => { + it.effect("returns only the models the organization has disallowed", () => + Effect.gen(function* () { + const configDir = yield* makeConfigDir("claude-home"); + // The real file carries dozens of unrelated keys around the cache, and + // names older models by dated API id where the catalog uses the bare + // slug. + yield* writeClaudeConfig( + configDir, + `{ + "numStartups": 12, + "oauthAccount": { "emailAddress": "dev@example.com" }, + "modelAccessCache": [ + { "apiName": "claude-fable-5", "entitled": false }, + { "apiName": "claude-fable-5-1", "entitled": false }, + { "apiName": "claude-haiku-4-5-20251001", "entitled": false }, + { "apiName": "claude-opus-4-5-20251101", "entitled": true }, + { "apiName": "claude-opus-5", "entitled": true }, + { "apiName": "claude-sonnet-5", "entitled": true } + ] + }`, + ); + + const restricted = yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: configDir }); + + assert.deepEqual([...restricted], ["claude-fable-5", "claude-fable-5-1", "claude-haiku-4-5"]); + }), + ); + + it.effect("reads ~/.claude.json of the home the CLI is spawned with", () => + Effect.gen(function* () { + const home = yield* makeConfigDir("home"); + yield* writeClaudeConfig( + home, + `{ "modelAccessCache": [{ "apiName": "claude-fable-5", "entitled": false }] }`, + ); + + // An instance environment may override HOME; the reader has to follow + // it to the same file the child reads rather than the server's own. + const restricted = yield* readClaudeRestrictedModels({ HOME: home }); + + assert.deepEqual([...restricted], ["claude-fable-5"]); + }), + ); + + it.effect("prefers the literal config directory over HOME", () => + Effect.gen(function* () { + const home = yield* makeConfigDir("home"); + const configDir = yield* makeConfigDir("claude-home "); + yield* writeClaudeConfig( + home, + `{ "modelAccessCache": [{ "apiName": "claude-opus-5", "entitled": false }] }`, + ); + yield* writeClaudeConfig( + configDir, + `{ "modelAccessCache": [{ "apiName": "claude-fable-5", "entitled": false }] }`, + ); + + assert.deepEqual( + [...(yield* readClaudeRestrictedModels({ HOME: home, CLAUDE_CONFIG_DIR: configDir }))], + ["claude-fable-5"], + ); + assert.deepEqual( + [...(yield* readClaudeRestrictedModels({ HOME: home, CLAUDE_CONFIG_DIR: "" }))], + ["claude-opus-5"], + ); + }), + ); + + it.effect("restricts nothing for a relative config dir or home", () => + Effect.gen(function* () { + // The CLI resolves a relative CLAUDE_CONFIG_DIR or HOME against each + // session's own working directory, so no single file speaks for the + // environment. The file must not even be consulted: this filesystem + // would answer every read with a restriction. + const reads: Array = []; + const restrictiveFileSystem = FileSystem.layerNoop({ + readFileString: (filePath) => + Effect.sync(() => { + reads.push(filePath); + return `{ "modelAccessCache": [{ "apiName": "claude-fable-5", "entitled": false }] }`; + }), + }); + + for (const environment of [ + { CLAUDE_CONFIG_DIR: "./claude" }, + { CLAUDE_CONFIG_DIR: " /claude" }, + { HOME: "home" }, + ]) { + const restricted = yield* readClaudeRestrictedModels(environment).pipe( + Effect.provide(restrictiveFileSystem), + ); + assert.deepEqual([...restricted], []); + } + assert.deepEqual(reads, []); + }), + ); + + it.effect("restricts nothing when the config is missing or malformed", () => + Effect.gen(function* () { + const absent = yield* makeConfigDir("absent-home"); + assert.deepEqual([...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: absent }))], []); + + const brokenJson = yield* makeConfigDir("broken-json"); + yield* writeClaudeConfig(brokenJson, "{ not json"); + assert.deepEqual( + [...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: brokenJson }))], + [], + ); + + const brokenCache = yield* makeConfigDir("broken-cache"); + yield* writeClaudeConfig(brokenCache, `{ "modelAccessCache": { "claude-fable-5": false } }`); + assert.deepEqual( + [...(yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: brokenCache }))], + [], + ); + }), + ); + + it.effect("ignores entries that carry no usable model id or verdict", () => + Effect.gen(function* () { + const configDir = yield* makeConfigDir("partial-home"); + yield* writeClaudeConfig( + configDir, + `{ + "modelAccessCache": [ + null, + "claude-fable-5", + { "entitled": false }, + { "apiName": " ", "entitled": false }, + { "apiName": "claude-opus-5" }, + { "apiName": "claude-sonnet-4-6", "entitled": false } + ] + }`, + ); + + const restricted = yield* readClaudeRestrictedModels({ CLAUDE_CONFIG_DIR: configDir }); + + // Only an explicit `false` restricts: an absent verdict is unknown, not + // disallowed, and one odd entry does not cost the others. + assert.deepEqual([...restricted], ["claude-sonnet-4-6"]); + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/ClaudeEntitlements.ts b/apps/server/src/provider/Drivers/ClaudeEntitlements.ts new file mode 100644 index 000000000000..d47ef85900ce --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeEntitlements.ts @@ -0,0 +1,103 @@ +/** + * ClaudeEntitlements — reads which models the account's organization allows. + * + * Enterprise and team organizations can disallow individual models. Claude + * Code records the resolved per-model entitlements in its global config file + * under `modelAccessCache`, the list its own `/model` menu is built from, and + * falls back to the org default when a disallowed model is requested — + * emitting only an `informational` notice mid-turn, after the user already + * picked it. + * + * The Agent SDK is not a usable substitute: its init model list is the CLI's + * curated picker with restricted rows already dropped, so a model can be + * absent from it and still run (`claude-opus-4-8` is absent yet answers + * normally), and the field that would carry them is internal to the VS Code + * extension. + * + * Reading is best effort in both directions: an unreadable, malformed, or + * absent cache yields no restrictions, so the picker degrades to today's + * behavior rather than hiding models the org actually allows. + * + * @module provider/Drivers/ClaudeEntitlements + */ +import * as NodeOS from "node:os"; + +import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +/** + * The `.claude.json` the spawned CLI reads, given the environment it is + * spawned with (see `makeClaudeEnvironment`, which exports an instance's + * `homePath` as `CLAUDE_CONFIG_DIR`). Verified against the CLI: with + * `CLAUDE_CONFIG_DIR` set it reads `$CLAUDE_CONFIG_DIR/.claude.json`; without + * it, `~/.claude.json` beside the `~/.claude` directory rather than inside it. + * + * A relative `CLAUDE_CONFIG_DIR` or `HOME` resolves against each session's + * own working directory, so no single file speaks for the whole environment; + * `undefined` here means restrict nothing. + */ +function resolveClaudeConfigFilePath( + path: Path.Path, + environment: NodeJS.ProcessEnv, +): string | undefined { + const root = environment.CLAUDE_CONFIG_DIR || environment.HOME || NodeOS.homedir(); + return path.isAbsolute(root) ? path.join(root, ".claude.json") : undefined; +} + +// Entries are validated one at a time, as the CLI does, so a single odd entry +// costs only itself rather than every restriction in the list. +const ClaudeGlobalConfig = Schema.fromJsonString( + Schema.Struct({ modelAccessCache: Schema.optional(Schema.Array(Schema.Unknown)) }), +); +const decodeClaudeGlobalConfig = Schema.decodeUnknownOption(ClaudeGlobalConfig); + +const ModelAccessEntry = Schema.Struct({ + apiName: TrimmedNonEmptyString, + entitled: Schema.Boolean, +}); +const decodeModelAccessEntry = Schema.decodeUnknownOption(ModelAccessEntry); + +/** + * The cache names models by API id, which for older models carries a release + * date (`claude-haiku-4-5-20251001`) that the catalog slug (`claude-haiku-4-5`) + * does not. Dropping the date is the same normalization the CLI applies before + * matching, and it is what lets the entry meet the slug. + */ +function toCatalogSlug(apiName: string): string { + return apiName.replace(/-\d{8}$/, ""); +} + +/** + * Model ids the organization has explicitly disallowed, as catalog slugs + * (`claude-fable-5`), for the account the given environment spawns the CLI + * as. Entries the cache marks entitled, and models it does not mention at + * all, are omitted — only an explicit `entitled: false` restricts. + */ +export const readClaudeRestrictedModels = Effect.fn("readClaudeRestrictedModels")(function* ( + environment: NodeJS.ProcessEnv, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const restricted = new Set(); + + const configFilePath = resolveClaudeConfigFilePath(path, environment); + if (configFilePath === undefined) return restricted; + + const contents = yield* fileSystem + .readFileString(configFilePath) + .pipe(Effect.orElseSucceed(() => undefined)); + const parsed = contents === undefined ? Option.none() : decodeClaudeGlobalConfig(contents); + if (Option.isNone(parsed)) return restricted; + + for (const entry of parsed.value.modelAccessCache ?? []) { + const decoded = decodeModelAccessEntry(entry); + if (Option.isSome(decoded) && !decoded.value.entitled) { + restricted.add(toCatalogSlug(decoded.value.apiName)); + } + } + return restricted; +}); diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 232b8cc02d00..8829e9501c5f 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -79,6 +79,19 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { }).catch(() => undefined), ), ); + // Point the probe at an isolated config dir so entitlements come from + // this fixture rather than the developer's real ~/.claude.json. + const claudeConfigDir = path.join(tempDir, "claude-config"); + yield* fs.makeDirectory(claudeConfigDir, { recursive: true }); + yield* fs.writeFileString( + path.join(claudeConfigDir, ".claude.json"), + `{ + "modelAccessCache": [ + { "apiName": "claude-fable-5", "entitled": false }, + { "apiName": "claude-opus-5", "entitled": true } + ] + }`, + ); yield* fs.writeFileString( executablePath, @@ -109,6 +122,11 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ' response: { subtype: "success", request_id: message.request_id, response },', ' }) + "\\n");', ' if (message.request?.subtype === "initialize") {', + // Initialization can refresh entitlements. The probe must use the + // refreshed cache, not retain the pre-initialization restriction. + ' writeFileSync(process.env.CLAUDE_CONFIG_DIR + "/.claude.json", JSON.stringify({', + ' modelAccessCache: [{ apiName: "claude-fable-5-1", entitled: false }],', + " }));", " reply({", ' commands: [{ name: "review", description: "Review changes", argumentHint: "[path]" }],', " agents: [],", @@ -141,6 +159,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { ...process.env, T3_PROBE_INVOCATION_PATH: invocationPath, ENABLE_CLAUDEAI_MCP_SERVERS: "true", + CLAUDE_CONFIG_DIR: claudeConfigDir, }, workspaceCwd, ); @@ -157,6 +176,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { input: { hint: "[path]" }, }, ], + restrictedModels: new Set(["claude-fable-5-1"]), usage: { rate_limits_available: true, rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, @@ -207,9 +227,10 @@ it.effect("preserves initialized capabilities when optional usage times out", () } as ReturnType; }); yield* Effect.addFinalizer(() => Effect.sync(() => query.mockRestore())); - const probe = yield* probeClaudeCapabilities( - decodeClaudeSettings({ binaryPath: "claude" }), - ).pipe(Effect.forkChild); + const probe = yield* probeClaudeCapabilities(decodeClaudeSettings({ binaryPath: "claude" }), { + ...process.env, + CLAUDE_CONFIG_DIR: "./unused-test-config", + }).pipe(Effect.forkChild); yield* Deferred.await(usageStarted); yield* TestClock.adjust("4 seconds"); const capabilities = yield* Fiber.join(probe); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 62d7444c6968..8097605872ef 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,6 +1,7 @@ import { type ClaudeSettings, type ModelCapabilities, + type ServerProviderModel, type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -33,6 +34,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; +import { readClaudeRestrictedModels } from "../Drivers/ClaudeEntitlements.ts"; import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; import { discoverClaudeSkills } from "../Drivers/ClaudeSkills.ts"; import { makeUnavailableUsageLimits } from "../providerUsageLimits.ts"; @@ -163,6 +165,47 @@ function apiProviderAuthMetadata( return apiProvider === "bedrock" ? { type: "bedrock", label: "Amazon Bedrock" } : undefined; } +/** + * Split the catalog into the models this account can run and the ones its + * organization has disallowed. Claude Code drops the latter from its own + * `/model` menu and silently substitutes the org default when one is + * requested anyway, so listing them would let a pick land on a different + * model than the label promises. Mirrors the CLI in consulting the + * entitlement cache only on the first-party and gateway backends: on Bedrock, + * Vertex, or Foundry the models come from the cloud account, so a cache left + * behind by an earlier claude.ai login must not hide anything. A backend the + * init handshake did not name counts as unknown, and unknown withholds + * nothing. + */ +function partitionClaudeModelsByEntitlement( + models: ReadonlyArray, + restrictedModels: ReadonlySet, + apiProvider: string | undefined, +): { + readonly entitled: ReadonlyArray; + readonly restricted: ReadonlyArray; +} { + if (restrictedModels.size === 0 || (apiProvider !== "firstParty" && apiProvider !== "gateway")) { + return { entitled: models, restricted: [] }; + } + return { + entitled: models.filter((model) => !restrictedModels.has(model.slug)), + restricted: models.filter((model) => restrictedModels.has(model.slug)), + }; +} + +/** + * Shown in the provider's status detail so a model missing from the picker + * has a visible explanation, the way the version upgrade message explains a + * model the installed CLI is too old for. + */ +function formatClaudeRestrictedModelsMessage( + restricted: ReadonlyArray, +): string | undefined { + if (restricted.length === 0) return undefined; + return `Restricted by your organization: ${restricted.map((model) => model.name).join(", ")}.`; +} + // ── SDK capability probe ──────────────────────────────────────────── // Amazon Bedrock initializes far slower than first-party auth: the SDK boots the @@ -235,6 +278,13 @@ type ClaudeCapabilitiesProbe = { */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + /** + * API model ids the account's organization has disallowed, read from the + * entitlements Claude Code caches beside its config. Read alongside the + * account probe so it shares the probe's cache and so provider snapshots + * stay a pure function of the probe result. + */ + readonly restrictedModels: ReadonlySet; /** * Subscription windows from the SDK's `get_usage` control request, or * `undefined` when the request itself failed. Absent windows on an @@ -340,7 +390,7 @@ const probeClaudeCapabilities = ( claudeSettings.binaryPath, claudeEnvironment, ); - return yield* Effect.tryPromise(async () => { + const { q, init } = yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API. @@ -358,9 +408,12 @@ const probeClaudeCapabilities = ( const init = await q.initializationResult(); return { q, init }; }); + // Initialization may refresh the cache, so read it after the handshake. + const restrictedModels = yield* readClaudeRestrictedModels(claudeEnvironment); + return { q, init, restrictedModels }; }).pipe( Effect.timeout(CAPABILITIES_PROBE_TIMEOUT_MS), - Effect.flatMap(({ q, init }) => + Effect.flatMap(({ q, init, restrictedModels }) => Effect.gen(function* () { // Usage has its own deadline so a slow optional request cannot discard initialization. const usageResult = yield* Effect.tryPromise(() => @@ -386,6 +439,7 @@ const probeClaudeCapabilities = ( tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), + restrictedModels, ...(usage ? { usage } : {}), } satisfies ClaudeCapabilitiesProbe; }), @@ -523,11 +577,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const models = providerModelsFromSettings( - resolveClaudeModelsForVersion(modelCatalog, parsedVersion), - claudeSettings.customModels, - DEFAULT_CLAUDE_MODEL_CAPABILITIES, - ); + const catalogModels = resolveClaudeModelsForVersion(modelCatalog, parsedVersion); const versionUpgradeMessage = formatClaudeVersionUpgradeMessage(modelCatalog, parsedVersion); const capabilities = resolveCapabilities @@ -538,11 +588,17 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { + // Without a probe there is no entitlement list, so nothing is withheld: + // an unknown org is treated as unrestrictive. return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, - models, + models: providerModelsFromSettings( + catalogModels, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ), slashCommands: dedupedSlashCommands, skills, probe: { @@ -555,6 +611,24 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + // Custom models are the user's own declarations and stay listed; the CLI + // reports a substitution on those if the org disallows them. + const entitlement = partitionClaudeModelsByEntitlement( + catalogModels, + capabilities.restrictedModels, + capabilities.apiProvider, + ); + const models = providerModelsFromSettings( + entitlement.entitled, + claudeSettings.customModels, + DEFAULT_CLAUDE_MODEL_CAPABILITIES, + ); + const message = [ + versionUpgradeMessage, + formatClaudeRestrictedModelsMessage(entitlement.restricted), + ] + .filter((part) => part !== undefined) + .join(" "); const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, @@ -584,7 +658,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( ...(capabilities.email ? { email: capabilities.email } : {}), ...(authMetadata ? authMetadata : {}), }, - ...(versionUpgradeMessage ? { message: versionUpgradeMessage } : {}), + ...(message ? { message } : {}), usageLimits, }, }); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index caee1981d79f..cd6abdf97fd9 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -143,6 +143,7 @@ type TestClaudeCapabilities = { readonly tokenSource: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + readonly restrictedModels: ReadonlySet; }; function claudeCapabilities(overrides: Partial = {}) { @@ -153,6 +154,7 @@ function claudeCapabilities(overrides: Partial = {}) { tokenSource: undefined, apiProvider: undefined, slashCommands: [], + restrictedModels: new Set(), ...overrides, }); } @@ -604,6 +606,60 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops Claude models a completed health check leaves out", () => { + // The boot-time snapshot lists the whole catalog; the check that + // reaches the account leaves out models the CLI is too old for or the + // organization has not entitled, and those must stay out. + const model = (slug: string, name: string) => ({ + slug, + name, + isCustom: false, + capabilities: null, + }); + const previousProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-09-06T00:00:00.000Z", + version: null, + models: [ + model("claude-fable-5-1", "Claude Fable 5.1"), + model("claude-opus-5", "Claude Opus 5"), + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "ready", + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-09-06T00:01:00.000Z", + version: "2.1.261", + models: [model("claude-opus-5", "Claude Opus 5")], + message: "Restricted by your organization: Claude Fable 5.1.", + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + model("claude-opus-5", "Claude Opus 5"), + ]); + + // A check that never reached the account keeps what was known. + const failedRefresh = { + ...refreshedProvider, + status: "error", + auth: { status: "unknown" }, + models: [model("claude-opus-5", "Claude Opus 5")], + } satisfies ServerProvider; + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, failedRefresh).models.map((m) => m.slug), + ["claude-opus-5", "claude-fable-5-1"], + ); + }); + it("preserves previously discovered provider models when a refresh returns none", () => { const previousProvider = { instanceId: ProviderInstanceId.make("cursor"), @@ -2708,6 +2764,89 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("withholds models the organization restricts and says so", () => + Effect.gen(function* () { + for (const apiProvider of ["firstParty", "gateway"]) { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + apiProvider, + restrictedModels: new Set(["claude-fable-5", "claude-fable-5-1"]), + }), + ); + // Dropped from the list the way a model the CLI is too old for is, + // so no picker offers a model that would run as a different one. + const slugs = new Set(status.models.map((model) => model.slug)); + assert.ok(!slugs.has("claude-fable-5")); + assert.ok(!slugs.has("claude-fable-5-1")); + assert.ok(slugs.has("claude-opus-5")); + assert.strictEqual( + status.message, + "Restricted by your organization: Claude Fable 5.1, Claude Fable 5.", + ); + } + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.259\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("keeps custom models listed even when the organization restricts them", () => + Effect.gen(function* () { + // A custom model is the user's own declaration; the CLI reports a + // substitution on it rather than the picker second-guessing it. + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, customModels: ["claude-fable-5"] }, + claudeCapabilities({ + apiProvider: "firstParty", + restrictedModels: new Set(["claude-fable-5"]), + }), + ); + const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); + assert.strictEqual(fable5?.isCustom, true); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.259\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("ignores cached entitlements on a third-party or unnamed backend", () => + Effect.gen(function* () { + // Bedrock models come from the AWS account, so an entitlement cache + // left behind by an earlier claude.ai login must not hide them; a + // handshake that names no backend is just as unknown. + for (const apiProvider of ["bedrock", "vertex", "foundry", undefined]) { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + apiProvider, + restrictedModels: new Set(["claude-fable-5"]), + }), + ); + assert.ok(status.models.some((model) => model.slug === "claude-fable-5")); + assert.strictEqual(status.message, undefined); + } + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.259\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns a display label for claude subscription types", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index a8e6caf95aa7..38039ddc2caf 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -103,12 +103,22 @@ export function upsertProviderWorkspaceSnapshot( const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { const isAntigravity = provider.driver === ProviderDriverKind.make("antigravity"); const isCodex = provider.driver === ProviderDriverKind.make("codex"); - if (!isAntigravity && !isCodex && provider.driver !== ProviderDriverKind.make("opencode")) { + // A Claude health check that reaches the account reports the catalog for + // that CLI version and account; a model it leaves out (too old a CLI, or + // one the organization has not entitled) must not come back from the + // boot-time snapshot, which lists the whole catalog. + const isClaude = provider.driver === ProviderDriverKind.make("claudeAgent"); + if ( + !isAntigravity && + !isCodex && + !isClaude && + provider.driver !== ProviderDriverKind.make("opencode") + ) { return true; } if ( - (isAntigravity || isCodex) && + (isAntigravity || isCodex || isClaude) && (!provider.enabled || provider.auth.status === "unauthenticated") ) { return false; diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index cce194dbd2b4..75d3783696a4 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -61,6 +61,23 @@ Claude Code holds the turn until that window reopens, so it can keep showing as working. Wait for the reset, or stop the turn and continue later. The warning's timestamp shows when the displayed wait started. +## Models your organization restricts + +A Team or Enterprise organization can disallow individual Claude models. When +yours does, those models are left out of the model picker, the same way Claude +Code leaves them out of its own `/model` menu, and the provider's status in +**Settings > Providers** names them. Without this, picking a restricted model +would run the organization's default model while the thread kept the restricted +model's name. + +The entitlements come from Claude Code's config directory, so they apply once +Claude Code has signed in with that account. Until then every model is listed. +Custom models you add yourself are always listed. + +On mobile, an existing thread's saved model can still appear as unavailable. +Choose another model before sending; queued messages using an unavailable model +return to the composer. New tasks skip unavailable saved defaults. + ## Skills Claude skills come from the config directory's `skills` folder and the project's From cece217123d44c012248dadde5e9fdb6ada4e09c Mon Sep 17 00:00:00 2001 From: whoisaldo Date: Wed, 16 Sep 2026 12:57:23 -0400 Subject: [PATCH 2/2] fix(claude): retain model access through failed probes --- apps/mobile/src/lib/modelOptions.test.ts | 67 ++++++++++++------- apps/mobile/src/lib/modelOptions.ts | 11 ++- .../src/provider/Layers/ClaudeProvider.ts | 4 +- .../provider/Layers/ProviderRegistry.test.ts | 42 +++++++++--- .../src/provider/Layers/ProviderRegistry.ts | 16 +++++ 5 files changed, 100 insertions(+), 40 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index eb75fd408769..4e84102e5377 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -218,28 +218,38 @@ describe("mobile model options", () => { auth: { status: "authenticated" }, models: [allowedModel], }; - const config = { providers: [provider] } as unknown as ServerConfig; - - it("keeps a saved restricted model disabled instead of making it selectable again", () => { - expect(buildModelOptions(config, null).map((option) => option.selection.model)).toEqual([ - "claude-opus-5", - ]); - expect(buildModelOptions(config, selection)).toMatchObject([ - { selection: { model: "claude-opus-5" } }, - { selection, isUnavailable: true }, - ]); - expect(isModelSelectionUnavailable(config, selection)).toBe(true); - expect(resolveSelectableModelSelection(config, selection)).toBeNull(); - expect(resolveDefaultableModelSelection(config, selection)).toBeNull(); - expect( - resolveNewTaskModelSelection({ - draftSelection: resolveSelectableModelSelection(config, selection), - projectDefaultSelection: resolveDefaultableModelSelection(config, selection), - stickySelection: resolveDefaultableModelSelection(config, selection), - modelOptions: buildModelOptions(config, null), - }), - ).toMatchObject({ instanceId: selection.instanceId, model: "claude-opus-5" }); - }); + it.each(["ready", "warning", "error"])( + "keeps a saved restricted model disabled while the provider is %s", + (status) => { + const config = { + providers: [ + { + ...provider, + status, + auth: { status: status === "ready" ? "authenticated" : "unknown" }, + }, + ], + } as unknown as ServerConfig; + expect(buildModelOptions(config, null).map((option) => option.selection.model)).toEqual([ + "claude-opus-5", + ]); + expect(buildModelOptions(config, selection)).toMatchObject([ + { selection: { model: "claude-opus-5" } }, + { selection, isUnavailable: true }, + ]); + expect(isModelSelectionUnavailable(config, selection)).toBe(true); + expect(resolveSelectableModelSelection(config, selection)).toBeNull(); + expect(resolveDefaultableModelSelection(config, selection)).toBeNull(); + expect( + resolveNewTaskModelSelection({ + draftSelection: resolveSelectableModelSelection(config, selection), + projectDefaultSelection: resolveDefaultableModelSelection(config, selection), + stickySelection: resolveDefaultableModelSelection(config, selection), + modelOptions: buildModelOptions(config, null), + }), + ).toMatchObject({ instanceId: selection.instanceId, model: "claude-opus-5" }); + }, + ); it("allows the model again when access returns or it is explicitly configured", () => { for (const isCustom of [false, true]) { @@ -261,9 +271,18 @@ describe("mobile model options", () => { } }); - it("does not infer restrictions from a pending or failed probe or an offline environment", () => { + it("does not infer restrictions from an unfiltered probe or an offline environment", () => { for (const status of ["warning", "error"]) { - const unchecked = { providers: [{ ...provider, status }] } as unknown as ServerConfig; + const unchecked = { + providers: [ + { + ...provider, + status, + auth: { status: "unknown" }, + models: [allowedModel, { ...allowedModel, slug: selection.model }], + }, + ], + } as unknown as ServerConfig; expect(isModelSelectionUnavailable(unchecked, selection)).toBe(false); expect(resolveSelectableModelSelection(unchecked, selection)).toBe(selection); } diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 9a0f60978b49..6fe5c9150cc8 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -75,9 +75,14 @@ export function isModelSelectionUnavailable( ); const driver = provider?.driver ?? config.settings?.providerInstances[selection.instanceId]?.driver; - // A completed Claude probe omits version-gated and organization-restricted - // models. A saved selection must not make those models selectable again. - if (provider?.driver === "claudeAgent" && provider.status === "ready") { + // The server retains Claude's inventory through failed refreshes. A saved + // selection must not restore models it omits, even while status is unknown. + if ( + provider?.driver === "claudeAgent" && + provider.enabled && + provider.installed && + provider.auth.status !== "unauthenticated" + ) { return resolveSelectableModel(provider.driver, selection.model, provider.models) === null; } return ( diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 8097605872ef..b39f05d32bdc 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -588,8 +588,8 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { - // Without a probe there is no entitlement list, so nothing is withheld: - // an unknown org is treated as unrestrictive. + // An unknown account starts with the catalog. The registry retains any + // previous inventory when a later probe fails to reach the account. return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index cd6abdf97fd9..e1f851fc232c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -647,17 +647,37 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te model("claude-opus-5", "Claude Opus 5"), ]); - // A check that never reached the account keeps what was known. - const failedRefresh = { - ...refreshedProvider, - status: "error", - auth: { status: "unknown" }, - models: [model("claude-opus-5", "Claude Opus 5")], - } satisfies ServerProvider; - assert.deepStrictEqual( - mergeProviderSnapshot(previousProvider, failedRefresh).models.map((m) => m.slug), - ["claude-opus-5", "claude-fable-5-1"], - ); + const removedCustom = { ...model("old-custom", "Old custom"), isCustom: true }; + const currentCustom = { ...model("new-custom", "New custom"), isCustom: true }; + for (const status of ["warning", "error"] as const) { + // Failed probes carry an unfiltered catalog, not an account inventory. + const failedRefresh = { + ...refreshedProvider, + status, + auth: { status: "unknown" }, + models: [...previousProvider.models, currentCustom], + } satisfies ServerProvider; + const retained = mergeProviderSnapshot( + { ...refreshedProvider, models: [...refreshedProvider.models, removedCustom] }, + failedRefresh, + ); + assert.deepStrictEqual(retained.models, [...refreshedProvider.models, currentCustom]); + assert.deepStrictEqual( + mergeProviderSnapshot(retained, failedRefresh).models, + retained.models, + ); + assert.deepStrictEqual( + mergeProviderSnapshot({ ...refreshedProvider, models: [] }, failedRefresh).models, + [currentCustom], + ); + assert.deepStrictEqual( + mergeProviderSnapshot(retained, { + ...refreshedProvider, + models: previousProvider.models, + }).models, + previousProvider.models, + ); + } }); it("preserves previously discovered provider models when a refresh returns none", () => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 38039ddc2caf..edb9dc7ca88f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -151,6 +151,22 @@ const mergeProviderModels = ( // the user and must not be resurrected from the previous snapshot. const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); + // Incomplete Claude probes carry the unfiltered catalog. Keep the last + // inventory, including its omissions, while applying current custom models. + if ( + provider.driver === "claudeAgent" && + provider.enabled && + provider.auth.status === "unknown" && + provider.status !== "ready" + ) { + const customModels = nextModels.filter((model) => model.isCustom); + const customSlugs = new Set(customModels.map((model) => model.slug)); + return [ + ...retainablePreviousModels.filter((model) => !customSlugs.has(model.slug)), + ...customModels, + ]; + } + if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { return retainablePreviousModels; }