| 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 | 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 | 661 | it("drops stale OpenCode models missing from a successful refresh", () => { |
| 624 | 662 | const previousProvider = { |
| 625 | 663 | instanceId: ProviderInstanceId.make("opencode"), |
apps/server/src/provider/Layers/ProviderRegistry.ts+7-3| ... | ... | @@ -105,9 +105,13 @@ const mergeProviderModels = ( |
| 105 | 105 | nextModels: ReadonlyArray<ServerProvider["models"][number]>, |
| 106 | 106 | ): ReadonlyArray<ServerProvider["models"][number]> => { |
| 107 | 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) { | |
| 110 | return previousModels; | |
| 113 | if (shouldRetainMissingModels && nextModels.length === 0 && retainablePreviousModels.length > 0) { | |
| 114 | return retainablePreviousModels; | |
| 111 | 115 | } |
| 112 | 116 | |
| 113 | 117 | const previousBySlug = new Map(previousModels.map((model) => [model.slug, model] as const)); |
| ... | ... | @@ -123,7 +127,7 @@ const mergeProviderModels = ( |
| 123 | 127 | }); |
| 124 | 128 | const nextSlugs = new Set(nextModels.map((model) => model.slug)); |
| 125 | 129 | return shouldRetainMissingModels |
| 126 | ? [...mergedModels, ...previousModels.filter((model) => !nextSlugs.has(model.slug))] | |
| 130 | ? [...mergedModels, ...retainablePreviousModels.filter((model) => !nextSlugs.has(model.slug))] | |
| 127 | 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 | 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 | 214 | it("ignores stale cached enabled state when the provider is now disabled", () => { |
| 186 | 215 | const cachedCodex = makeProvider(CODEX_DRIVER, { |
| 187 | 216 | checkedAt: "2026-04-10T12:00:00.000Z", |
apps/server/src/provider/providerStatusCache.ts+7-1| ... | ... | @@ -20,7 +20,13 @@ const mergeProviderModels = ( |
| 20 | 20 | cachedModels: ReadonlyArray<ServerProvider["models"][number]>, |
| 21 | 21 | ): ReadonlyArray<ServerProvider["models"][number]> => { |
| 22 | 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 | 32 | export const orderProviderSnapshots = ( |
apps/web/src/modelSelection.test.ts+23| ... | ... | @@ -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 | 252 | it("applies persisted per-instance model ordering", () => { |
| 230 | 253 | const providers = [ |
| 231 | 254 | provider({ |
apps/web/src/modelSelection.ts+12-3| ... | ... | @@ -179,7 +179,12 @@ export function getAppModelOptions( |
| 179 | 179 | selectedModel?: string | null, |
| 180 | 180 | ): AppModelOption[] { |
| 181 | 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 | 188 | const seen = new Set(options.map((option) => option.slug)); |
| 184 | 189 | const builtInModelSlugs = new Set( |
| 185 | 190 | Arr.filterMap(getProviderModels(providers, provider), (model) => |
| ... | ... | @@ -225,14 +230,18 @@ export function getAppModelOptions( |
| 225 | 230 | * when present, falling back to the legacy per-kind |
| 226 | 231 | * `settings.providers[driverKind].customModels` bucket for default |
| 227 | 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 | 237 | export function getAppModelOptionsForInstance( |
| 231 | 238 | settings: UnifiedSettings, |
| 232 | 239 | entry: ProviderInstanceEntry, |
| 233 | 240 | selectedModel?: string | null, |
| 234 | 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 | 245 | const seen = new Set(options.map((option) => option.slug)); |
| 237 | 246 | const builtInModelSlugs = new Set( |
| 238 | 247 | Arr.filterMap(entry.models, (model) => |