From ca802aaade5103e4b54830ad56fd7d776c764bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Maluk?= Date: Tue, 3 Nov 2026 00:42:25 -0300 Subject: [PATCH 1/6] refactor: look up selected models in the full catalog, offer only visible ones Model options now come from one per-instance catalog that keeps hidden models flagged isHidden. Pickers and new choices filter hidden entries out, while the selected model's label, badge and capabilities are read from the catalog, so a saved hidden selection still renders accurately. Mobile gets the same lookup through getSelectedModelOption, matched by instance and model. Only built-in models can be hidden at this point, so resolution is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/components/ComposerModelControl.tsx | 3 +- .../settings/ScheduledTaskPickerScreens.tsx | 8 +- .../SettingsScheduledTasksRouteScreen.tsx | 11 +-- .../src/features/threads/ThreadComposer.tsx | 14 +-- .../features/threads/ThreadSettingsSheet.tsx | 30 ++++-- .../threads/new-task-flow-provider.tsx | 13 ++- apps/mobile/src/lib/modelOptions.test.ts | 8 ++ apps/mobile/src/lib/modelOptions.ts | 15 +++ apps/web/src/components/chat/ChatComposer.tsx | 4 +- .../components/chat/ModelPickerContent.tsx | 10 +- .../chat/ProviderModelPicker.browser.test.tsx | 7 +- .../components/chat/ProviderModelPicker.tsx | 14 ++- .../src/components/chat/providerIconUtils.ts | 1 + apps/web/src/modelSelection.test.ts | 13 ++- apps/web/src/modelSelection.ts | 96 +++++++++---------- 15 files changed, 150 insertions(+), 97 deletions(-) diff --git a/apps/mobile/src/components/ComposerModelControl.tsx b/apps/mobile/src/components/ComposerModelControl.tsx index e422cfabd4591..92309251ec33e 100644 --- a/apps/mobile/src/components/ComposerModelControl.tsx +++ b/apps/mobile/src/components/ComposerModelControl.tsx @@ -43,6 +43,7 @@ export function ComposerModelControl(props: { accessibilityLabel={[ "Model and reasoning settings", label, + props.modelOption?.isHidden ? "Hidden from picker" : null, badge?.showBadge ? badge.displayName : null, summary, ] @@ -70,7 +71,7 @@ export function ComposerModelControl(props: { /> ) } - label={props.modelOption?.shortName ?? label} + label={`${props.modelOption?.shortName ?? label}${props.modelOption?.isHidden ? " (Hidden)" : ""}`} iconOnly={props.iconOnly} suffix={ summary ? ( diff --git a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx index aec2b1a0eeb3c..8d8c118601ce3 100644 --- a/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx +++ b/apps/mobile/src/features/settings/ScheduledTaskPickerScreens.tsx @@ -3,7 +3,7 @@ import * as Haptics from "expo-haptics"; import { useMemo, useState } from "react"; import { AppText as Text } from "../../components/ui/AppText"; -import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { buildModelOptions, getSelectedModelOption, groupByProvider } from "../../lib/modelOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; import { useDebouncedValue, usePaginatedBranches } from "../../state/queries"; @@ -31,11 +31,7 @@ export function ScheduledTaskModelPickerRouteScreen() { [config, selectedModel, projectId], ); const providerGroups = useMemo(() => groupByProvider(models), [models]); - const selectedOption = models.find( - (option) => - option.selection.instanceId === selectedModel?.instanceId && - option.selection.model === selectedModel.model, - ); + const selectedOption = getSelectedModelOption(config, selectedModel, projectId); const optionDescriptors = useMemo( () => resolveProviderOptionDescriptors({ diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index 0832ee3945056..ddefd03c16e7c 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -56,7 +56,7 @@ import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { usePreparedConnection } from "../../state/session"; -import { buildModelOptions } from "../../lib/modelOptions"; +import { buildModelOptions, getSelectedModelOption } from "../../lib/modelOptions"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects, useEnvironmentServerConfig } from "../../state/entities"; import { useEnvironmentQuery } from "../../state/query"; @@ -693,6 +693,7 @@ function TaskForm({ .sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null)); const config = useEnvironmentServerConfig(environmentId); const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); + const selectedModelOption = getSelectedModelOption(config, draft.modelSelection, draft.projectId); // Existing webhook tasks keep the option so they can still switch to a timer. const webhookDenial = managedCapabilityDenial(config?.managedPolicy, "webhookTasks"); const canOperate = useAtomValue( @@ -919,11 +920,9 @@ function TaskForm({ label="Model" borderTop value={ - modelOptions.find( - (option) => - option.selection.instanceId === draft.modelSelection?.instanceId && - option.selection.model === draft.modelSelection?.model, - )?.label ?? + (selectedModelOption + ? `${selectedModelOption.label}${selectedModelOption.isHidden ? " (Hidden)" : ""}` + : null) ?? draft.modelSelection?.model ?? (modelOptions.length ? "Choose model" : "No models available") } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index ea2928f531374..3698bb5d70c6c 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -86,7 +86,7 @@ import { composerStripAttachments, type DraftComposerFileAttachment, } from "../../lib/composerImages"; -import { buildModelOptions, isModelSelectionUnavailable } from "../../lib/modelOptions"; +import { getSelectedModelOption, isModelSelectionUnavailable } from "../../lib/modelOptions"; import { useScaledTextRole, type ScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; @@ -703,21 +703,11 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps }); }; - const lockedProviderInstanceId = props.canSwitchProvider - ? undefined - : currentModelSelection.instanceId; - const modelOptions = buildModelOptions( + const currentModelOption = getSelectedModelOption( props.serverConfig, currentModelSelection, - lockedProviderInstanceId, props.selectedThread.projectId, ); - const currentModelOption = - modelOptions.find( - (option) => - option.selection.instanceId === currentModelSelection.instanceId && - option.selection.model === currentModelSelection.model, - ) ?? null; const openSettings = () => { settingsSheetPresentation.open(); }; diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index ce4a8a8833b8f..55c1fa416c1e5 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -68,6 +68,7 @@ import { cn } from "../../lib/cn"; import { useReduceMotionEnabled } from "../../lib/appState"; import { buildModelOptions, + getSelectedModelOption, groupByProvider, type ModelOption, type ProviderGroup, @@ -311,6 +312,7 @@ type ThreadSettingsSessionValue = { readonly displayedDescriptors: ReadonlyArray; readonly promptTraits: ReturnType; readonly displayedModelSelection: ModelSelection | null; + readonly hiddenModelLabel: string | null; readonly reportedModelSelection: ModelSelection | null; readonly providerExpansionOverrides: ReadonlySet; readonly hasLegacyModels: boolean; @@ -512,6 +514,7 @@ function ThreadSettingsSessionProvider( const displayedModel = pendingModel ?? props.providerGroups.flatMap((group) => group.models).find((option) => isApplied(option)) ?? + getSelectedModelOption(config, props.selectedModel, props.projectId) ?? null; const runtimeModeChoices = withManagedRuntimeModeLimits( runtimeModeChoicesForSupportedModes(displayedModel?.supportedRuntimeModes), @@ -592,6 +595,7 @@ function ThreadSettingsSessionProvider( }; const pressModel = (option: ModelOption) => { + if (option.isHidden) return; void Haptics.selectionAsync(); if (isApplied(option) || option.key !== pendingModel?.key) { setPendingPrompt(null); @@ -620,6 +624,15 @@ function ThreadSettingsSessionProvider( displayedDescriptors, promptTraits, displayedModelSelection: pendingModel?.selection ?? props.selectedModel, + hiddenModelLabel: + !pendingModel && + props.selectedModel && + settings?.providerModelPreferences?.[props.selectedModel.instanceId]?.hiddenModels.includes( + props.selectedModel.model, + ) + ? (getSelectedModelOption(config, props.selectedModel, props.projectId)?.label ?? + props.selectedModel.model) + : null, reportedModelSelection: pendingModel ? null : (props.reportedModelSelection ?? null), favoriteKeys, favoritesLoaded, @@ -1030,6 +1043,12 @@ function ThreadSettingsMainContent(props: { maintainVisibleContentPosition={THREAD_SETTINGS_MAINTAIN_VISIBLE_CONTENT_POSITION} ListHeaderComponent={ <> + {session.hiddenModelLabel ? ( + + {session.hiddenModelLabel} is hidden from the picker. Your saved selection still uses + it. + + ) : null} {Platform.OS === "android" ? ( - option.selection.instanceId === modelSelection.instanceId && - option.selection.model === modelSelection.model, - ) ?? null; + const currentModelOption = getSelectedModelOption( + serverConfig, + modelSelection, + selectedThread.projectId, + ); const updateModelSelection = (selection: ModelSelection) => updateThreadDraftModelSelection(threadKey, selection, serverConfig?.providers ?? []); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 3876fd2f83dbb..4b73408c80b45 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -29,6 +29,7 @@ import type { DraftComposerAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { buildModelOptions, + getSelectedModelOption, groupByProvider, resolveDefaultableModelSelection, resolveNewTaskModelSelection, @@ -471,13 +472,11 @@ export function NewTaskFlowProvider( ? `${selectedModel.instanceId}:${selectedModel.model}` : null; - const selectedModelOption = - modelOptions.find( - (option) => - selectedModel && - option.selection.instanceId === selectedModel.instanceId && - option.selection.model === selectedModel.model, - ) ?? null; + const selectedModelOption = getSelectedModelOption( + selectedEnvironmentServerConfig, + selectedModel, + selectedProject?.id ?? null, + ); const selectedProviderStatus = selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index bf03fed351076..d72685bf4dc75 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -14,6 +14,7 @@ import { buildModelOptions, describeUnavailableModelSelection, groupByProvider, + getSelectedModelOption, isModelSelectionUnavailable, resolveDefaultableModelSelection, resolveNewTaskModelSelection, @@ -186,6 +187,13 @@ describe("mobile model options", () => { (option) => option.providerKey, ), ).toEqual(["codex", "codex_work"]); + expect( + getSelectedModelOption( + config, + { instanceId: ProviderInstanceId.make("codex_work"), model: "test" }, + null, + )?.providerKey, + ).toBe("codex_work"); }); it("matches the web driver fallback without moving unknown Antigravity history", () => { diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 7771c738042d3..07cd296e7cebd 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -458,6 +458,21 @@ export function buildModelOptions( return [...options.values()]; } +export function getSelectedModelOption( + config: T3ServerConfig | null | undefined, + selection: ModelSelection | null, + projectId: ProjectId | null, +): ModelOption | null { + if (!selection) return null; + return ( + buildModelOptions(config, selection, selection.instanceId, projectId, true).find( + (option) => + option.selection.instanceId === selection.instanceId && + option.selection.model === selection.model, + ) ?? null + ); +} + export function groupByProvider(options: ReadonlyArray): ReadonlyArray { const groups = new Map< string, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9ad9158cd4da3..6cb49ad378abd 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -237,7 +237,7 @@ import { Button } from "../ui/button"; import { useManagedPolicy } from "../../state/managedPolicy"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; import { hasProviderSetup } from "./ProviderStatusBanner"; -import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; +import { type AppModelOption, getAppModelCatalogForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; import { isVideoAttachment, type SessionPhase, type Thread, type ThreadShell } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; @@ -978,7 +978,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) for (const entry of providerInstanceEntries) { modelOptionsByInstance.set( entry.instanceId, - getAppModelOptionsForInstance( + getAppModelCatalogForInstance( settings, entry, entry.instanceId === selectedInstanceId ? selectedModelForPicker : null, diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index f040df764cda9..28b0b58fa5455 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -89,7 +89,8 @@ export function resolveModelPickerSelectedModel(input: { }) { if (input.driverKind === "antigravity" && input.model === ANTIGRAVITY_DEFAULT_MODEL) { const availableModels = input.options.filter( - (option) => option.slug !== ANTIGRAVITY_DEFAULT_MODEL && !option.isUnavailable, + (option) => + option.slug !== ANTIGRAVITY_DEFAULT_MODEL && !option.isUnavailable && !option.isHidden, ); return ( availableModels.find((option) => option.aliases?.includes(ANTIGRAVITY_DEFAULT_MODEL)) ?? @@ -105,6 +106,7 @@ function shouldIncludeModelPickerOption(input: { readonly activeInstanceId: ProviderInstanceId; readonly activeModel: string; }): boolean { + if (input.option.isHidden) return false; if (input.entry.driverKind === "antigravity" && input.option.slug === ANTIGRAVITY_DEFAULT_MODEL) { return false; } @@ -496,7 +498,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!entry) { return null; } - const resolvedModel = resolveSelectableModel(entry.driverKind, modelSlug, options); + const resolvedModel = resolveSelectableModel( + entry.driverKind, + modelSlug, + options.filter((option) => !option.isHidden), + ); if (!resolvedModel) return null; if (additive && onToggleModel) { onToggleModel(instanceId, resolvedModel); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx index b6dd1a38e86cf..6a0feb9f29e65 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx @@ -110,7 +110,7 @@ it("keeps a docked picker open after a pick and hands focus to that model's effo [ instanceId, [ - { slug: "original", name: "Original model" }, + { slug: "original", name: "Original model", isHidden: true }, { slug: "second", name: "Second model" }, { slug: "plain", name: "Plain model" }, ], @@ -152,7 +152,12 @@ it("keeps a docked picker open after a pick and hands focus to that model's effo } await using screen = await render(); + await expect.element(screen.getByText("Original model", { exact: true })).toBeVisible(); + await expect.element(screen.getByText("Hidden", { exact: true })).toBeVisible(); await screen.getByRole("button", { name: "Choose model" }).click(); + await expect + .element(page.getByRole("option", { name: /Original model/ })) + .not.toBeInTheDocument(); const search = page.getByPlaceholder("Search models..."); await expect.element(search).toHaveFocus(); await userEvent.keyboard("{ArrowRight}"); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 2e47b4c2fe3b4..ef1d28c97fa1a 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -135,14 +135,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { }) ?? (activeEntry?.driverKind === "opencode" || activeEntry?.driverKind === "antigravity" ? undefined - : selectedInstanceOptions[0]); + : selectedInstanceOptions.find((option) => !option.isHidden)); const triggerTitle = selectedModel ? getTriggerDisplayModelName(selectedModel, activeEntry?.driverKind) : props.model === ANTIGRAVITY_DEFAULT_MODEL ? "Choose model" : props.model || "Choose model"; const triggerLabel = selectedModel - ? `${getDisplayModelName(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}` + ? `${getDisplayModelName(selectedModel)}${selectedModel.isUnavailable ? " (Unavailable)" : ""}${selectedModel.isHidden ? " (Hidden from picker)" : ""}` : triggerTitle; const showInstanceBadge = activeEntry !== null && shouldShowInstanceBadge(activeEntry, props.instanceEntries); @@ -208,13 +208,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { options: props.modelOptionsByInstance.get(selection.instanceId) ?? [], }); const unavailable = model?.isUnavailable ? " (Unavailable)" : ""; + const hidden = model?.isHidden ? " (Hidden)" : ""; return { ...selection, entry, label: model - ? `${getTriggerDisplayModelName(model, entry?.driverKind)}${unavailable}` + ? `${getTriggerDisplayModelName(model, entry?.driverKind)}${unavailable}${hidden}` : selection.model, - fullLabel: model ? `${getDisplayModelName(model)}${unavailable}` : selection.model, + fullLabel: model ? `${getDisplayModelName(model)}${unavailable}${hidden}` : selection.model, }; }); const multipleLabel = selectedEntries @@ -396,6 +397,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { )} {props.triggerSuffix} + {selectedModel?.isHidden && !selectedEntries && props.triggerLabel === undefined ? ( + + Hidden + + ) : null} {selectedModel?.isUnavailable && !selectedEntries && props.triggerLabel === undefined ? ( Unavailable diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 565dc25c05f1d..40ede64838ef7 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -11,6 +11,7 @@ export type ModelEsque = { badge?: "new" | undefined; isLegacy?: boolean | undefined; isUnavailable?: boolean | undefined; + isHidden?: boolean | undefined; }; function escapeRegExp(value: string): string { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 435854d54b393..467a0e9235e1f 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -25,6 +25,7 @@ import { import { defaultModelSelectionsAreMixed, getAppModelOptionsForInstance, + getCustomModelOptionsByInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, resolveEffectiveDefaultModelSelection, @@ -333,7 +334,7 @@ describe("instance-scoped model selection", () => { ).toEqual(["claude-sonnet-4-6", "openai/gpt-5.5"]); }); - it("falls back when the selected model is hidden", () => { + it("offers only visible models and falls back when the selected model is hidden", () => { const providers = [ provider({ instanceId: "claudeAgent", @@ -350,6 +351,16 @@ describe("instance-scoped model selection", () => { }, }; + expect( + getAppModelOptionsForInstance(settings, deriveProviderInstanceEntries(providers)[0]!).map( + (option) => option.slug, + ), + ).toEqual(["claude-sonnet-4-6"]); + expect( + getCustomModelOptionsByInstance(settings, providers) + .get(ProviderInstanceId.make("claudeAgent")) + ?.find((option) => option.slug === "claude-opus-4-6"), + ).toMatchObject({ isHidden: true }); expect( resolveAppModelSelectionForInstance( ProviderInstanceId.make("claudeAgent"), diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ec2816cc2849a..ec09d8e094f58 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -71,6 +71,7 @@ export interface AppModelOption { isDefault?: boolean; isLegacy?: boolean; isUnavailable?: boolean; + isHidden?: boolean; } function appendUnavailableDynamicModelSelection( @@ -132,7 +133,9 @@ function applyInstanceModelPreferences( ): AppModelOption[] { const hiddenModels = new Set(preferences.hiddenModels); return sortModelsForProviderInstance( - options.filter((option) => option.isCustom || !hiddenModels.has(option.slug)), + options.map((option) => + !option.isCustom && hiddenModels.has(option.slug) ? { ...option, isHidden: true } : option, + ), { modelOrder: preferences.modelOrder }, ); } @@ -160,55 +163,37 @@ function normalizeCustomModelEntries( return normalizedModels; } -function getAppModelOptions( +export function getAppModelOptionsForInstance( settings: UnifiedSettings, - providers: ReadonlyArray, - provider: ProviderDriverKind, + entry: Pick, selectedModel?: string | null, ): AppModelOption[] { - const rawModels = getProviderModels(providers, provider); - // Server custom-model snapshots can lag removals; rebuild custom rows from settings. - const options: AppModelOption[] = rawModels - .filter((model) => !model.isCustom) - .map((model) => toAppModelOption(model, provider)); - const seen = new Set(options.map((option) => option.slug)); - const builtInModelSlugs = new Set( - Arr.filterMap(getProviderModels(providers, provider), (model) => - model.isCustom ? Result.failVoid : Result.succeed(model.slug), - ), + return getAppModelCatalogForInstance(settings, entry, selectedModel).filter( + (option) => !option.isHidden, ); +} - const defaultInstanceId = defaultInstanceIdForDriver(provider); - const customModels = readInstanceCustomModels(settings, defaultInstanceId, provider); - for (const entry of normalizeCustomModelEntries(customModels, builtInModelSlugs)) { - if (seen.has(entry.slug)) { - continue; - } - - seen.add(entry.slug); - const shortName = resolveModelShortName(entry, provider); - options.push({ - slug: entry.slug, - name: entry.name, - isCustom: true, - ...(shortName ? { shortName } : {}), - }); - } - - const preferences = readInstanceModelPreferences(settings, defaultInstanceId); +export function getAppModelCatalogForInstance( + settings: UnifiedSettings, + entry: Pick, + selectedModel?: string | null, +): AppModelOption[] { + const preferences = readInstanceModelPreferences(settings, entry.instanceId); return appendUnavailableDynamicModelSelection( - applyInstanceModelPreferences(options, preferences), - rawModels, - provider, + applyInstanceModelPreferences( + getConfiguredAppModelOptionsForInstance(settings, entry), + preferences, + ), + entry.models, + entry.driverKind, selectedModel, preferences.hiddenModels, ); } -export function getAppModelOptionsForInstance( +function getConfiguredAppModelOptionsForInstance( settings: UnifiedSettings, - entry: ProviderInstanceEntry, - selectedModel?: string | null, + entry: Pick, ): AppModelOption[] { const options: AppModelOption[] = entry.models .filter((model) => !model.isCustom) @@ -236,14 +221,21 @@ export function getAppModelOptionsForInstance( }); } - const preferences = readInstanceModelPreferences(settings, entry.instanceId); - return appendUnavailableDynamicModelSelection( - applyInstanceModelPreferences(options, preferences), - entry.models, - entry.driverKind, - selectedModel, - preferences.hiddenModels, + return options; +} + +function resolveConfiguredAppModelSelection( + settings: UnifiedSettings, + entry: Pick, + selectedModel: string | null | undefined, +): string | null { + const hiddenModels = new Set( + readInstanceModelPreferences(settings, entry.instanceId).hiddenModels, ); + const options = getConfiguredAppModelOptionsForInstance(settings, entry).filter( + (option) => option.isCustom || !hiddenModels.has(option.slug), + ); + return resolveSelectableModel(entry.driverKind, selectedModel, options); } export function resolveAppModelSelection( @@ -253,8 +245,14 @@ export function resolveAppModelSelection( selectedModel: string | null | undefined, ): string { const resolvedProvider = resolveSelectableProvider(providers, provider); - const options = getAppModelOptions(settings, providers, resolvedProvider, selectedModel); + const entry = { + instanceId: defaultInstanceIdForDriver(resolvedProvider), + driverKind: resolvedProvider, + models: getProviderModels(providers, resolvedProvider), + }; + const options = getAppModelOptionsForInstance(settings, entry, selectedModel); return ( + resolveConfiguredAppModelSelection(settings, entry, selectedModel) ?? resolveSelectableModel(resolvedProvider, selectedModel, options) ?? getDefaultServerModel(providers, resolvedProvider) ); @@ -276,7 +274,7 @@ export function resolveAppModelSelectionForInstance( entry, resolutionOptions?.preserveUnavailableSelection ? selectedModel : null, ); - const resolvedSelection = resolveSelectableModel(entry.driverKind, selectedModel, options); + const resolvedSelection = resolveConfiguredAppModelSelection(settings, entry, selectedModel); if (resolvedSelection) { return resolvedSelection; } @@ -356,7 +354,7 @@ export function getCustomModelOptionsByInstance( for (const entry of deriveProviderInstanceEntries(providers)) { out.set( entry.instanceId, - getAppModelOptionsForInstance( + getAppModelCatalogForInstance( settings, entry, entry.instanceId === selectedInstanceId ? selectedModel : null, @@ -392,7 +390,7 @@ export function resolveAppModelSelectionState( // Use the cheap generation default; the catalog default is the expensive chat flagship. const options = getAppModelOptionsForInstance(settings, entry); const model = - resolveSelectableModel(entry.driverKind, selectedModel, options) ?? + resolveConfiguredAppModelSelection(settings, entry, selectedModel) ?? resolveSelectableModel( entry.driverKind, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind], From ba4adcbf25515c25c5ed94e5690f755bdafe8a1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Maluk?= Date: Tue, 3 Nov 2026 00:45:17 -0300 Subject: [PATCH 2/6] feat(settings): custom models can be hidden from pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker switch and the bulk toggle in Settings → Providers now cover custom models, including ones routed through a proxy, so a model can be hidden without deleting it. Removing a custom model also clears its hidden entry. Hidden custom models drop out of web and mobile choices, but a saved selection that already uses one keeps resolving to it and shows it as hidden. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/modelOptions.test.ts | 62 ++++++++++++++----- apps/mobile/src/lib/modelOptions.ts | 17 +++-- .../settings/ProviderModelsSection.tsx | 51 +++++++-------- apps/web/src/modelSelection.test.ts | 49 ++++++++++----- apps/web/src/modelSelection.ts | 2 +- 5 files changed, 118 insertions(+), 63 deletions(-) diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index d72685bf4dc75..07c14c5fe4300 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -725,7 +725,21 @@ describe("synchronized mobile model visibility", () => { { slug: "hidden", name: "Hidden", isCustom: false, capabilities: null }, { slug: "first", name: "First", isCustom: false, capabilities: null }, { slug: "second", name: "Second", isCustom: false, capabilities: null }, - { slug: "custom", name: "Custom", isCustom: true, capabilities: null }, + { + slug: "custom", + name: "Custom", + isCustom: true, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: [{ id: "high", label: "High" }], + }, + ], + }, + }, ]; const config = { settings: { @@ -745,6 +759,7 @@ describe("synchronized mobile model visibility", () => { instanceId, continuation: { groupKey: instanceId }, driver: "codex", + iconUrl: "https://example.com/provider.svg", enabled: true, installed: true, auth: { status: "authenticated" }, @@ -758,7 +773,7 @@ describe("synchronized mobile model visibility", () => { options .filter((option) => option.providerKey === provider) .map((option) => option.selection.model), - ).toEqual(["second", "first", "custom"]); + ).toEqual(["second", "first"]); expect( options .filter((option) => option.providerKey === other) @@ -766,7 +781,7 @@ describe("synchronized mobile model visibility", () => { ).toEqual(["hidden", "first", "second", "custom"]); }); - it("exposes hidden built-ins for management without offering custom visibility controls", () => { + it("exposes hidden built-in and custom models for management", () => { const options = buildModelOptions( config, { instanceId: provider, model: "hidden" }, @@ -785,26 +800,39 @@ describe("synchronized mobile model visibility", () => { canHide: true, }); expect(options.find((option) => option.selection.model === "custom")).toMatchObject({ - isHidden: false, - canHide: false, + isHidden: true, + canHide: true, }); }); - it.each(["hidden", "missing"])("does not resurrect the hidden fallback %s", (model) => { - expect( - buildModelOptions(config, { instanceId: provider, model }, provider, null).map( - (option) => option.selection.model, - ), - ).toEqual(["second", "first", "custom"]); - }); + it.each(["hidden", "custom", "missing"])( + "omits hidden selection %s from new choices but retains its metadata", + (model) => { + const options = buildModelOptions(config, { instanceId: provider, model }, provider, null); + expect(options.map((option) => option.selection.model)).toEqual(["second", "first"]); + if (model === "custom") { + const selection = { + instanceId: provider, + model, + options: [{ id: "effort", value: "high" }], + }; + expect(getSelectedModelOption(config, selection, null)).toMatchObject({ + label: "Custom", + isHidden: true, + providerIconUrl: "https://example.com/provider.svg", + capabilities: models[3]!.capabilities, + selection, + }); + expect(resolveDefaultableModelSelection(config, selection, null)).toEqual(selection); + } + }, + ); // regression: #16816 sync model favorites and visibility across clients - it("keeps custom models visible and restores built-in models when the server clears hidden models", () => { + it("restores built-in and custom models when the server clears hidden models", () => { expect( - buildModelOptions(config, { instanceId: provider, model: "custom" }, provider, null).map( - (option) => option.selection.model, - ), - ).toContain("custom"); + buildModelOptions(config, null, provider, null).map((option) => option.selection.model), + ).not.toContain("custom"); const cleared = { ...config, settings: { ...config.settings, providerModelPreferences: {} } }; expect( buildModelOptions(cleared, null, provider, null).map((option) => option.selection.model), diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 07cd296e7cebd..45fbafb039234 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -357,7 +357,7 @@ export function buildModelOptions( const hiddenModels = new Set(preferences?.hiddenModels ?? []); const order = new Map((preferences?.modelOrder ?? []).map((slug, index) => [slug, index])); const models = provider.models.filter( - (model) => includeHidden || model.isCustom || !hiddenModels.has(model.slug), + (model) => includeHidden || !hiddenModels.has(model.slug), ); if (order.size > 0) models.sort((a, b) => (order.get(a.slug) ?? Infinity) - (order.get(b.slug) ?? Infinity)); @@ -377,8 +377,8 @@ export function buildModelOptions( ? {} : { supportedRuntimeModes: provider.supportedRuntimeModes }), ...(provider.iconUrl ? { providerIconUrl: provider.iconUrl } : {}), - isHidden: !model.isCustom && hiddenModels.has(model.slug), - canHide: !model.isCustom, + isHidden: hiddenModels.has(model.slug), + canHide: true, isDefault: model.isDefault === true, isLegacy: model.isLegacy === true, ...(updateRequired ? { providerUpdateRequired: updateRequired } : {}), @@ -401,9 +401,10 @@ export function buildModelOptions( (!config?.settings?.providerModelPreferences?.[ fallbackModelSelection.instanceId ]?.hiddenModels.includes(fallbackModelSelection.model) || - config?.providers - .find((provider) => provider.instanceId === fallbackModelSelection.instanceId) - ?.models.find((model) => model.slug === fallbackModelSelection.model)?.isCustom) + (includeHidden && + config?.providers + .find((provider) => provider.instanceId === fallbackModelSelection.instanceId) + ?.models.find((model) => model.slug === fallbackModelSelection.model)?.isCustom)) ) { const key = `${fallbackModelSelection.instanceId}:${fallbackModelSelection.model}`; const existing = options.get(key); @@ -442,6 +443,10 @@ export function buildModelOptions( providerLabel, providerDriver, providerBadge: providerBadgeFor(fallbackModelSelection.instanceId), + isHidden: + config?.settings?.providerModelPreferences?.[ + fallbackModelSelection.instanceId + ]?.hiddenModels.includes(fallbackModelSelection.model) ?? false, isDefault: false, isLegacy: model?.isLegacy === true, ...(isModelSelectionUnavailable(config, fallbackModelSelection, projectId) || diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 3e39d6ccfc12b..8b2f280a6839f 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -63,7 +63,7 @@ function describeModelCapabilities( return labels; } -function groupModelsForDisplay( +function groupModelsForDisplay( models: ReadonlyArray, options: { readonly favoriteModels: ReadonlySet; @@ -76,7 +76,7 @@ function groupModelsForDisplay !model.isCustom && options.hiddenModels.has(model.slug); + const isHidden = (model: T) => options.hiddenModels.has(model.slug); return [ ...ordered.filter((model) => options.favoriteModels.has(model.slug)), ...ordered.filter((model) => !options.favoriteModels.has(model.slug) && !isHidden(model)), @@ -85,18 +85,18 @@ function groupModelsForDisplay>, + models: ReadonlyArray>, hiddenModels: ReadonlyArray, ): string[] { - const builtInSlugs = models.filter((model) => !model.isCustom).map((model) => model.slug); - const builtInSlugSet = new Set(builtInSlugs); - const allBuiltInModelsHidden = builtInSlugs.every((slug) => hiddenModels.includes(slug)); + const slugs = models.map((model) => model.slug); + const slugSet = new Set(slugs); + const allModelsHidden = slugs.every((slug) => hiddenModels.includes(slug)); - if (allBuiltInModelsHidden) { - return hiddenModels.filter((slug) => !builtInSlugSet.has(slug)); + if (allModelsHidden) { + return hiddenModels.filter((slug) => !slugSet.has(slug)); } - return [...new Set([...hiddenModels, ...builtInSlugs])]; + return [...new Set([...hiddenModels, ...slugs])]; } interface ProviderModelsSectionProps { @@ -150,9 +150,7 @@ export function ProviderModelsSection({ modelOrder, }); const favoriteCount = displayModels.filter((model) => favoriteModelSet.has(model.slug)).length; - const hiddenCount = displayModels.filter( - (model) => !model.isCustom && hiddenModelSet.has(model.slug), - ).length; + const hiddenCount = displayModels.filter((model) => hiddenModelSet.has(model.slug)).length; const flagMissingReasoning = models.some(hasReasoningControl); const showFilter = models.length > FILTER_THRESHOLD; const normalizedFilter = filter.trim().toLowerCase(); @@ -180,8 +178,8 @@ export function ProviderModelsSection({ // Derive after the effect and use hiddenModels directly to preserve the editor's option-array cache. const builtInModels = models.filter((model) => !model.isCustom); - const allBuiltInModelsHidden = - builtInModels.length > 0 && builtInModels.every((model) => hiddenModels.includes(model.slug)); + const allModelsHidden = + models.length > 0 && models.every((model) => hiddenModels.includes(model.slug)); const handleAdd = () => { if (!canManageCustomModels || driverKind === "antigravity") return; @@ -221,6 +219,12 @@ export function ProviderModelsSection({ if (!canManageCustomModels) return; if (editingSlug === slug) setEditingSlug(null); onChange(customModels.filter((entry) => entry.slug !== slug)); + if (hiddenModelSet.has(slug)) { + onHiddenModelsChange( + hiddenModels.filter((model) => model !== slug), + [slug], + ); + } onModelOrderChange(modelOrder.filter((model) => model !== slug)); onFavoriteModelsChange(favoriteModels.filter((model) => model !== slug)); setError(null); @@ -248,7 +252,7 @@ export function ProviderModelsSection({ const groupOf = (model: (typeof displayModels)[number]) => favoriteModelSet.has(model.slug) ? "favorite" - : !model.isCustom && hiddenModelSet.has(model.slug) + : hiddenModelSet.has(model.slug) ? "hidden" : "visible"; const handleMove = (slug: string, direction: -1 | 1) => { @@ -283,7 +287,7 @@ export function ProviderModelsSection({ ) : null}
- {builtInModels.length > 0 ? ( + {models.length > 0 ? ( ) : null} @@ -474,11 +479,7 @@ function ProviderModelRow({ }) { const capLabels = describeModelCapabilities(model, flagMissingReasoning); const nameClassName = cn("text-xs", isHidden ? "text-muted-foreground" : "text-foreground/90"); - const pickerTooltip = model.isCustom - ? "Custom models are always shown in the picker" - : isHidden - ? "Hidden from picker" - : "Shown in picker"; + const pickerTooltip = isHidden ? "Hidden from picker" : "Shown in picker"; return (
onSetHidden(model.slug, !checked)} aria-label={`Show ${model.name} in the model picker`} /> diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 467a0e9235e1f..a3be8fbf523e9 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -334,18 +334,21 @@ describe("instance-scoped model selection", () => { ).toEqual(["claude-sonnet-4-6", "openai/gpt-5.5"]); }); - it("offers only visible models and falls back when the selected model is hidden", () => { + it.each([ + ["claude-opus-4-6", "claude-sonnet-4-6"], + ["openai/gpt-5.5", "openai/gpt-5.5"], + ])("hides %s while preserving configured custom selections", (hiddenModel, expectedModel) => { const providers = [ provider({ - instanceId: "claudeAgent", + instanceId: "claude_openrouter", models: ["claude-opus-4-6", "claude-sonnet-4-6"], }), ]; const settings: UnifiedSettings = { ...settingsWithProviderInstances(), providerModelPreferences: { - [ProviderInstanceId.make("claudeAgent")]: { - hiddenModels: ["claude-opus-4-6"], + [ProviderInstanceId.make("claude_openrouter")]: { + hiddenModels: [hiddenModel], modelOrder: [], }, }, @@ -355,29 +358,47 @@ describe("instance-scoped model selection", () => { getAppModelOptionsForInstance(settings, deriveProviderInstanceEntries(providers)[0]!).map( (option) => option.slug, ), - ).toEqual(["claude-sonnet-4-6"]); + ).not.toContain(hiddenModel); + expect( + getAppModelOptionsForInstance( + settings, + deriveProviderInstanceEntries(providers)[0]!, + ` ${hiddenModel} `, + ).some((option) => option.slug === hiddenModel), + ).toBe(false); + expect( getCustomModelOptionsByInstance(settings, providers) - .get(ProviderInstanceId.make("claudeAgent")) - ?.find((option) => option.slug === "claude-opus-4-6"), - ).toMatchObject({ isHidden: true }); + .get(ProviderInstanceId.make("claude_openrouter")) + ?.find((option) => option.slug === hiddenModel), + ).toMatchObject({ + name: hiddenModel, + isHidden: true, + }); + expect( resolveAppModelSelectionForInstance( - ProviderInstanceId.make("claudeAgent"), + ProviderInstanceId.make("claude_openrouter"), settings, providers, - "claude-opus-4-6", + hiddenModel, ), - ).toBe("claude-sonnet-4-6"); + ).toBe(expectedModel); expect( resolveAppModelSelectionForInstance( - ProviderInstanceId.make("claudeAgent"), + ProviderInstanceId.make("claude_openrouter"), settings, providers, - "claude-opus-4-6", + hiddenModel, { preserveUnavailableSelection: true }, ), - ).toBe("claude-sonnet-4-6"); + ).toBe(expectedModel); + expect( + getAppModelOptionsForInstance( + { ...settings, providerModelPreferences: {} }, + deriveProviderInstanceEntries(providers)[0]!, + ).map((option) => option.slug), + ).toContain(hiddenModel); }); it("falls back instead of resolving a custom slug against the wrong instance", () => { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index ec09d8e094f58..cc68cc007c6e9 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -134,7 +134,7 @@ function applyInstanceModelPreferences( const hiddenModels = new Set(preferences.hiddenModels); return sortModelsForProviderInstance( options.map((option) => - !option.isCustom && hiddenModels.has(option.slug) ? { ...option, isHidden: true } : option, + hiddenModels.has(option.slug) ? { ...option, isHidden: true } : option, ), { modelOrder: preferences.modelOrder }, ); From 923a371c1e8387eeb0f7a0bf101c7e98920ca7ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Maluk?= Date: Tue, 3 Nov 2026 00:48:13 -0300 Subject: [PATCH 3/6] fix: defaults and fallbacks skip hidden models When no saved selection applies, model resolution now falls back to the instance's first visible model instead of a hidden one. This covers the composer, the derived project default and the scheduled-task default. When every model of an instance is hidden, nothing is chosen automatically: the composer explains how to show a model, and the picker says there are no visible models. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 7 +-- .../components/chat/ModelPickerContent.tsx | 4 +- .../scheduledTasksSettings.logic.test.ts | 32 ++++++++++ .../settings/scheduledTasksSettings.logic.ts | 6 +- apps/web/src/composerDraftStore.ts | 8 ++- apps/web/src/modelSelection.test.ts | 61 +++++++++++++++++++ apps/web/src/modelSelection.ts | 11 +++- apps/web/src/providerInstances.test.ts | 29 +++++++++ apps/web/src/providerInstances.ts | 19 +++++- 9 files changed, 164 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 6cb49ad378abd..0b2505523c52c 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -933,10 +933,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? undefined : clampManagedRuntimeMode(managedPolicy, compatibleRuntimeMode, compatibleRuntimeModes); - const providerSendBlockReason = getAntigravitySendBlockReason( - selectedProviderEntry?.snapshot, - selectedModel, - ); + const providerSendBlockReason = !selectedModel + ? "Show a model in Settings → Providers, or choose another model." + : getAntigravitySendBlockReason(selectedProviderEntry?.snapshot, selectedModel); const sendDisabledReason = externalSendDisabledReason ?? (multipleModelSelections?.length === 0 ? "Select at least one model." : null) ?? diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 28b0b58fa5455..bed6293820a73 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -1076,7 +1076,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { emptyLabel={ selectedInstanceId === "favorites" && !isSearching ? "No favorites yet" - : "No models found" + : !isSearching && flatModels.length === 0 + ? "No visible models. Show one in Settings → Providers." + : "No models found" } onRequestClose={onRequestClose} onOpenProviderSetup={onOpenProviderSetup} diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index c4c66c1cfbae0..4be776dc492f8 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -361,6 +361,38 @@ describe("scheduled task model defaults", () => { expect( resolve({ ...config, settings: { ...config.settings, defaultModelSelection: null } }, null), ).toEqual({ instanceId, model: "catalog-default" }); + expect( + resolve( + { + ...config, + settings: { + ...config.settings, + defaultModelSelection: null, + providerModelPreferences: { + [instanceId]: { + hiddenModels: config.providers[0]!.models.map((model) => model.slug), + modelOrder: [], + }, + }, + }, + }, + null, + ), + ).toBeNull(); + expect( + resolve( + { + ...config, + settings: { + ...config.settings, + providerModelPreferences: { + [instanceId]: { hiddenModels: [environmentSelection.model], modelOrder: [] }, + }, + }, + }, + null, + ), + ).toEqual(environmentSelection); }); it("falls back to the environment default when the project provider is unavailable", () => { expect( diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 9e0d0dad27cb9..0efaac7c56198 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -319,7 +319,11 @@ export function scheduledTaskDefaultModel( } const models = available.flatMap((entry) => entry.models - .filter((model) => !model.isLegacy) + .filter( + (model) => + !model.isLegacy && + !settings.providerModelPreferences?.[entry.instanceId]?.hiddenModels.includes(model.slug), + ) .map((model) => ({ instanceId: entry.instanceId, model })), ); const fallback = models.find(({ model }) => model.isDefault) ?? models[0]; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 4e6dfe04a7ebb..966dddfd4825a 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1111,13 +1111,17 @@ export function deriveEffectiveComposerModelState(input: { input.threadModelSelection?.instanceId === input.selectedInstanceId; const baseModel = (input.selectedInstanceId - ? resolveAppModelSelectionForInstance( + ? (resolveAppModelSelectionForInstance( input.selectedInstanceId, input.settings, input.providers, baseModelCandidate, { preserveUnavailableSelection: preserveThreadModel }, - ) + ) ?? + (input.providers.some((provider) => provider.instanceId === input.selectedInstanceId) && + input.settings.providerModelPreferences[input.selectedInstanceId]?.hiddenModels.length + ? "" + : null)) : null) ?? // Antigravity has no static model or cross-account catalog fallback. (input.selectedProvider === "antigravity" && input.selectedInstanceId ? "" : null) ?? diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index a3be8fbf523e9..25e49d6269f6e 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -26,6 +26,7 @@ import { defaultModelSelectionsAreMixed, getAppModelOptionsForInstance, getCustomModelOptionsByInstance, + resolveAppModelSelection, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, resolveEffectiveDefaultModelSelection, @@ -399,6 +400,47 @@ describe("instance-scoped model selection", () => { deriveProviderInstanceEntries(providers)[0]!, ).map((option) => option.slug), ).toContain(hiddenModel); + + const allHidden = { + ...settings, + providerModelPreferences: { + [ProviderInstanceId.make("claude_openrouter")]: { + hiddenModels: ["claude-opus-4-6", "claude-sonnet-4-6", "openai/gpt-5.5"], + modelOrder: [], + }, + }, + }; + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claude_openrouter"), + allHidden, + providers, + null, + ), + ).toBeNull(); + const stockProviders = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: ["claude-opus-4-6"], + }), + ]; + expect( + resolveAppModelSelection( + ProviderDriverKind.make("claudeAgent"), + { + ...settings, + providerModelPreferences: { + [ProviderInstanceId.make("claudeAgent")]: { + hiddenModels: ["claude-opus-4-6"], + modelOrder: [], + }, + }, + }, + stockProviders, + null, + ), + ).toBe(""); }); it("falls back instead of resolving a custom slug against the wrong instance", () => { @@ -541,6 +583,25 @@ describe("instance-scoped model selection", () => { expect(dispatch.modelOptionsForDispatch).toBeUndefined(); }); + it("keeps the thread model while its instance snapshot is missing and has hidden models", () => { + const instanceId = ProviderInstanceId.make("codex_work"); + const driver = ProviderDriverKind.make("codex"); + const state = deriveEffectiveComposerModelState({ + draft: null, + providers: [provider({ provider: driver, instanceId: "codex", models: ["gpt-5.6-sol"] })], + selectedProvider: driver, + selectedInstanceId: instanceId, + threadModelSelection: createModelSelection(instanceId, "gpt-5.6-sol"), + projectModelSelection: null, + settings: { + ...settingsWithProviderInstances(), + providerModelPreferences: { [instanceId]: { hiddenModels: ["other"], modelOrder: [] } }, + }, + }); + + expect(state.selectedModel).toBe("gpt-5.6-sol"); + }); + it("preserves an explicit draft OpenCode selection while the catalog is empty", () => { const instanceId = ProviderInstanceId.make("opencode_work"); const driver = ProviderDriverKind.make("opencode"); diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index cc68cc007c6e9..40efc7ef1892a 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -254,7 +254,11 @@ export function resolveAppModelSelection( return ( resolveConfiguredAppModelSelection(settings, entry, selectedModel) ?? resolveSelectableModel(resolvedProvider, selectedModel, options) ?? - getDefaultServerModel(providers, resolvedProvider) + options.find((option) => option.isDefault)?.slug ?? + options[0]?.slug ?? + (readInstanceModelPreferences(settings, entry.instanceId).hiddenModels.length > 0 + ? "" + : getDefaultServerModel(providers, resolvedProvider)) ); } @@ -402,8 +406,9 @@ export function resolveAppModelSelectionState( supportedProviders, selectedModel, ) ?? - entry.models[0]?.slug ?? - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; + (readInstanceModelPreferences(settings, entry.instanceId).hiddenModels.length > 0 + ? "" + : DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]); if (!model) { return createModelSelection(entry.instanceId, "", []); } diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 891f3312ae833..ef7f134a309cb 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_SERVER_SETTINGS, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -14,6 +15,7 @@ import { getDefaultProviderInstanceModel, isProviderInstancePickerReady, resolveDefaultProviderModelSelection, + resolveProjectDefaultProviderModelSelection, resolveSelectableProviderInstance, shouldShowInstanceBadge, } from "./providerInstances"; @@ -364,6 +366,33 @@ describe("resolveSelectableProviderInstance", () => { }); describe("getDefaultProviderInstanceModel", () => { + it.each([false, true])( + "does not automatically select a hidden model (all hidden: %s)", + (allHidden) => { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const providers = [ + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId, + models: [model("hidden-custom", true, true), model("visible")], + }), + ]; + const selection = resolveProjectDefaultProviderModelSelection( + providers, + { + ...DEFAULT_SERVER_SETTINGS, + providerModelPreferences: { + [instanceId]: { + hiddenModels: allHidden ? ["hidden-custom", "visible"] : ["hidden-custom"], + modelOrder: [], + }, + }, + }, + null, + ); + expect(selection).toEqual(allHidden ? null : { instanceId, model: "visible" }); + }, + ); it("uses the instance's own models, not the default instance of the kind", () => { const providers = [ provider({ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c2120f60e31cc..a9c60be09c556 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -251,13 +251,28 @@ export function resolveProjectDefaultProviderModelSelection( settings: ServerSettings, projectId: ProjectId | null, ): ModelSelection | null { - const entries = providerInstanceEntriesFor(providers, settings, projectId); + const visibleProviders = providers.map((provider) => ({ + ...provider, + models: provider.models.filter( + (model) => + !settings.providerModelPreferences?.[provider.instanceId]?.hiddenModels.includes( + model.slug, + ), + ), + })); + const entries = providerInstanceEntriesFor(visibleProviders, settings, projectId).filter( + (entry) => + entry.models.length > 0 || + !settings.providerModelPreferences?.[entry.instanceId]?.hiddenModels.length, + ); // Matches resolveComposerProviderSelection: the first driver group wins before any other. const entry = resolveSelectableProviderInstanceEntry( entries.filter((candidate) => candidate.driverKind === entries[0]?.driverKind), undefined, ) ?? resolveSelectableProviderInstanceEntry(entries, undefined); - const model = entry ? getDefaultProviderInstanceModel(providers, entry.instanceId) : undefined; + const model = entry + ? getDefaultProviderInstanceModel(visibleProviders, entry.instanceId) + : undefined; return entry && model ? { instanceId: entry.instanceId, model } : null; } From e1daa7a03c3a85824b6216baab70501ef73fa5b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Maluk?= Date: Tue, 3 Nov 2026 00:50:39 -0300 Subject: [PATCH 4/6] fix: new threads don't reuse a hidden model New threads used to carry the current thread's model or reapply the last-used (sticky) selection even after that model was hidden. Both now skip hidden models, and when that leaves nothing, the visible project default applies. Mobile new tasks do the same. The sticky selection itself is kept, so unhiding the model restores it. Drafts with an explicit pick and existing threads are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../threads/new-task-flow-provider.tsx | 1 + apps/mobile/src/lib/modelOptions.test.ts | 3 ++ apps/mobile/src/lib/modelOptions.ts | 8 ++++ .../src/components/chat/DraftHeroHeadline.tsx | 9 ++-- apps/web/src/composerDraftStore.test.ts | 44 +++++++++++++------ apps/web/src/composerDraftStore.ts | 22 ++++++++-- apps/web/src/hooks/useHandleNewThread.test.ts | 5 ++- apps/web/src/hooks/useHandleNewThread.ts | 31 ++++++++++--- 8 files changed, 93 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 4b73408c80b45..9d483b90f52b7 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -442,6 +442,7 @@ export function NewTaskFlowProvider( selectedEnvironmentServerConfig, storedStickyModelSelection, selectedProject?.id ?? null, + { requireVisible: true }, ); const modelOptions = buildModelOptions( selectedEnvironmentServerConfig, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 07c14c5fe4300..e226845d4202b 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -824,6 +824,9 @@ describe("synchronized mobile model visibility", () => { selection, }); expect(resolveDefaultableModelSelection(config, selection, null)).toEqual(selection); + expect( + resolveDefaultableModelSelection(config, selection, null, { requireVisible: true }), + ).toBeNull(); } }, ); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 45fbafb039234..19e5e83964b21 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -250,6 +250,7 @@ export function resolveDefaultableModelSelection( config: T3ServerConfig | null | undefined, selection: ModelSelection | null, projectId: ProjectId | null, + options?: { readonly requireVisible?: boolean }, ): ModelSelection | null { const usable = resolveSelectableModelSelection(config, selection, projectId); if (!usable || !config) { @@ -257,6 +258,13 @@ export function resolveDefaultableModelSelection( } const provider = config.providers.find((candidate) => candidate.instanceId === usable.instanceId); const model = provider?.models.find((candidate) => candidate.slug === usable.model); + if ( + options?.requireVisible && + config.settings?.providerModelPreferences?.[usable.instanceId]?.hiddenModels.includes( + usable.model, + ) + ) + return null; return provider?.driver !== "antigravity" && model?.isLegacy === true ? null : usable; } diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 91bf642fcb32f..fbf3ae4a1c248 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -209,11 +209,11 @@ export function DraftHeroHeadline({ draftId, ); if (!hasExplicitComposerModelSelection(currentDraft)) { - applyStickyState(draftId); const environmentConfig = environments.find( (environment) => environment.environmentId === project.environmentId, )?.serverConfig; const environmentSettings = environmentConfig?.settings; + applyStickyState(draftId, environmentSettings?.providerModelPreferences); const defaultSelection = environmentSettings ? resolveProjectSettings(environmentSettings, project.id, project).settings .defaultModelSelection @@ -221,7 +221,8 @@ export function DraftHeroHeadline({ const fallbackResolution = resolveNewThreadModelSelection({ defaultSelection: defaultSelection ?? - (useComposerDraftStore.getState().stickyActiveProvider !== null || !environmentSettings + (useComposerDraftStore.getState().getComposerDraft(draftId)?.activeProvider || + !environmentSettings ? null : resolveProjectDefaultProviderModelSelection( providersByEnvironment.get(project.environmentId) ?? [], @@ -234,9 +235,7 @@ export function DraftHeroHeadline({ projectId: project.id, now: Date.now(), }); - const defaultModelSelection = fallbackResolution.fallback - ? fallbackResolution.selection - : defaultSelection; + const defaultModelSelection = fallbackResolution.selection; if (defaultModelSelection) { setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index d92417ba96d88..1a8f9c4a31023 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1860,21 +1860,39 @@ describe("composerDraftStore sticky composer settings", () => { ); }); - it("applies sticky activeProvider to new drafts", () => { - const store = useComposerDraftStore.getState(); - const threadId = ThreadId.make("thread-sticky-active-provider"); - const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + it.each([false, true])( + "applies only visible sticky selections to new drafts (hidden: %s)", + (hidden) => { + const store = useComposerDraftStore.getState(); + const threadId = ThreadId.make("thread-sticky-active-provider"); + const threadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + + store.setStickyModelSelection(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6")); + store.applyStickyState( + threadRef, + hidden + ? { + [CLAUDE_AGENT_INSTANCE]: { hiddenModels: ["claude-opus-4-6"], modelOrder: [] }, + } + : {}, + ); - store.setStickyModelSelection(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6")); - store.applyStickyState(threadRef); + if (hidden) { + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toBeUndefined(); + expect( + useComposerDraftStore.getState().stickyModelSelectionByProvider[CLAUDE_AGENT_INSTANCE], + ).toEqual(modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6")); + return; + } - expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ - modelSelectionByProvider: { - claudeAgent: modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"), - }, - activeProvider: "claudeAgent", - }); - }); + expect(draftFor(threadId, TEST_ENVIRONMENT_ID)).toMatchObject({ + modelSelectionByProvider: { + claudeAgent: modelSelection(CLAUDE_AGENT_DRIVER, "claude-opus-4-6"), + }, + activeProvider: "claudeAgent", + }); + }, + ); it("replaces a non-explicit stale model and its options with sticky state", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 966dddfd4825a..2917ebc149e6c 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -518,7 +518,10 @@ interface ComposerDraftStoreState { replaceOptions?: boolean; }, ) => void; - applyStickyState: (threadRef: ComposerThreadTarget) => void; + applyStickyState: ( + threadRef: ComposerThreadTarget, + preferences?: UnifiedSettings["providerModelPreferences"] | null, + ) => void; setProviderModelOptions: ( threadRef: ComposerThreadTarget, provider: ProviderDriverKind, @@ -2907,14 +2910,25 @@ const composerDraftStore = create()( }; }); }, - applyStickyState: (threadRef) => { + applyStickyState: (threadRef, preferences) => { const threadKey = resolveComposerDraftKey(get(), threadRef) ?? ""; if (threadKey.length === 0) { return; } set((state) => { - const stickyMap = state.stickyModelSelectionByProvider; - const stickyActiveProvider = state.stickyActiveProvider; + const stickyMap = Object.fromEntries( + Object.entries(state.stickyModelSelectionByProvider).filter( + ([instanceId, selection]) => + selection && + !preferences?.[ProviderInstanceId.make(instanceId)]?.hiddenModels.includes( + selection.model, + ), + ), + ); + const stickyActiveProvider = + state.stickyActiveProvider && stickyMap[state.stickyActiveProvider] + ? state.stickyActiveProvider + : null; const existing = state.draftsByThreadKey[threadKey]; const base = existing ?? createEmptyThreadDraft(); const nextMap = compactModelSelectionByProvider(stickyMap); diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 6faf5bd76d73d..b953abaa93e34 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -200,7 +200,10 @@ vi.mock("../lib/chatThreadActions", async (importOriginal) => ({ hasExplicitComposerModelSelection: () => false, resolveNewThreadModelSelectionOverride: () => null, })); -vi.mock("../providerInstances", () => ({ resolveDefaultProviderModelSelection: () => null })); +vi.mock("../providerInstances", () => ({ + resolveDefaultProviderModelSelection: () => null, + resolveProjectDefaultProviderModelSelection: () => null, +})); vi.mock("../lib/t3ProjectFileDefaults", () => ({ readT3ProjectFile: () => testState.projectFileRead, })); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index a43825472f357..46b774096e033 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -128,7 +128,14 @@ export function createNewThreadHandler(router: AnyRouter) { const composerModelSelection = composerActiveProvider ? (carrySourceComposer?.modelSelectionByProvider[composerActiveProvider] ?? null) : null; - const carryModelSelection = composerModelSelection ?? carrySourceShell?.modelSelection ?? null; + const carryCandidate = composerModelSelection ?? carrySourceShell?.modelSelection ?? null; + const carryModelSelection = + carryCandidate && + !targetServerSettings.providerModelPreferences?.[ + carryCandidate.instanceId + ]?.hiddenModels.includes(carryCandidate.model) + ? carryCandidate + : null; const carryInteractionMode = carrySourceComposer?.interactionMode ?? carrySourceShell?.interactionMode ?? @@ -147,10 +154,19 @@ export function createNewThreadHandler(router: AnyRouter) { project, ); const configuredDefaultModelSelection = projectSettings.settings.defaultModelSelection ?? null; + const stickyState = useComposerDraftStore.getState(); + const stickySelection = stickyState.stickyActiveProvider + ? stickyState.stickyModelSelectionByProvider[stickyState.stickyActiveProvider] + : null; + const hasVisibleStickySelection = + stickySelection && + !targetServerSettings.providerModelPreferences?.[ + stickySelection.instanceId + ]?.hiddenModels.includes(stickySelection.model); const fallbackResolution = resolveNewThreadModelSelection({ defaultSelection: configuredDefaultModelSelection ?? - (carryModelSelection || useComposerDraftStore.getState().stickyActiveProvider !== null + (carryModelSelection || hasVisibleStickySelection ? null : resolveProjectDefaultProviderModelSelection( environmentServerConfigs.get(projectRef.environmentId)?.providers ?? [], @@ -163,9 +179,7 @@ export function createNewThreadHandler(router: AnyRouter) { projectId: projectRef.projectId, now: Date.now(), }); - const projectDefaultModelSelection = fallbackResolution.fallback - ? fallbackResolution.selection - : configuredDefaultModelSelection; + const projectDefaultModelSelection = fallbackResolution.selection; const defaultRuntimeMode = projectSettings.settings.defaultRuntimeMode; const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ @@ -290,7 +304,10 @@ export function createNewThreadHandler(router: AnyRouter) { const storedDraft = getComposerDraft(emptyStoredDraftThread.draftId); const storedDraftHasExplicitModelPick = hasExplicitComposerModelSelection(storedDraft); if (!storedDraftHasExplicitModelPick) { - applyStickyState(emptyStoredDraftThread.draftId); + applyStickyState( + emptyStoredDraftThread.draftId, + targetServerSettings.providerModelPreferences, + ); const modelSelectionOverride = resolveModelSelectionOverride( emptyStoredDraftThread.draftId, ); @@ -407,7 +424,7 @@ export function createNewThreadHandler(router: AnyRouter) { runtimeMode: defaultRuntimeMode, ...(carryInteractionMode ? { interactionMode: carryInteractionMode } : {}), }); - applyStickyState(draftId); + applyStickyState(draftId, targetServerSettings.providerModelPreferences); const modelSelectionOverride = resolveModelSelectionOverride(draftId); if (modelSelectionOverride) { setModelSelection(draftId, modelSelectionOverride, { replaceOptions: true }); From 6b5e9714e4d280de97c5f00858b20f65353d006f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Maluk?= Date: Tue, 3 Nov 2026 00:53:45 -0300 Subject: [PATCH 5/6] fix(server): delegated tasks skip hidden models Agents see the same visibility as the pickers. The capability listing omits hidden custom models, and a delegated task that names no model starts on the instance's first visible model. Driver-only targets skip instances with every model hidden and report model_unavailable when no enabled instance has a visible model. Explicit and inherited models are kept. A retry of an accepted delegation prefers a visible model but falls back to any advertised one, and skips option validation, so it still reaches its receipt after models are hidden. Replays start no work. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 99 +++++++++++++++++-- apps/server/src/mcp/OrchestratorMcpService.ts | 45 +++++++-- 2 files changed, 129 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 3bd9439d308b4..8cda75851c82a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1604,6 +1604,7 @@ describe("OrchestratorMcpService provider resolution", () => { models: [ ...opencodeSlugs.map((slug) => model(slug, { isLegacy: slug === "oc/m03" })), model("oc-custom", { isCustom: true }), + model("oc-custom-hidden", { isCustom: true }), ], }, ]; @@ -1631,7 +1632,10 @@ describe("OrchestratorMcpService provider resolution", () => { { provider: opencodeInstanceId, model: "oc/m10" }, ], providerModelPreferences: { - [opencodeInstanceId]: { hiddenModels: ["oc/m02"], modelOrder: ["oc/m12"] }, + [opencodeInstanceId]: { + hiddenModels: ["oc/m02", "oc-custom-hidden"], + modelOrder: ["oc/m12"], + }, }, }), providerRegistryLayer(catalog), @@ -1799,9 +1803,54 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - it.effect("driver-only delegation skips an instance the project turned off", () => + it.effect.each([ + { + name: "driver-only delegation skips instances the project turned off or fully hid", + accepted: false, + target: { driverKind: ProviderDriverKind.make("codex") }, + altHiddenModels: ["hidden-custom"], + expectedModel: "gpt-5.4", + }, + { + name: "an accepted delegation replay skips hidden models like the original request", + accepted: true, + target: { providerInstanceId: ProviderInstanceId.make("codex-alt") }, + altHiddenModels: ["hidden-custom"], + expectedModel: "gpt-5.4", + }, + { + name: "an accepted delegation replay still reaches its receipt after every model is hidden", + accepted: true, + target: { providerInstanceId: ProviderInstanceId.make("codex-alt") }, + altHiddenModels: ["hidden-custom", "gpt-5.4"], + expectedModel: "hidden-custom", + }, + { + name: "an accepted delegation replay keeps options only the now-hidden model accepted", + accepted: true, + target: { + providerInstanceId: ProviderInstanceId.make("codex-alt"), + options: [{ id: "effort", value: "high" }], + }, + altHiddenModels: ["hidden-custom"], + expectedModel: "gpt-5.4", + }, + { + name: "driver-only delegation reports hidden models when every enabled instance hides them all", + accepted: false, + target: { driverKind: ProviderDriverKind.make("codex") }, + altHiddenModels: ["hidden-custom", "gpt-5.4"], + expectedModel: null, + }, + ])("$name", ({ accepted, target, altHiddenModels, expectedModel }) => Effect.gen(function* () { const altInstanceId = ProviderInstanceId.make("codex-alt"); + const allHiddenInstanceId = ProviderInstanceId.make("codex-all-hidden"); + const alternateProvider = providerSnapshot({ + instanceId: altInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }); const dispatched = yield* Ref.make>([]); const task = { id: taskId, @@ -1828,7 +1877,7 @@ describe("OrchestratorMcpService provider resolution", () => { NodeServices.layer, NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ - hasReceipt: () => Effect.succeed(false), + hasReceipt: () => Effect.succeed(accepted), getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId @@ -1872,27 +1921,60 @@ describe("OrchestratorMcpService provider resolution", () => { model: "gpt-5.4", }), providerSnapshot({ - instanceId: altInstanceId, + instanceId: allHiddenInstanceId, driver: ProviderDriverKind.make("codex"), model: "gpt-5.4", }), + { + ...alternateProvider, + models: [ + { + ...alternateProvider.models[0]!, + slug: "hidden-custom", + name: "Hidden custom", + isCustom: true, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [{ id: "high", label: "High" }], + }, + ], + }, + }, + { ...alternateProvider.models[0]!, capabilities: { optionDescriptors: [] } }, + ], + }, ]), - adapterRegistryLayer([codexInstanceId, altInstanceId]), + adapterRegistryLayer([codexInstanceId, allHiddenInstanceId, altInstanceId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(RemoteDelegation.RemoteDelegation)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), - ServerSettings.layerTest(), + ServerSettings.layerTest({ + providerModelPreferences: { + [altInstanceId]: { hiddenModels: altHiddenModels, modelOrder: [] }, + [allHiddenInstanceId]: { hiddenModels: ["gpt-5.4"], modelOrder: [] }, + }, + }), ); yield* Effect.gen(function* () { const service = yield* OrchestratorMcpService.OrchestratorMcpService; - const result = yield* service.delegateTask(scope, { + const delegation = service.delegateTask(scope, { task: "Summarize the diff.", - target: { driverKind: ProviderDriverKind.make("codex") }, + target, mode: "async", clientRequestId: "delegate-driver-skip-disabled-1", }); + if (expectedModel === null) { + const error = yield* Effect.flip(delegation); + assert.equal(error.code, "model_unavailable"); + return; + } + const result = yield* delegation; assert.equal(result.status, "running"); const commands = yield* Ref.get(dispatched); assert.equal(commands.length, 1); @@ -1900,6 +1982,7 @@ describe("OrchestratorMcpService provider resolution", () => { modelSelection: { instanceId: string; model: string }; }; assert.equal(request.modelSelection.instanceId, altInstanceId); + assert.equal(request.modelSelection.model, expectedModel); }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); }), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index b937453f39b22..8d7ccf322b8c0 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -299,7 +299,7 @@ function listCapabilityModels(input: { }) { const rank = new Map(input.preferences?.modelOrder.map((slug, index) => [slug, index])); const visible = input.provider.models - .filter((model) => model.isCustom || !input.preferences?.hiddenModels.includes(model.slug)) + .filter((model) => !input.preferences?.hiddenModels.includes(model.slug)) .toSorted((a, b) => (rank.get(a.slug) ?? rank.size) - (rank.get(b.slug) ?? rank.size)); const { instanceId, driver, displayName } = input.provider; const providerText = `${instanceId} ${driver} ${displayName ?? ""}`.toLowerCase(); @@ -1335,6 +1335,23 @@ const make = Effect.gen(function* () { const requestedInstanceId = input.target?.providerInstanceId; const requestedDriver = input.target?.driverKind; const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds(); + const requestedModel = input.target?.model; + // Replays prefer the visible model the accepted request chose, but never fail on visibility. + const settings = + requestedModel === undefined + ? yield* serverSettings.getSettings.pipe( + Effect.mapError(() => + failure("orchestration_error", "Unable to read server settings."), + ), + ) + : undefined; + const fallbackModel = (provider: ServerProvider) => { + const hiddenModels = settings?.providerModelPreferences?.[provider.instanceId]?.hiddenModels; + return ( + provider.models.find((candidate) => !hiddenModels?.includes(candidate.slug))?.slug ?? + (input.skipProjectEnablementFilter ? provider.models[0]?.slug : undefined) + ); + }; let instanceId = requestedInstanceId; if (instanceId === undefined && requestedDriver !== undefined) { @@ -1359,6 +1376,7 @@ const make = Effect.gen(function* () { ...(inheritedCandidate === undefined ? [] : [inheritedCandidate]), ...healthyCandidates.filter((candidate) => candidate !== inheritedCandidate), ]; + let enabledCandidateHidesEveryModel = false; for (const candidate of orderedCandidates) { const enabledForProject = input.skipProjectEnablementFilter === true || @@ -1366,10 +1384,23 @@ const make = Effect.gen(function* () { projectId: input.parent.thread.projectId, instanceId: candidate.instanceId, })); - if (enabledForProject) { - instanceId = candidate.instanceId; - break; + if (!enabledForProject) continue; + if ( + requestedModel === undefined && + candidate !== inheritedCandidate && + fallbackModel(candidate) === undefined + ) { + enabledCandidateHidesEveryModel = true; + continue; } + instanceId = candidate.instanceId; + break; + } + if (instanceId === undefined && enabledCandidateHidesEveryModel) { + return yield* failure( + "model_unavailable", + `Every enabled instance of driver ${requestedDriver} has all its models hidden. Pass target.model, or unhide a model in Settings.`, + ); } if (instanceId === undefined) { return yield* failure( @@ -1407,12 +1438,11 @@ const make = Effect.gen(function* () { } const inheritedSelection = input.parent.thread.modelSelection; - const requestedModel = input.target?.model; const model = requestedModel ?? (instanceId === inheritedSelection.instanceId ? inheritedSelection.model - : provider.models[0]?.slug); + : fallbackModel(provider)); if (model === undefined) { return yield* failure( "model_unavailable", @@ -1435,7 +1465,8 @@ const make = Effect.gen(function* () { const requestedOptions = input.target?.options; if (requestedOptions !== undefined) { const invalid = invalidOptionSelections(requestedOptions, descriptors); - if (invalid.length > 0) { + // A replay may resolve another model than the accepted request; it starts no work. + if (invalid.length > 0 && input.skipProjectEnablementFilter !== true) { return yield* failure( "invalid_request", `Model ${model} on provider ${instanceId} rejected options: ${invalid.join(" ")}`, From c2f84d473367622cea3c5cb2b7a90dd6743339b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Maluk?= Date: Tue, 3 Nov 2026 00:55:14 -0300 Subject: [PATCH 6/6] docs(user): explain hiding models from pickers Co-Authored-By: Claude Opus 5.5 --- docs/user/project-settings.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index ba481be9f5b00..efb9fffc8baca 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -27,6 +27,13 @@ again. Providers and diagnostics are per machine: they show one environment at a time, the primary one until you pick another. Every other setting fans out to the selection. +To hide a model without deleting it, turn off its picker switch in **Settings → Providers**. This +works for built-in and custom models, including ones routed through a proxy, and applies to that +provider only, so the same model can stay visible under another one. Hidden models drop out of +model pickers, automatic defaults and the model new threads remember. A thread, draft, default or +scheduled task that already uses a hidden custom model keeps using it and shows it as hidden. If +every model is hidden and none is selected, show one again to continue. + With a project selected, a provider's switch in **Settings → Providers** turns it on or off for that project only, in either direction: a provider off on the machine can be used in one project, and one on everywhere can be kept out of another. That project's model pickers follow the switch,