diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index a35fb752b446..cf24a2a89651 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -195,6 +195,86 @@ describe("instance-scoped model selection", () => { ).not.toContain("openai/gpt-5.5"); }); + it("removes a custom model immediately when the provider snapshot is stale", () => { + const baseProvider = provider({ + instanceId: "claudeAgent", + models: ["claude-sonnet-4-6"], + }); + const staleProviders: ServerProvider[] = [ + { + ...baseProvider, + models: [ + ...baseProvider.models, + { + slug: "removed-preview-model", + name: "Removed Preview Model", + isCustom: true, + capabilities: {}, + }, + ], + }, + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + ...settingsWithProviderInstances().providerInstances, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { customModels: [] }, + }, + }, + }; + const stock = deriveProviderInstanceEntries(staleProviders)[0]!; + + expect(getAppModelOptionsForInstance(settings, stock).map((option) => option.slug)).toEqual([ + "claude-sonnet-4-6", + ]); + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claudeAgent"), + settings, + staleProviders, + "removed-preview-model", + ), + ).toBe("claude-sonnet-4-6"); + }); + + it("does not fall back to a stale custom model when no configured models remain", () => { + const baseProvider = provider({ instanceId: "claudeAgent" }); + const staleProviders: ServerProvider[] = [ + { + ...baseProvider, + models: [ + { + slug: "removed-preview-model", + name: "Removed Preview Model", + isCustom: true, + capabilities: {}, + }, + ], + }, + ]; + const settings: UnifiedSettings = { + ...settingsWithProviderInstances(), + providerInstances: { + ...settingsWithProviderInstances().providerInstances, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { customModels: [] }, + }, + }, + }; + + expect( + resolveAppModelSelectionForInstance( + ProviderInstanceId.make("claudeAgent"), + settings, + staleProviders, + "removed-preview-model", + ), + ).toBeNull(); + }); + it("hides server models from the instance option list", () => { const providers = [ provider({ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 2763245299db..b50204e54e26 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -152,10 +152,16 @@ export function getAppModelOptions( provider: ProviderDriverKind, _selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = getProviderModels(providers, provider).map(toAppModelOption); + const liveModels = getProviderModels(providers, provider); + const options: AppModelOption[] = liveModels + .filter((model) => !model.isCustom) + .map(toAppModelOption); + const liveCustomModelsBySlug = new Map( + liveModels.filter((model) => model.isCustom).map((model) => [model.slug, model] as const), + ); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( - Arr.filterMap(getProviderModels(providers, provider), (model) => + Arr.filterMap(liveModels, (model) => model.isCustom ? Result.failVoid : Result.succeed(model.slug), ), ); @@ -172,11 +178,16 @@ export function getAppModelOptions( } seen.add(slug); - options.push({ - slug, - name: slug, - isCustom: true, - }); + const liveCustomModel = liveCustomModelsBySlug.get(slug); + options.push( + liveCustomModel + ? toAppModelOption(liveCustomModel) + : { + slug, + name: slug, + isCustom: true, + }, + ); } return applyInstanceModelPreferences( @@ -200,7 +211,12 @@ export function getAppModelOptionsForInstance( settings: UnifiedSettings, entry: ProviderInstanceEntry, ): AppModelOption[] { - const options: AppModelOption[] = entry.models.map(toAppModelOption); + const options: AppModelOption[] = entry.models + .filter((model) => !model.isCustom) + .map(toAppModelOption); + const liveCustomModelsBySlug = new Map( + entry.models.filter((model) => model.isCustom).map((model) => [model.slug, model] as const), + ); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( Arr.filterMap(entry.models, (model) => @@ -215,7 +231,10 @@ export function getAppModelOptionsForInstance( } seen.add(slug); - options.push({ slug, name: slug, isCustom: true }); + const liveCustomModel = liveCustomModelsBySlug.get(slug); + options.push( + liveCustomModel ? toAppModelOption(liveCustomModel) : { slug, name: slug, isCustom: true }, + ); } return applyInstanceModelPreferences( @@ -253,8 +272,8 @@ export function resolveAppModelSelectionForInstance( resolveSelectableModel(entry.driverKind, selectedModel, options) ?? options.find((option) => option.isDefault)?.slug ?? options[0]?.slug ?? - entry.models.find((model) => model.isDefault)?.slug ?? - entry.models[0]?.slug ?? + entry.models.find((model) => model.isDefault && !model.isCustom)?.slug ?? + entry.models.find((model) => !model.isCustom)?.slug ?? null ); }