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
620620 );
621621 });
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
623661 it("drops stale OpenCode models missing from a successful refresh", () => {
624662 const previousProvider = {
625663 instanceId: ProviderInstanceId.make("opencode"),
apps/server/src/provider/Layers/ProviderRegistry.ts+7-3
......@@ -105,9 +105,13 @@ const mergeProviderModels = (
105105 nextModels: ReadonlyArray<ServerProvider["models"][number]>,
106106): ReadonlyArray<ServerProvider["models"][number]> => {
107107 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) {
110 return previousModels;
113 if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) {
114 return retainablePreviousModels;
111115 }
112116
113117 const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const));
......@@ -123,7 +127,7 @@ const mergeProviderModels = (
123127 });
124128 const nextSlugs = new Set(nextModels.map((model) => model.slug));
125129 return shouldRetainMissingModels
126 ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))]
130 ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))]
127131 : mergedModels;
128132};
129133
apps/server/src/provider/providerStatusCache.test.ts+29
......@@ -182,6 +182,35 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
182182 );
183183 });
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
185214 it("ignores stale cached enabled state when the provider is now disabled", () => {
186215 const cachedCodex = makeProvider(CODEX_DRIVER, {
187216 checkedAt: "2026-04-10T12:00:00.000Z",
apps/server/src/provider/providerStatusCache.ts+7-1
......@@ -20,7 +20,13 @@ const mergeProviderModels = (
2020 cachedModels: ReadonlyArray<ServerProvider["models"][number]>,
2121): ReadonlyArray<ServerProvider["models"][number]> => {
2222 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 ];
2430};
2531
2632export const orderProviderSnapshots = (
apps/web/src/modelSelection.test.ts+23
......@@ -226,6 +226,29 @@ describe("instance-scoped model selection", () => {
226226 ]);
227227 });
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
229252 it("applies persisted per-instance model ordering", () => {
230253 const providers = [
231254 provider({
apps/web/src/modelSelection.ts+12-3
......@@ -179,7 +179,12 @@ export function getAppModelOptions(
179179 selectedModel?: string | null,
180180): AppModelOption[] {
181181 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);
183188 const seen = new Set(options.map((option) => option.slug));
184189 const builtInModelSlugs = new Set(
185190 Arr.filterMap(getProviderModels(providers, provider), (model) =>
......@@ -225,14 +230,18 @@ export function getAppModelOptions(
225230 * when present, falling back to the legacy per-kind
226231 * `settings.providers[driverKind].customModels` bucket for default
227232 * 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.
229236 */
230237export function getAppModelOptionsForInstance(
231238 settings: UnifiedSettings,
232239 entry: ProviderInstanceEntry,
233240 selectedModel?: string | null,
234241): AppModelOption[] {
235 const options: AppModelOption[] = entry.models.map(toAppModelOption);
242 const options: AppModelOption[] = entry.models
243 .filter((model) => !model.isCustom)
244 .map(toAppModelOption);
236245 const seen = new Set(options.map((option) => option.slug));
237246 const builtInModelSlugs = new Set(
238247 Arr.filterMap(entry.models, (model) =>