diff --git a/packages/core/src/__tests__/model-catalog.test.ts b/packages/core/src/__tests__/model-catalog.test.ts index dea3eeea2b..85ac3287b3 100644 --- a/packages/core/src/__tests__/model-catalog.test.ts +++ b/packages/core/src/__tests__/model-catalog.test.ts @@ -159,6 +159,70 @@ 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('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', diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts index e13e95b6f4..72a56c67a4 100644 --- a/packages/core/src/model-catalog.ts +++ b/packages/core/src/model-catalog.ts @@ -167,11 +167,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, @@ -180,12 +180,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