authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-09-02 06:07:19+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 00:07:19-04:00
log941acb4f919bcef337d4d9e1623101bdc30d3616
treee4eb0f60fa94599a6d811eb90750b136a06250ec
parente7deb2aaf4d4d089618dc0c8f393fa0b8b6c77e9
signature Signed by PGP key B5690EEEBB952194

fix(provider): drop removed custom models from the model picker (#9075)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>

6 files changed, 116 insertions(+), 7 deletions(-)

apps/server/src/provider/Layers/ProviderRegistry.test.ts+38
...@@ -620,6 +620,44 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te...@@ -620,6 +620,44 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te
620 );620 );
621 });621 });
622622
623 it("drops custom models the refreshed snapshot no longer carries", () => {
624 const previousProvider = {
625 instanceId: ProviderInstanceId.make("claudeAgent"),
626 driver: ProviderDriverKind.make("claudeAgent"),
627 status: "ready",
628 enabled: true,
629 installed: true,
630 auth: { status: "authenticated" },
631 checkedAt: "2026-04-14T00:00:00.000Z",
632 version: "2.1.0",
633 models: [
634 {
635 slug: "claude-sonnet-4-6",
636 name: "Sonnet 4.6",
637 isCustom: false,
638 capabilities: null,
639 },
640 {
641 slug: "removed-custom",
642 name: "removed-custom",
643 isCustom: true,
644 capabilities: null,
645 },
646 ],
647 slashCommands: [],
648 skills: [],
649 } as const satisfies ServerProvider;
650 const refreshedProvider = {
651 ...previousProvider,
652 checkedAt: "2026-04-14T00:01:00.000Z",
653 models: [previousProvider.models[0]],
654 } satisfies ServerProvider;
655
656 assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [
657 ...refreshedProvider.models,
658 ]);
659 });
660
623 it("drops stale OpenCode models missing from a successful refresh", () => {661 it("drops stale OpenCode models missing from a successful refresh", () => {
624 const previousProvider = {662 const previousProvider = {
625 instanceId: ProviderInstanceId.make("opencode"),663 instanceId: ProviderInstanceId.make("opencode"),
apps/server/src/provider/Layers/ProviderRegistry.ts+7-3
...@@ -105,9 +105,13 @@ const mergeProviderModels = (...@@ -105,9 +105,13 @@ const mergeProviderModels = (
105 nextModels: ReadonlyArray<ServerProvider["models"][number]>,105 nextModels: ReadonlyArray<ServerProvider["models"][number]>,
106): ReadonlyArray<ServerProvider["models"][number]> => {106): ReadonlyArray<ServerProvider["models"][number]> => {
107 const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider);107 const shouldRetainMissingModels = shouldRetainMissingProviderModels(provider);
108 // Custom rows are derived from settings and every snapshot carries the full
109 // current list, so a custom model missing from `nextModels` was removed by
110 // the user and must not be resurrected from the previous snapshot.
111 const retainablePreviousModels = previousModels.filter((model) => !model.isCustom);
108112
109 if (shouldRetainMissingModels && nextModels.length === 0 && previousModels.length > 0) {113 if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) {
110 return previousModels;114 return retainablePreviousModels;
111 }115 }
112116
113 const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));117 const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));
...@@ -123,7 +127,7 @@ const mergeProviderModels = (...@@ -123,7 +127,7 @@ const mergeProviderModels = (
123 });127 });
124 const nextSlugs = new Set(nextModels.map((model) => model.slug));128 const nextSlugs = new Set(nextModels.map((model) => model.slug));
125 return shouldRetainMissingModels129 return shouldRetainMissingModels
126 ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]130 ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))]
127 : mergedModels;131 : mergedModels;
128};132};
129133
apps/server/src/provider/providerStatusCache.test.ts+29
...@@ -182,6 +182,35 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {...@@ -182,6 +182,35 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
182 );182 );
183 });183 });
184184
185 it("does not resurrect cached custom models that settings no longer declare", () => {
186 const builtIn = {
187 slug: "gpt-5.4",
188 name: "GPT-5.4",
189 isCustom: false,
190 capabilities: emptyCapabilities,
191 } as const;
192 const cachedCodex = makeProvider(CODEX_DRIVER, {
193 models: [
194 builtIn,
195 {
196 slug: "removed-custom",
197 name: "removed-custom",
198 isCustom: true,
199 capabilities: emptyCapabilities,
200 },
201 ],
202 });
203 const fallbackCodex = makeProvider(CODEX_DRIVER, { models: [builtIn] });
204
205 assert.deepStrictEqual(
206 hydrateCachedProvider({
207 cachedProvider: cachedCodex,
208 fallbackProvider: fallbackCodex,
209 }).models,
210 [builtIn],
211 );
212 });
213
185 it("ignores stale cached enabled state when the provider is now disabled", () => {214 it("ignores stale cached enabled state when the provider is now disabled", () => {
186 const cachedCodex = makeProvider(CODEX_DRIVER, {215 const cachedCodex = makeProvider(CODEX_DRIVER, {
187 checkedAt: "2026-04-10T12:00:00.000Z",216 checkedAt: "2026-04-10T12:00:00.000Z",
apps/server/src/provider/providerStatusCache.ts+7-1
...@@ -20,7 +20,13 @@ const mergeProviderModels = (...@@ -20,7 +20,13 @@ const mergeProviderModels = (
20 cachedModels: ReadonlyArray<ServerProvider["models"][number]>,20 cachedModels: ReadonlyArray<ServerProvider["models"][number]>,
21): ReadonlyArray<ServerProvider["models"][number]> => {21): ReadonlyArray<ServerProvider["models"][number]> => {
22 const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug));22 const fallbackSlugs = new Set(fallbackModels.map((model) => model.slug));
23 return [...fallbackModels, ...cachedModels.filter((model) => !fallbackSlugs.has(model.slug))];23 // The fallback snapshot is built from current settings and already carries
24 // every custom model, so cached custom rows that are not in it were removed
25 // while the cache was stale and must not come back.
26 return [
27 ...fallbackModels,
28 ...cachedModels.filter((model) => !model.isCustom && !fallbackSlugs.has(model.slug)),
29 ];
24};30};
2531
26export const orderProviderSnapshots = (32export const orderProviderSnapshots = (
apps/web/src/modelSelection.test.ts+23
...@@ -226,6 +226,29 @@ describe("instance-scoped model selection", () => {...@@ -226,6 +226,29 @@ describe("instance-scoped model selection", () => {
226 ]);226 ]);
227 });227 });
228228
229 it("drops server-reported custom rows that are no longer in settings", () => {
230 const baseProvider = provider({
231 instanceId: "claude_openrouter",
232 models: ["claude-sonnet-4-6"],
233 });
234 const providers = [
235 {
236 ...baseProvider,
237 models: [
238 ...baseProvider.models,
239 { slug: "removed/custom", name: "removed/custom", isCustom: true, capabilities: {} },
240 ],
241 },
242 ];
243 const openrouter = deriveProviderInstanceEntries(providers)[0]!;
244
245 expect(
246 getAppModelOptionsForInstance(settingsWithProviderInstances(), openrouter).map(
247 (option) => option.slug,
248 ),
249 ).toEqual(["claude-sonnet-4-6", "openai/gpt-5.5"]);
250 });
251
229 it("applies persisted per-instance model ordering", () => {252 it("applies persisted per-instance model ordering", () => {
230 const providers = [253 const providers = [
231 provider({254 provider({
apps/web/src/modelSelection.ts+12-3
...@@ -179,7 +179,12 @@ export function getAppModelOptions(...@@ -179,7 +179,12 @@ export function getAppModelOptions(
179 selectedModel?: string | null,179 selectedModel?: string | null,
180): AppModelOption[] {180): AppModelOption[] {
181 const rawModels = getProviderModels(providers, provider);181 const rawModels = getProviderModels(providers, provider);
182 const options: AppModelOption[] = rawModels.map(toAppModelOption);182 // Server-reported custom rows mirror settings and can lag a removal, so
183 // only built-ins are taken from the snapshot; custom rows are rebuilt from
184 // settings below.
185 const options: AppModelOption[] = rawModels
186 .filter((model) => !model.isCustom)
187 .map(toAppModelOption);
183 const seen = new Set(options.map((option) => option.slug));188 const seen = new Set(options.map((option) => option.slug));
184 const builtInModelSlugs = new Set(189 const builtInModelSlugs = new Set(
185 Arr.filterMap(getProviderModels(providers, provider), (model) =>190 Arr.filterMap(getProviderModels(providers, provider), (model) =>
...@@ -225,14 +230,18 @@ export function getAppModelOptions(...@@ -225,14 +230,18 @@ export function getAppModelOptions(
225 * when present, falling back to the legacy per-kind230 * when present, falling back to the legacy per-kind
226 * `settings.providers[driverKind].customModels` bucket for default231 * `settings.providers[driverKind].customModels` bucket for default
227 * instances only. This keeps two instances of the same kind from leaking232 * instances only. This keeps two instances of the same kind from leaking
228 * custom slugs into each other.233 * custom slugs into each other. Custom rows reported by the server are
234 * ignored so a slug removed in Settings disappears without waiting for the
235 * next provider probe.
229 */236 */
230export function getAppModelOptionsForInstance(237export function getAppModelOptionsForInstance(
231 settings: UnifiedSettings,238 settings: UnifiedSettings,
232 entry: ProviderInstanceEntry,239 entry: ProviderInstanceEntry,
233 selectedModel?: string | null,240 selectedModel?: string | null,
234): AppModelOption[] {241): AppModelOption[] {
235 const options: AppModelOption[] = entry.models.map(toAppModelOption);242 const options: AppModelOption[] = entry.models
243 .filter((model) => !model.isCustom)
244 .map(toAppModelOption);
236 const seen = new Set(options.map((option) => option.slug));245 const seen = new Set(options.map((option) => option.slug));
237 const builtInModelSlugs = new Set(246 const builtInModelSlugs = new Set(
238 Arr.filterMap(entry.models, (model) =>247 Arr.filterMap(entry.models, (model) =>