diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 75ea0162ca55..19d3562d3855 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -98,14 +98,20 @@ function ModelRow(props: { readonly isFirst: boolean; readonly isLast: boolean; }) { + const restricted = props.option.unavailableReason !== null; + // The reason replaces the subtitle: it is the one thing that explains why + // the row can't be picked, and rows only have space for a single detail line. + const detail = props.option.unavailableReason ?? props.option.subtitle; return ( ) : null} - {props.option.subtitle ? ( + {detail ? ( - {props.option.subtitle} + {detail} ) : null} diff --git a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts index 5c6e25f43785..596decd7f7b3 100644 --- a/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts +++ b/apps/mobile/src/features/threads/thread-settings-sheet-state.test.ts @@ -18,6 +18,7 @@ function modelOption( providerDriver: "codex", isDefault: false, isLegacy: false, + unavailableReason: null, capabilities: null, selection: { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 26ffd6855582..fe2955b36729 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -17,6 +17,13 @@ export type ModelOption = { readonly providerDriver: string; readonly isDefault: boolean; readonly isLegacy: boolean; + /** + * Why this environment can't run the model (today: not entitled by the + * Claude account's organization), or `null` when it can. Picking a + * restricted model silently runs the org default instead, so rows carrying + * a reason are shown disabled. + */ + readonly unavailableReason: string | null; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -143,6 +150,7 @@ export function buildModelOptions( providerDriver: provider.driver, isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, + unavailableReason: model.unavailableReason ?? null, capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -174,6 +182,7 @@ export function buildModelOptions( providerDriver: fallbackModelSelection.instanceId, isDefault: false, isLegacy: false, + unavailableReason: null, capabilities: null, selection: fallbackModelSelection, }); diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 0409b7c691b1..602aaecae4ba 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -162,6 +162,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..dbd8e8ca2a32 --- /dev/null +++ b/apps/server/src/provider/Drivers/ClaudeEntitlements.test.ts @@ -0,0 +1,111 @@ +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"); + yield* writeClaudeConfig( + configDir, + JSON.stringify({ + modelAccessCache: [ + { apiName: "claude-fable-5", entitled: false }, + { apiName: "claude-opus-5", entitled: true }, + { apiName: "claude-sonnet-5", entitled: true }, + { apiName: "claude-opus-4-8", entitled: true }, + ], + }), + ); + + const restricted = yield* readClaudeRestrictedModels({ homePath: configDir }); + + assert.deepEqual([...restricted], ["claude-fable-5"]); + }), + ); + + it.effect("reads the config beside a CLAUDE_CONFIG_DIR from the environment", () => + Effect.gen(function* () { + const configDir = yield* makeConfigDir("ambient-home"); + yield* writeClaudeConfig( + configDir, + JSON.stringify({ modelAccessCache: [{ apiName: "claude-fable-5", entitled: false }] }), + ); + + const restricted = yield* readClaudeRestrictedModels( + { homePath: "" }, + { CLAUDE_CONFIG_DIR: configDir }, + ); + + assert.deepEqual([...restricted], ["claude-fable-5"]); + }), + ); + + it.effect("restricts nothing when the config is missing", () => + Effect.gen(function* () { + const configDir = yield* makeConfigDir("absent-home"); + + const restricted = yield* readClaudeRestrictedModels({ homePath: configDir }); + + assert.deepEqual([...restricted], []); + }), + ); + + it.effect("restricts nothing when the config or its cache is malformed", () => + Effect.gen(function* () { + const brokenJson = yield* makeConfigDir("broken-json"); + yield* writeClaudeConfig(brokenJson, "{ not json"); + assert.deepEqual([...(yield* readClaudeRestrictedModels({ homePath: brokenJson }))], []); + + const brokenCache = yield* makeConfigDir("broken-cache"); + yield* writeClaudeConfig( + brokenCache, + JSON.stringify({ modelAccessCache: { "claude-fable-5": false } }), + ); + assert.deepEqual([...(yield* readClaudeRestrictedModels({ homePath: 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, + JSON.stringify({ + modelAccessCache: [ + null, + "claude-fable-5", + { entitled: false }, + { apiName: " ", entitled: false }, + // Only an explicit `false` restricts: an absent verdict is unknown, + // not disallowed. + { apiName: "claude-opus-5" }, + { apiName: "claude-sonnet-4-6", entitled: false }, + ], + }), + ); + + const restricted = yield* readClaudeRestrictedModels({ homePath: configDir }); + + 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..8d1242a7bbda --- /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 config file under + * `modelAccessCache`, the same list its own `/model` picker greys rows out + * 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 init handshake is not a usable substitute here: its model + * catalog is the CLI's curated picker list, so a model can be missing from it + * and still run (`claude-opus-4-8` is absent yet answers normally). Absence + * therefore cannot be read as "disallowed", while `entitled: false` can. + * + * 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 type { ClaudeSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +/** + * Resolve the `.claude.json` the spawned CLI would read, matching the + * precedence in {@link makeClaudeEnvironment}: the instance's `homePath` + * (exported as `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the + * process environment, then `~/.claude.json`. + * + * Note this is the config *file* beside the `.claude` directory, not inside + * it, so it does not share `ClaudeSkills`' config-dir resolution. + */ +const resolveClaudeConfigFilePath = Effect.fn("resolveClaudeConfigFilePath")(function* ( + config: Pick, + environment: NodeJS.ProcessEnv, + cwd?: string, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + if (homePath.length > 0) { + return path.join(path.resolve(expandHomePath(homePath)), ".claude.json"); + } + // No tilde expansion: the spawned CLI receives this env var verbatim, so a + // literal `~` must stay literal to land on the same file the runtime reads. + const environmentConfigDir = environment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (environmentConfigDir.length > 0) { + const resolved = cwd + ? path.resolve(cwd, environmentConfigDir) + : path.resolve(environmentConfigDir); + return path.join(resolved, ".claude.json"); + } + return path.join(NodeOS.homedir(), ".claude.json"); +}); + +/** + * Model ids the organization has explicitly disallowed, as API model ids + * (`claude-fable-5`). 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* ( + config: Pick, + environment?: NodeJS.ProcessEnv, + cwd?: string, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const configFilePath = yield* resolveClaudeConfigFilePath( + config, + environment ?? process.env, + cwd, + ); + + const contents = yield* fileSystem + .readFileString(configFilePath) + .pipe(Effect.orElseSucceed(() => "")); + if (contents.length === 0) return new Set(); + + const parsed = yield* Effect.try(() => JSON.parse(contents) as unknown).pipe( + Effect.orElseSucceed(() => undefined), + ); + const modelAccessCache = (parsed as { readonly modelAccessCache?: unknown } | undefined) + ?.modelAccessCache; + if (!Array.isArray(modelAccessCache)) return new Set(); + + const restricted = new Set(); + for (const entry of modelAccessCache) { + if (typeof entry !== "object" || entry === null) continue; + const { apiName, entitled } = entry as { + readonly apiName?: unknown; + readonly entitled?: unknown; + }; + if (entitled !== false || typeof apiName !== "string") continue; + const normalized = apiName.trim(); + if (normalized.length > 0) restricted.add(normalized); + } + return restricted; +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 2f0efeac5f53..ea7a20bd43cb 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2610,6 +2610,14 @@ describe("ClaudeAdapterLive", () => { { type: "system", subtype: "plugin_install", session_id: "session", uuid: "pi" }, { type: "system", subtype: "memory_recall", session_id: "session", uuid: "mr" }, { type: "system", subtype: "elicitation_complete", session_id: "session", uuid: "ec" }, + { + type: "system", + subtype: "informational", + content: "Transcript-only note.", + level: "info", + session_id: "session", + uuid: "info-quiet", + }, { type: "prompt_suggestion", suggestion: "try this", session_id: "session", uuid: "ps" }, { type: "system", @@ -2633,6 +2641,17 @@ describe("ClaudeAdapterLive", () => { session_id: "session", uuid: "notif-high", } as unknown as SDKMessage); + // Warning-level informational notices surface too: this is how the CLI + // reports that an org-restricted model was swapped for another one. + harness.query.emit({ + type: "system", + subtype: "informational", + content: + 'Model "claude-fable-5" is restricted by your organization\'s settings. Using claude-opus-5[1m] instead.', + level: "warning", + session_id: "session", + uuid: "info-warning", + } as unknown as SDKMessage); // session_state_changed maps to the matching session states. for (const [state, uuid] of [ ["running", "ssc-run"], @@ -2663,10 +2682,15 @@ describe("ClaudeAdapterLive", () => { yield* Effect.yieldNow; const warnings = runtimeEvents.filter((event) => event.type === "runtime.warning"); - // Exactly one warning: the high-priority notification. Nothing else. + // Only the high-priority notification and the warning-level + // informational. Nothing else, and neither quiet informational nor any + // undeclared subtype leaks through as an unknown-subtype row. assert.deepEqual( warnings.map((event) => event.payload.message), - ["context window nearly full"], + [ + "context window nearly full", + 'Model "claude-fable-5" is restricted by your organization\'s settings. Using claude-opus-5[1m] instead.', + ], ); const sessionStates = runtimeEvents .filter((event) => event.type === "session.state.changed") diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6989378d8287..65a802dc6876 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -3130,6 +3130,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } + // `informational` is another real-but-undeclared subtype. Its warning level + // carries notices the user has to see to understand the turn — notably the + // org-restricted model substitution ("Model "…" is restricted by your + // organization's settings. Using … instead."), which decides which model + // actually answered. Without a case it reached the unknown-subtype branch + // and surfaced as an error row that named no model. Quieter levels are + // transcript and footer chrome, so they stay consumed. + if ((message.subtype as string) === "informational") { + const informational = message as unknown as { + readonly content?: unknown; + readonly level?: unknown; + }; + const content = typeof informational.content === "string" ? informational.content.trim() : ""; + if (informational.level === "warning" && content.length > 0) { + yield* emitRuntimeWarning(context, content, message); + } + return; + } + switch (message.subtype) { case "init": yield* offerRuntimeEvent({ diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index 7e5fa2611f0f..f3635e0c8a1f 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -49,6 +49,19 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { const invocationPath = path.join(tempDir, "invocation.json"); const workspaceCwd = path.join(tempDir, "workspace"); yield* fs.makeDirectory(workspaceCwd, { recursive: true }); + // 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"), + JSON.stringify({ + modelAccessCache: [ + { apiName: "claude-fable-5", entitled: false }, + { apiName: "claude-opus-5", entitled: true }, + ], + }), + ); yield* fs.writeFileString( executablePath, @@ -102,6 +115,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, ); @@ -118,6 +132,7 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { input: { hint: "[path]" }, }, ], + restrictedModels: new Set(["claude-fable-5"]), }); // @effect-diagnostics-next-line preferSchemaOverJson:off diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index f815ac75be34..0d857f71c4ef 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -40,6 +40,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"; @@ -361,6 +362,25 @@ function getBuiltInClaudeModelsForVersion( }); } +/** + * Shown on models the organization has not entitled. Claude Code silently + * substitutes the org default for these, so the picker marks them unselectable + * instead of letting a pick land on a different model than the label promises. + */ +const ORG_RESTRICTED_MODEL_REASON = "Restricted by your organization."; + +function applyClaudeModelRestrictions( + models: ReadonlyArray, + restrictedModels: ReadonlySet, +): ReadonlyArray { + if (restrictedModels.size === 0) return models; + return models.map((model) => + restrictedModels.has(model.slug) + ? { ...model, unavailableReason: ORG_RESTRICTED_MODEL_REASON } + : model, + ); +} + function formatClaudeOpus5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; @@ -636,6 +656,12 @@ type ClaudeCapabilitiesProbe = { */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + /** + * API model ids the account's organization has disallowed. 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; }; function parseClaudeInitializationCommands( @@ -735,6 +761,7 @@ const probeClaudeCapabilities = ( claudeSettings.binaryPath, claudeEnvironment, ); + const restrictedModels = yield* readClaudeRestrictedModels(claudeSettings, environment, cwd); return yield* Effect.tryPromise(async () => { const q = claudeQuery({ // Never yield — we only need initialization data, not a conversation. @@ -765,6 +792,7 @@ const probeClaudeCapabilities = ( tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), + restrictedModels, } satisfies ClaudeCapabilitiesProbe; }); }).pipe( @@ -902,7 +930,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } - const models = providerModelsFromSettings( + const versionedModels = providerModelsFromSettings( getBuiltInClaudeModelsForVersion(parsedVersion), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, @@ -931,11 +959,13 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const dedupedSlashCommands = dedupeSlashCommands(slashCommands); if (!capabilities) { + // Without a probe there is no entitlement list, so nothing is marked + // restricted: an unknown org is treated as unrestrictive. return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, checkedAt, - models, + models: versionedModels, slashCommands: dedupedSlashCommands, skills, probe: { @@ -948,6 +978,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const models = applyClaudeModelRestrictions(versionedModels, capabilities.restrictedModels); const authMetadata = claudeAuthMetadata({ subscriptionType: capabilities.subscriptionType, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb336e61b21c..b26f77edb973 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -134,6 +134,7 @@ type TestClaudeCapabilities = { readonly tokenSource: string | undefined; readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + readonly restrictedModels: ReadonlySet; }; function claudeCapabilities(overrides: Partial = {}) { @@ -144,6 +145,7 @@ function claudeCapabilities(overrides: Partial = {}) { tokenSource: undefined, apiProvider: undefined, slashCommands: [], + restrictedModels: new Set(), ...overrides, }); } @@ -2132,6 +2134,35 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("marks models the organization restricts as unavailable", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ restrictedModels: new Set(["claude-fable-5"]) }), + ); + // Still listed, so the picker can explain why it can't be used — + // hiding it would just relocate the surprise. + const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); + assert.strictEqual(fable5?.unavailableReason, "Restricted by your organization."); + const opus5 = status.models.find((model) => model.slug === "claude-opus-5"); + assert.strictEqual(opus5?.unavailableReason, undefined); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides Claude Fable 5 on older Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/web/src/components/chat/ModelPickerContent.test.ts b/apps/web/src/components/chat/ModelPickerContent.test.ts index cbee3b6b16e5..f3a5fb353a1c 100644 --- a/apps/web/src/components/chat/ModelPickerContent.test.ts +++ b/apps/web/src/components/chat/ModelPickerContent.test.ts @@ -2,7 +2,10 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { describe, expect, it } from "vite-plus/test"; import { deriveProviderInstanceEntries } from "../../providerInstances"; -import { shouldIncludeModelPickerOption } from "./ModelPickerContent"; +import { + resolveModelPickerDisabledReason, + shouldIncludeModelPickerOption, +} from "./ModelPickerContent"; function entry(status: ServerProvider["status"]) { return deriveProviderInstanceEntries([ @@ -65,3 +68,32 @@ describe("shouldIncludeModelPickerOption", () => { }, ); }); + +describe("resolveModelPickerDisabledReason", () => { + it("disables an unrunnable model without a caller-supplied reason", () => { + // The settings pickers never pass getModelDisabledReason, so an + // org-restricted model has to disable itself or it stays selectable there. + expect( + resolveModelPickerDisabledReason( + { unavailableReason: "Restricted by your organization." }, + undefined, + ), + ).toBe("Restricted by your organization."); + }); + + it("keeps caller-supplied reasons for runnable models", () => { + expect(resolveModelPickerDisabledReason({}, "Start a new thread to use this model.")).toBe( + "Start a new thread to use this model.", + ); + expect(resolveModelPickerDisabledReason(undefined, null)).toBeNull(); + }); + + it("prefers the model's own reason over the caller's", () => { + expect( + resolveModelPickerDisabledReason( + { unavailableReason: "Restricted by your organization." }, + "Start a new thread to use this model.", + ), + ).toBe("Restricted by your organization."); + }); +}); diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index a8ebac4f4e6a..625b30f56b14 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -54,6 +54,7 @@ type ModelPickerItem = { continuationGroupKey?: string | undefined; isLegacy?: boolean | undefined; isUnavailable?: boolean | undefined; + unavailableReason?: string | undefined; }; export function shouldIncludeModelPickerOption(input: { @@ -72,6 +73,18 @@ export function shouldIncludeModelPickerOption(input: { ); } +/** + * A model the environment can't run states its own reason, so every picker + * disables it whether or not the call site passes `getModelDisabledReason`. + * Caller-supplied reasons (thread state) apply on top of that. + */ +export function resolveModelPickerDisabledReason( + option: Pick | undefined, + callerReason: string | null | undefined, +): string | null { + return option?.unavailableReason ?? callerReason ?? null; +} + const EMPTY_MODEL_JUMP_LABELS = new Map(); function ModelListSeparator() { @@ -258,6 +271,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ...(model.subProvider ? { subProvider: model.subProvider } : {}), ...(model.isLegacy ? { isLegacy: true } : {}), ...(model.isUnavailable ? { isUnavailable: true } : {}), + ...(model.unavailableReason ? { unavailableReason: model.unavailableReason } : {}), instanceId, driverKind: entry.driverKind, instanceDisplayName: entry.displayName, @@ -452,9 +466,25 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }); }, []); + // An unrunnable model carries its own reason, so every picker disables it + // without each call site having to opt in. Caller-supplied reasons (thread + // state) still apply on top. + const resolveDisabledReason = useCallback( + (instanceId: ProviderInstanceId, modelSlug: string): string | null => { + const option = modelOptionsByInstance + .get(instanceId) + ?.find((candidate) => candidate.slug === modelSlug); + return resolveModelPickerDisabledReason( + option, + getModelDisabledReason?.(instanceId, modelSlug), + ); + }, + [getModelDisabledReason, modelOptionsByInstance], + ); + const handleModelSelect = useCallback( (modelSlug: string, instanceId: ProviderInstanceId) => { - if (getModelDisabledReason?.(instanceId, modelSlug)) { + if (resolveDisabledReason(instanceId, modelSlug)) { return; } const options = modelOptionsByInstance.get(instanceId); @@ -473,7 +503,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { onInstanceModelChange(instanceId, resolvedModel); } }, - [entryByInstanceId, getModelDisabledReason, modelOptionsByInstance, onInstanceModelChange], + [entryByInstanceId, modelOptionsByInstance, onInstanceModelChange, resolveDisabledReason], ); const toggleFavorite = useCallback( @@ -497,7 +527,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { >(); let selectableModelIndex = 0; for (const model of visibleModels) { - if (getModelDisabledReason?.(model.instanceId, model.slug)) { + if ( + resolveModelPickerDisabledReason( + model, + getModelDisabledReason?.(model.instanceId, model.slug), + ) + ) { continue; } const jumpCommand = modelPickerJumpCommandForIndex(selectableModelIndex); @@ -784,8 +819,10 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!model) { return null; } - const disabledReason = - getModelDisabledReason?.(model.instanceId, model.slug) ?? null; + const disabledReason = resolveModelPickerDisabledReason( + model, + getModelDisabledReason?.(model.instanceId, model.slug), + ); return (