diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index e1624a424dbada8803b65e626e13030e25236736..56e429a94e731215a0af578d1f84961e8a51463f 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -620,6 +620,44 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); }); + it("drops custom models the refreshed snapshot no longer carries", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-04-14T00:00:00.000Z", + version: "2.1.0", + models: [ + { + slug: "claude-sonnet-4-6", + name: "Sonnet 4.6", + isCustom: false, + capabilities: null, + }, + { + slug: "removed-custom", + name: "removed-custom", + isCustom: true, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-04-14T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + it("drops stale OpenCode models missing from a successful refresh", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index ff884b5f9006c0e0ff7918602088b9f2b15d8db3..2433f951c9e78a54b23df7adbb45f2048509ae95 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -105,9 +105,13 @@ const mergeProviderModels = ( nextModels: ReadonlyArray, ): ReadonlyArray => { const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider); + // Custom rows are derived from settings and every snapshot carries the full + // current list, so a custom model missing from `nextModels` was removed by + // the user and must not be resurrected from the previous snapshot. + const retainablePreviousModels = previousModels.filter((model) => !model.isCustom); - if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) { - return previousModels; + if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { + return retainablePreviousModels; } const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); @@ -123,7 +127,7 @@ const mergeProviderModels = ( }); const nextSlugs = new Set(nextModels.map((model) => model.slug)); return shouldRetainMissingModels - ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))] + ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] : mergedModels; }; diff --git a/apps/server/src/provider/providerStatusCache.test.ts b/apps/server/src/provider/providerStatusCache.test.ts index 07f67cd7de8f95d3dbde02dc493bee4b7ed9365f..cd7bfff273f867c4b7d6a310dbc37bc4bebf64aa 100644 --- a/apps/server/src/provider/providerStatusCache.test.ts +++ b/apps/server/src/provider/providerStatusCache.test.ts @@ -182,6 +182,35 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => { ); }); + it("does not resurrect cached custom models that settings no longer declare", () => { + const builtIn = { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: emptyCapabilities, + } as const; + const cachedCodex = makeProvider(CODEX_DRIVER, { + models: [ + builtIn, + { + slug: "removed-custom", + name: "removed-custom", + isCustom: true, + capabilities: emptyCapabilities, + }, + ], + }); + const fallbackCodex = makeProvider(CODEX_DRIVER, { models: [builtIn] }); + + assert.deepStrictEqual( + hydrateCachedProvider({ + cachedProvider: cachedCodex, + fallbackProvider: fallbackCodex, + }).models, + [builtIn], + ); + }); + it("ignores stale cached enabled state when the provider is now disabled", () => { const cachedCodex = makeProvider(CODEX_DRIVER, { checkedAt: "2026-04-10T12:00:00.000Z", diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index abc625f1731635277927534512a4c3707b177ce3..31cdff723a8de26a265b0a081248698847442eb3 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -20,7 +20,13 @@ const mergeProviderModels = ( cachedModels: ReadonlyArray, ): ReadonlyArray => { const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug)); - return [...fallbackModels, ...cachedModels.filter((model) => !fallbackSlugs.has(model.slug))]; + // The fallback snapshot is built from current settings and already carries + // every custom model, so cached custom rows that are not in it were removed + // while the cache was stale and must not come back. + return [ + ...fallbackModels, + ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)), + ]; }; export const orderProviderSnapshots = ( diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index ce2eea0f231228bf5e9a9b8b9c5b6d9debaec1b6..74026443ea4982127015b81178291e4ecc6b7467 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -226,6 +226,29 @@ describe("instance-scoped model selection", () => { ]); }); + it("drops server-reported custom rows that are no longer in settings", () => { + const baseProvider = provider({ + instanceId: "claude_openrouter", + models: ["claude-sonnet-4-6"], + }); + const providers = [ + { + ...baseProvider, + models: [ + ...baseProvider.models, + { slug: "removed/custom", name: "removed/custom", isCustom: true, capabilities: {} }, + ], + }, + ]; + const openrouter = deriveProviderInstanceEntries(providers)[0]!; + + expect( + getAppModelOptionsForInstance(settingsWithProviderInstances(), openrouter).map( + (option) => option.slug, + ), + ).toEqual(["claude-sonnet-4-6", "openai/gpt-5.5"]); + }); + it("applies persisted per-instance model ordering", () => { const providers = [ provider({ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 12865d64a9b2a39fd4c1b2340f73bd990bc9c67f..3f0d3e83d7305a05ab06a3ff39f180850408b5a1 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -179,7 +179,12 @@ export function getAppModelOptions( selectedModel?: string | null, ): AppModelOption[] { const rawModels = getProviderModels(providers, provider); - const options: AppModelOption[] = rawModels.map(toAppModelOption); + // Server-reported custom rows mirror settings and can lag a removal, so + // only built-ins are taken from the snapshot; custom rows are rebuilt from + // settings below. + const options: AppModelOption[] = rawModels + .filter((model) => !model.isCustom) + .map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( Arr.filterMap(getProviderModels(providers, provider), (model) => @@ -225,14 +230,18 @@ export function getAppModelOptions( * when present, falling back to the legacy per-kind * `settings.providers[driverKind].customModels` bucket for default * instances only. This keeps two instances of the same kind from leaking - * custom slugs into each other. + * custom slugs into each other. Custom rows reported by the server are + * ignored so a slug removed in Settings disappears without waiting for the + * next provider probe. */ export function getAppModelOptionsForInstance( settings: UnifiedSettings, entry: ProviderInstanceEntry, selectedModel?: string | null, ): AppModelOption[] { - const options: AppModelOption[] = entry.models.map(toAppModelOption); + const options: AppModelOption[] = entry.models + .filter((model) => !model.isCustom) + .map(toAppModelOption); const seen = new Set(options.map((option) => option.slug)); const builtInModelSlugs = new Set( Arr.filterMap(entry.models, (model) =>