Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/core/src/__tests__/model-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,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',
Expand Down
25 changes: 15 additions & 10 deletions packages/core/src/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Pre-existing, not introduced here (anchored to this hunk because the affected line at :270 is unchanged and cannot take an inline comment) — but it sits directly adjacent to what this PR fixes, so it is worth naming while the area is being touched.

A connection that succeeded at discovery (modelSource === 'fetched') but whose models were all later quarantined ends up here with [] after the filter, still carrying 'fetched'. That takes the "authoritatively empty" branch, so the picker renders empty and no fallback catalog is offered — even though the static fallback may contain usable models.

Trigger → path → outcome: a connection works normally, every one of its model ids later lands in brokenModelIds, the user opens the model picker and sees nothing.

From the user's side this is indistinguishable from the failed-discovery case this PR is fixing, but only the latter is covered. Behavior is unchanged from before this PR (the old code also reduced to rawModels = liveModels = [] here), so this is not a regression — hence P3 rather than a blocker.

Smallest fix: check for emptiness after filtering — if the pre-filter list was non-empty and the post-filter list is empty, treat it as having no usable discovery result and fall back. A production-seam test would set models such that every id hits brokenModelIds with modelSource: 'fetched', and assert fallback catalog entries are still returned.

...displayNameForKnownModel(input.providerType, id),
}));
const savedChoiceSources = savedChoiceSourcesById(input.savedModelIds);
const seen = new Set<string>();
const entries = rawModels
Expand Down
Loading