From dca7ddeaa18e68b10f35d6cedb2757552fd07f62 Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Fri, 30 Oct 2026 23:50:08 +0200 Subject: [PATCH 1/4] fix(web): composer fallback keeps the effort of a model saved under an alias --- apps/web/src/composerDraftStore.ts | 11 +++-- apps/web/src/modelSelection.test.ts | 73 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 08faddc903708..75c6df78b6a06 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1204,12 +1204,13 @@ function withFallbackInstanceOptions(input: { (provider) => provider.instanceId === requestedInstanceId, )?.driver; // The model of the selection the options came from, which can differ from the shown model's source. + // It can be a driver alias such as "5.4" for "gpt-5.4". Catalog aliases are not used: Antigravity's + // "antigravity-default" names a different model on each instance. const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; - if ( - !requestedOptions || - requestedDriver !== input.selectedProvider || - requestedModel !== input.selectedModel - ) { + const sameModel = + requestedModel === input.selectedModel || + normalizeModelSlug(requestedModel, input.selectedProvider) === input.selectedModel; + if (!requestedOptions || requestedDriver !== 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..1a7dda1b40934 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -923,6 +923,79 @@ 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("keeps the effort of a thread saved under a driver alias", () => { + const disabledCodex = ProviderInstanceId.make("codex"); + const fallbackCodex = ProviderInstanceId.make("codex_team"); + const models = [reasoningModel("gpt-5.4", "reasoningEffort")]; + const { selectedInstanceId, state, traits } = composerFor({ + providers: [ + { ...provider({ provider: codex, instanceId: disabledCodex }), enabled: false, models }, + { ...provider({ provider: codex, instanceId: fallbackCodex }), models }, + ], + // "5.4" is the driver alias of gpt-5.4. + threadModelSelection: createModelSelection(disabledCodex, "5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(selectedInstanceId).toBe(fallbackCodex); + expect(state.selectedModel).toBe("gpt-5.4"); + expect(traits.promptEffort).toBe("xhigh"); + }); + + 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 = [ { From ef81c5711595e7a35919c4e8cd22d6e57fb98d0e Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Sat, 31 Oct 2026 00:05:08 +0200 Subject: [PATCH 2/4] fix(web): fallback leaves a custom model's exact slug unexpanded --- apps/web/src/composerDraftStore.ts | 14 ++++++++------ apps/web/src/modelSelection.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 75c6df78b6a06..2b638b3e6ebf2 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1200,17 +1200,19 @@ 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. - // It can be a driver alias such as "5.4" for "gpt-5.4". Catalog aliases are not used: Antigravity's - // "antigravity-default" names a different model on each instance. + // It can be a driver alias such as "5.4" for "gpt-5.4", expanded only when the requested instance + // does not offer that exact slug (a custom model can be named "5.4"). Catalog aliases are not used: + // Antigravity's "antigravity-default" names a different model on each instance. const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; const sameModel = requestedModel === input.selectedModel || - normalizeModelSlug(requestedModel, input.selectedProvider) === input.selectedModel; - if (!requestedOptions || requestedDriver !== input.selectedProvider || !sameModel) { + (!requestedProvider?.models.some((model) => model.slug === requestedModel) && + normalizeModelSlug(requestedModel, input.selectedProvider) === 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 1a7dda1b40934..fd477f23bcb68 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -969,6 +969,30 @@ describe("composer fallback to another instance of the same driver", () => { expect(traits.promptEffort).toBe("xhigh"); }); + it("does not alias-expand a custom model the requested instance offers", () => { + // A disabled Codex instance reports only its custom models; this one is named "5.4". + const disabledCodex = ProviderInstanceId.make("codex"); + const { state, traits } = composerFor({ + providers: [ + { + ...provider({ provider: codex, instanceId: disabledCodex }), + enabled: false, + models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], + }, + { + ...provider({ provider: codex, instanceId: "codex_team" }), + models: [reasoningModel("gpt-5.4", "reasoningEffort")], + }, + ], + threadModelSelection: createModelSelection(disabledCodex, "5.4", [ + { id: "reasoningEffort", value: "xhigh" }, + ]), + }); + + expect(state.selectedModel).toBe("gpt-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"); From e88dcb0d918b378eedd1a45a5c17ea94395fbc0c Mon Sep 17 00:00:00 2001 From: Mantas Kantautas Date: Sat, 31 Oct 2026 00:14:16 +0200 Subject: [PATCH 3/4] fix(web): fallback reads an unavailable instance's custom models from settings --- apps/web/src/composerDraftStore.ts | 21 ++++++--- apps/web/src/modelSelection.test.ts | 66 +++++++++++++++++++---------- apps/web/src/modelSelection.ts | 2 +- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2b638b3e6ebf2..4fd17c83c96d3 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 ( @@ -1203,14 +1208,18 @@ function withFallbackInstanceOptions(input: { const requestedProvider = input.providers.find( (provider) => provider.instanceId === requestedInstanceId, ); - // The model of the selection the options came from, which can differ from the shown model's source. - // It can be a driver alias such as "5.4" for "gpt-5.4", expanded only when the requested instance - // does not offer that exact slug (a custom model can be named "5.4"). Catalog aliases are not used: - // Antigravity's "antigravity-default" names a different model on each instance. + // A driver alias such as "5.4" expands only when the requested instance does not offer that exact + // slug; custom models count, from settings too, since an unavailable instance reports none. + // Catalog aliases are not used: Antigravity's "antigravity-default" differs per instance. const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; + const requestedOffersSlug = + requestedProvider?.models.some((model) => model.slug === requestedModel) || + readInstanceCustomModels(input.settings, requestedInstanceId, input.selectedProvider).some( + (model) => model.slug === requestedModel, + ); const sameModel = requestedModel === input.selectedModel || - (!requestedProvider?.models.some((model) => model.slug === requestedModel) && + (!requestedOffersSlug && normalizeModelSlug(requestedModel, input.selectedProvider) === input.selectedModel); if (!requestedOptions || requestedProvider?.driver !== input.selectedProvider || !sameModel) { return modelOptions; diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index fd477f23bcb68..4d8c01290553c 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, + })), + }, }, ]), ), @@ -969,29 +976,42 @@ describe("composer fallback to another instance of the same driver", () => { expect(traits.promptEffort).toBe("xhigh"); }); - it("does not alias-expand a custom model the requested instance offers", () => { - // A disabled Codex instance reports only its custom models; this one is named "5.4". - const disabledCodex = ProviderInstanceId.make("codex"); - const { state, traits } = composerFor({ - providers: [ - { - ...provider({ provider: codex, instanceId: disabledCodex }), - enabled: false, - models: [reasoningModel("5.4", "reasoningEffort", { isCustom: true })], - }, - { - ...provider({ provider: codex, instanceId: "codex_team" }), - models: [reasoningModel("gpt-5.4", "reasoningEffort")], - }, - ], - threadModelSelection: createModelSelection(disabledCodex, "5.4", [ - { id: "reasoningEffort", value: "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(); - }); + expect(state.selectedModel).toBe("gpt-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. 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, From a293e904056a4c6601ed8223b1d46c10f68638b5 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 04:49:44 +0000 Subject: [PATCH 4/4] fix(web): match static aliases on both fallback instances --- apps/web/src/composerDraftStore.ts | 23 ++++++++++++--------- apps/web/src/modelSelection.test.ts | 32 ++++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 4fd17c83c96d3..8588ddbc28724 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1208,19 +1208,22 @@ function withFallbackInstanceOptions(input: { const requestedProvider = input.providers.find( (provider) => provider.instanceId === requestedInstanceId, ); - // A driver alias such as "5.4" expands only when the requested instance does not offer that exact - // slug; custom models count, from settings too, since an unavailable instance reports none. - // Catalog aliases are not used: Antigravity's "antigravity-default" differs per instance. const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; - const requestedOffersSlug = - requestedProvider?.models.some((model) => model.slug === requestedModel) || - readInstanceCustomModels(input.settings, requestedInstanceId, input.selectedProvider).some( - (model) => model.slug === requestedModel, - ); + // 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 || - (!requestedOffersSlug && - normalizeModelSlug(requestedModel, input.selectedProvider) === input.selectedModel); + modelIdentity(requestedInstanceId, requestedModel) === + modelIdentity(selectedInstanceId, input.selectedModel); if (!requestedOptions || requestedProvider?.driver !== input.selectedProvider || !sameModel) { return modelOptions; } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 4d8c01290553c..01c1a7534ed3c 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -956,23 +956,26 @@ describe("composer fallback to another instance of the same driver", () => { }, }); - it("keeps the effort of a thread saved under a driver alias", () => { + 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("gpt-5.4", "reasoningEffort")]; + 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 }, ], - // "5.4" is the driver alias of gpt-5.4. - threadModelSelection: createModelSelection(disabledCodex, "5.4", [ + threadModelSelection: createModelSelection(disabledCodex, savedModel, [ { id: "reasoningEffort", value: "xhigh" }, ]), }); expect(selectedInstanceId).toBe(fallbackCodex); - expect(state.selectedModel).toBe("gpt-5.4"); + expect(state.selectedModel).toBe(fallbackModel); expect(traits.promptEffort).toBe("xhigh"); }); @@ -1013,6 +1016,25 @@ describe("composer fallback to another instance of the same driver", () => { }, ); + 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");