| author | |
| committer | |
| log | 941acb4f919bcef337d4d9e1623101bdc30d3616 |
| tree | e4eb0f60fa94599a6d811eb90750b136a06250ec |
| parent | e7deb2aaf4d4d089618dc0c8f393fa0b8b6c77e9 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | }); |
| 622 | 622 | ||
| 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); | ||
| 108 | 112 | ||
| 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 | } |
| 112 | 116 | ||
| 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 shouldRetainMissingModels | 129 | 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 | }; |
| 129 | 133 |
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 | }); |
| 184 | 184 | ||
| 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 | }; |
| 25 | 31 | ||
| 26 | export const orderProviderSnapshots = ( | 32 | export 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 | }); |
| 228 | 228 | ||
| 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-kind | 230 | * when present, falling back to the legacy per-kind |
| 226 | * `settings.providers[driverKind].customModels` bucket for default | 231 | * `settings.providers[driverKind].customModels` bucket for default |
| 227 | * instances only. This keeps two instances of the same kind from leaking | 232 | * 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 | */ |
| 230 | export function getAppModelOptionsForInstance( | 237 | export 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) => |