diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 08faddc903708..8588ddbc28724 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -53,7 +53,11 @@ import type { import { waitForDraftHeroTransition } from "./components/chat/draftHeroTransition"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { readThreadPreviewState } from "./previewStateStore"; -import { resolveAppModelSelection, resolveAppModelSelectionForInstance } from "./modelSelection"; +import { + readInstanceCustomModels, + resolveAppModelSelection, + resolveAppModelSelectionForInstance, +} from "./modelSelection"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -1188,6 +1192,7 @@ function withFallbackInstanceOptions(input: { readonly selectedProvider: ProviderDriverKind; readonly selectedInstanceId?: ProviderInstanceId | null | undefined; readonly requestedInstanceId?: ProviderInstanceId | null | undefined; + readonly settings: UnifiedSettings; }): ProviderOptionSelectionsByProvider | null { const { modelOptions, selectedInstanceId, requestedInstanceId } = input; if ( @@ -1200,16 +1205,26 @@ function withFallbackInstanceOptions(input: { return modelOptions; } const requestedOptions = modelOptions[requestedInstanceId]; - const requestedDriver = input.providers.find( + const requestedProvider = input.providers.find( (provider) => provider.instanceId === requestedInstanceId, - )?.driver; - // The model of the selection the options came from, which can differ from the shown model's source. + ); const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; - if ( - !requestedOptions || - requestedDriver !== input.selectedProvider || - requestedModel !== input.selectedModel - ) { + // Static aliases are shared across instances; exact custom slugs and catalog aliases are not. + const modelIdentity = (instanceId: ProviderInstanceId, model: string | undefined) => { + const isCustom = + input.providers + .find((provider) => provider.instanceId === instanceId) + ?.models.some((entry) => entry.slug === model && entry.isCustom) || + readInstanceCustomModels(input.settings, instanceId, input.selectedProvider).some( + (entry) => entry.slug === model, + ); + return isCustom ? model : normalizeModelSlug(model, input.selectedProvider); + }; + const sameModel = + requestedModel === input.selectedModel || + modelIdentity(requestedInstanceId, requestedModel) === + modelIdentity(selectedInstanceId, input.selectedModel); + if (!requestedOptions || requestedProvider?.driver !== input.selectedProvider || !sameModel) { return modelOptions; } return { ...modelOptions, [selectedInstanceId]: requestedOptions }; diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index b5e9be7d9e011..01c1a7534ed3c 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -829,6 +829,7 @@ describe("composer fallback to another instance of the same driver", () => { readonly providers: ReadonlyArray; readonly draft?: Parameters[0]["draft"]; readonly threadModelSelection: ReturnType; + readonly customModels?: Partial>>; }) { const settings = { ...DEFAULT_UNIFIED_SETTINGS, @@ -838,7 +839,13 @@ describe("composer fallback to another instance of the same driver", () => { { driver: provider.driver, enabled: provider.enabled, - config: {}, + config: { + customModels: (input.customModels?.[provider.instanceId] ?? []).map((slug) => ({ + slug, + name: slug, + capabilities: null, + })), + }, }, ]), ), @@ -923,6 +930,138 @@ describe("composer fallback to another instance of the same driver", () => { expect(traits.modelOptionsForDispatch).toBeUndefined(); }); + describe("with model aliases", () => { + const reasoningModel = ( + slug: string, + id: string, + extra: Partial = {}, + ): ServerProvider["models"][number] => ({ + slug, + name: slug, + isCustom: false, + ...extra, + capabilities: { + optionDescriptors: [ + { + id, + label: id, + type: "select", + options: ["medium", "high", "xhigh"].map((value) => ({ + id: value, + label: value, + isDefault: value === "medium", + })), + }, + ], + }, + }); + + it.each([ + ["5.4", "gpt-5.4"], + ["gpt-5-codex", "gpt-5.4"], + ["gpt-5.4", "5.4"], + ])("keeps the effort from %s on fallback %s", (savedModel, fallbackModel) => { + const disabledCodex = ProviderInstanceId.make("codex"); + const fallbackCodex = ProviderInstanceId.make("codex_team"); + const models = [reasoningModel(fallbackModel, "reasoningEffort")]; + const { selectedInstanceId, state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), enabled: false, models }, + { ...provider({ provider: codex, instanceId: fallbackCodex }), models }, + ], + threadModelSelection: createModelSelection(disabledCodex, savedModel, [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(selectedInstanceId).toBe(fallbackCodex); + expect(state.selectedModel).toBe(fallbackModel); + expect(traits.promptEffort).toBe("xhigh"); + }); + + it.each([ + { + // A disabled Codex instance reports only its custom models. + state: "disabled", + requested: { + enabled: false, + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + }, + { + // An unavailable instance reports no models; its custom models are only in settings. + state: "unavailable", + requested: { enabled: false, availability: "unavailable" as const, models: [] }, + }, + ])( + "does not alias-expand a custom model named 5.4 when the requested instance is $state", + ({ requested }) => { + const disabledCodex = ProviderInstanceId.make("codex"); + const { state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), ...requested }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("gpt-5.4", "reasoningEffort")], + }, + ], + customModels: { [disabledCodex]: ["5.4"] }, + threadModelSelection: createModelSelection(disabledCodex, "5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("gpt-5.4"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }, + ); + + it("does not alias-expand the fallback instance's custom model", () => { + const { state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: "codex" }), enabled: false }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + ], + customModels: { codex_team: ["5.4"] }, + threadModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("5.4"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + + it("does not carry options through an alias that names another model per instance", () => { + // "antigravity-default" is each Antigravity instance's current model. + const antigravity = ProviderDriverKind.make("antigravity"); + const disabledAntigravity = ProviderInstanceId.make("antigravity"); + const current = { isDefault: true, aliases: [ANTIGRAVITY_DEFAULT_MODEL] }; + const { state, traits } = composerFor({ + providers: [ + { + ...provider({ provider: antigravity, instanceId: disabledAntigravity }), + enabled: false, + models: [reasoningModel("gemini-3-pro", "thinking", current)], + }, + { + ...provider({ provider: antigravity, instanceId: "antigravity_work" }), + models: [reasoningModel("claude-sonnet-4-6", "thinking", current)], + }, + ], + threadModelSelection: createModelSelection(disabledAntigravity, ANTIGRAVITY_DEFAULT_MODEL, [ + { id: "thinking", value: "high" }, + ]), + }); + + expect(state.selectedModel).toBe("claude-sonnet-4-6"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + }); + it("does not carry options to another driver", () => { const codexOnly: ReadonlyArray = [ { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index db52028c397e0..31b58ff7ccc6f 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -37,7 +37,7 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; const DEFAULT_TEXT_GENERATION_INSTANCE_ID = ProviderInstanceId.make("codex"); // Legacy per-driver buckets would leak custom-instance models into stock instances. -function readInstanceCustomModels( +export function readInstanceCustomModels( settings: UnifiedSettings, instanceId: ProviderInstanceId, driverKind: ProviderDriverKind,