From 5cc82dfa4d0ecf8147901601fea69fd625208822 Mon Sep 17 00:00:00 2001 From: whoisaldo Date: Sun, 6 Sep 2026 02:11:06 -0400 Subject: [PATCH] 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. --- .../src/provider/Drivers/ClaudeDriver.ts | 1 + .../Drivers/ClaudeEntitlements.test.ts | 138 ++++++++++++++++++ .../provider/Drivers/ClaudeEntitlements.ts | 105 +++++++++++++ .../Layers/ClaudeCapabilitiesProbe.test.ts | 15 ++ .../src/provider/Layers/ClaudeProvider.ts | 90 ++++++++++-- .../provider/Layers/ProviderRegistry.test.ts | 137 +++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 14 +- docs/user/providers-claude.md | 13 ++ 8 files changed, 502 insertions(+), 11 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/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..69febdf96c2b --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts @@ -0,0 +1,138 @@ +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("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" }, { 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..073fb70193e2 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeEntitlements.ts @@ -0,0 +1,105 @@ +/** + * 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 configDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + const home = environment.HOME?.trim() ?? ""; + const root = configDir.length > 0 ? configDir : home.length > 0 ? 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..fa8046538e9b 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, @@ -141,6 +154,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 +171,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { input: { hint: "[path]" }, }, ], + restrictedModels: new Set(["claude-fable-5"]), usage: { rate_limits_available: true, rate_limits: { five_hour: { utilization: 12, resets_at: "2026-07-18T14:39:00Z" } }, diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index e3d2c6ab565d..6183934e511f 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"; @@ -162,6 +164,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 @@ -234,6 +277,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 @@ -339,6 +389,7 @@ const probeClaudeCapabilities = ( claudeSettings.binaryPath, claudeEnvironment, ); + const restrictedModels = yield* readClaudeRestrictedModels(claudeEnvironment); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -355,11 +406,11 @@ const probeClaudeCapabilities = ( }), }); const init = await q.initializationResult(); - return { q, init }; + 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(() => @@ -385,6 +436,7 @@ const probeClaudeCapabilities = ( tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), + restrictedModels, ...(usage ? { usage } : {}), } satisfies ClaudeCapabilitiesProbe; }), @@ -522,11 +574,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 @@ -537,11 +585,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: { @@ -554,6 +608,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, @@ -583,7 +655,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 988c89e1e679..f5764afdcb27 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, }); } @@ -589,6 +591,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"), @@ -2695,6 +2751,87 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("withholds models the organization restricts and says so", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ + apiProvider: "firstParty", + 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", 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 b43b58e52627..432b8a25f18d 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -57,6 +57,19 @@ 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. + ## Skills Claude skills come from the config directory's `skills` folder and the project's