From d7698bbf6212520eb35cb3d34cfc3ae60b44b397 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 23 Aug 2026 16:54:36 +0800 Subject: [PATCH 1/2] fix(core): preserve fallback models after failed discovery --- .../core/src/__tests__/model-catalog.test.ts | 32 +++++++++++++++++++ packages/core/src/model-catalog.ts | 25 +++++++++------ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 3940a21afc..341f513a89 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -141,6 +141,38 @@ test('the catalog and the readiness gate agree that no catalog is a veto', () => assert.equal(buildModelCatalogEntries(catalog('fallback'))[0]?.unavailableReason, 'none'); }); +test('failed or pending discovery keeps the static fallback catalog visible', () => { + const entries = buildModelCatalogEntries({ + providerType: 'openai' as const, + defaultModel: 'gpt-5.4', + models: [], + fallbackModels: ['gpt-5.4', 'gpt-5-mini'], + }); + + assert.deepEqual( + entries.map(({ id, source, unavailableReason }) => [id, source, unavailableReason]), + [ + ['gpt-5.4', 'static_catalog', 'none'], + ['gpt-5-mini', 'static_catalog', 'none'], + ], + ); +}); + +test('an explicitly fetched empty inventory remains authoritative', () => { + const entries = buildModelCatalogEntries({ + providerType: 'openai' as const, + defaultModel: 'gpt-5.4', + models: [], + modelSource: 'fetched', + fallbackModels: ['gpt-5.4', 'gpt-5-mini'], + }); + + assert.deepEqual( + entries.map(({ id, unavailableReason }) => [id, unavailableReason]), + [['gpt-5.4', 'not_in_live_list']], + ); +}); + test('connection catalogs preserve user-choice provenance without inventing availability', () => { const connection: LlmConnection = { slug: 'zai-live', diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index dfec9fbe5f..ad1e1dc9b5 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -166,11 +166,11 @@ const DEFAULT_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000; export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCatalogEntry[] { const liveModels = input.models; - const modelSource = input.modelSource ?? (liveModels ? 'fetched' : 'fallback'); - // The RAW `modelSource`, not the defaulted one above: an empty `models` array - // is truthy, so defaulting turned "nobody has asked yet" into "the provider - // enumerated nothing" and every model vanished from the picker while - // `authorizeConnectionModel` was still admitting it. + const modelSource = + input.modelSource ?? + (liveModels !== undefined && liveModels.length > 0 ? 'fetched' : 'fallback'); + // The RAW `modelSource`, not a source inferred from the array, distinguishes + // a failed discovery from an explicit empty provider response. const inventory = classifyConnectionModelInventory({ providerType: input.providerType, models: input.models, @@ -179,12 +179,17 @@ export function buildModelCatalogEntries(input: BuildModelCatalogInput): ModelCa const normalizedDefaultModel = input.defaultModel?.trim(); const recommendedRanks = recommendedRanksForProvider(input.providerType, input.fallbackModels); const source = inventory === 'live' ? 'provider_api' : 'static_catalog'; + // An empty array without a successful discovery source is the persisted + // shape of a failed or not-yet-run discovery. It must not hide the static + // fallback catalog from the picker. An empty fetched array is different: it + // is an authoritative provider response and should remain empty. const rawModels = - liveModels ?? - (input.fallbackModels ?? []).map((id) => ({ - id, - ...displayNameForKnownModel(input.providerType, id), - })); + liveModels !== undefined && (liveModels.length > 0 || modelSource === 'fetched') + ? liveModels + : (input.fallbackModels ?? []).map((id) => ({ + id, + ...displayNameForKnownModel(input.providerType, id), + })); const savedChoiceSources = savedChoiceSourcesById(input.savedModelIds); const seen = new Set(); const entries = rawModels From cb3770233099d5ca454effed4ceb3b930964b895 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 23 Aug 2026 18:55:45 +0800 Subject: [PATCH 2/2] test(core): cover persisted empty discovery catalog --- .../core/src/__tests__/model-catalog.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index 341f513a89..723955b76a 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -173,6 +173,38 @@ test('an explicitly fetched empty inventory remains authoritative', () => { ); }); +test('a persisted empty discovery result preserves the connection fallback through the public catalog path', () => { + const connection: LlmConnection = { + slug: 'custom-relay', + name: 'Custom relay', + providerType: 'openai', + defaultModel: 'gpt-5.4', + enabled: true, + models: [], + createdAt: 1, + updatedAt: 1, + }; + + const entries = buildConnectionModelCatalogEntries({ + connection, + fallbackModels: ['gpt-5.4', 'gpt-5-mini'], + providerAvailable: true, + authOk: true, + }); + + assert.deepEqual( + entries.map(({ id, unavailableReason, provenance }) => [ + id, + unavailableReason, + provenance.modelSource, + ]), + [ + ['gpt-5.4', 'none', 'fallback'], + ['gpt-5-mini', 'none', 'fallback'], + ], + ); +}); + test('connection catalogs preserve user-choice provenance without inventing availability', () => { const connection: LlmConnection = { slug: 'zai-live',