diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 3bf14e26fc6..d1d2d64b001 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -468,6 +468,50 @@ describe("modelsListCommand forward-compat", () => { ]); }); + it("falls back to registry-backed rows when the fast-path catalog is empty", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); + mocks.loadProviderCatalogModelsForList.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [{ ...OPENAI_CODEX_MODEL }], + availableKeys: new Set(["openai-codex/gpt-5.4"]), + registry: { + getAll: () => [{ ...OPENAI_CODEX_MODEL }], + }, + }); + const runtime = createRuntime(); + + await modelsListCommand( + { all: true, provider: "openai-codex", json: true }, + runtime as never, + ); + + expect(mocks.loadModelRegistry).toHaveBeenCalledWith( + mocks.resolvedConfig, + expect.objectContaining({ + providerFilter: "openai-codex", + }), + ); + expect(mocks.loadProviderCatalogModelsForList).toHaveBeenNthCalledWith(1, { + cfg: mocks.resolvedConfig, + agentDir: "/tmp/openclaw-agent", + providerFilter: "openai-codex", + staticOnly: true, + }); + expect(mocks.loadProviderCatalogModelsForList).toHaveBeenNthCalledWith(2, { + cfg: mocks.resolvedConfig, + agentDir: "/tmp/openclaw-agent", + providerFilter: "openai-codex", + staticOnly: undefined, + }); + expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([ + expect.objectContaining({ + key: "openai-codex/gpt-5.4", + available: true, + }), + ]); + }); + it("keeps the registry path for provider filters without static catalog coverage", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(false); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index e1c26a4cbbe..ba28db382fe 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -70,13 +70,16 @@ export async function modelsListCommand( providerFilter, useProviderCatalogFastPath, }); + const loadRegistryState = async () => { + const loaded = await loadListModelRegistry(cfg, { providerFilter }); + modelRegistry = loaded.registry; + discoveredKeys = loaded.discoveredKeys; + availableKeys = loaded.availableKeys; + availabilityErrorMessage = loaded.availabilityErrorMessage; + }; try { if (shouldLoadRegistry) { - const loaded = await loadListModelRegistry(cfg, { providerFilter }); - modelRegistry = loaded.registry; - discoveredKeys = loaded.discoveredKeys; - availableKeys = loaded.availableKeys; - availabilityErrorMessage = loaded.availabilityErrorMessage; + await loadRegistryState(); } else if (!opts.all) { const loaded = loadConfiguredListModelRegistry(cfg, entries, { providerFilter }); modelRegistry = loaded.registry; @@ -88,14 +91,7 @@ export async function modelsListCommand( process.exitCode = 1; return; } - if (availabilityErrorMessage !== undefined) { - runtime.error( - `Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`, - ); - } - - const rows: ModelRow[] = []; - const rowContext = { + const buildRowContext = (skipRuntimeModelSuppression: boolean) => ({ cfg, agentDir, authStore, @@ -106,16 +102,35 @@ export async function modelsListCommand( provider: providerFilter, local: opts.local, }, - skipRuntimeModelSuppression: useProviderCatalogFastPath, - }; + skipRuntimeModelSuppression, + }); + const rows: ModelRow[] = []; if (opts.all) { - await appendAllModelRowSources({ + let rowContext = buildRowContext(useProviderCatalogFastPath); + const initialAppend = await appendAllModelRowSources({ rows, context: rowContext, modelRegistry, useProviderCatalogFastPath, }); + if (initialAppend.requiresRegistryFallback) { + try { + await loadRegistryState(); + } catch (err) { + runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`); + process.exitCode = 1; + return; + } + rows.length = 0; + rowContext = buildRowContext(false); + await appendAllModelRowSources({ + rows, + context: rowContext, + modelRegistry, + useProviderCatalogFastPath: false, + }); + } } else { const registry = modelRegistry; if (!registry) { @@ -127,10 +142,16 @@ export async function modelsListCommand( rows, entries, modelRegistry: registry, - context: rowContext, + context: buildRowContext(false), }); } + if (availabilityErrorMessage !== undefined) { + runtime.error( + `Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`, + ); + } + if (rows.length === 0) { runtime.log("No models found."); return; diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index e0925fd3722..e894c9ccbf2 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -167,6 +167,30 @@ describe("loadProviderCatalogModelsForList", () => { ); }); + it("returns an empty catalog when a static provider catalog throws", async () => { + providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValueOnce([ + { + id: "moonshot", + pluginId: "moonshot", + label: "Moonshot", + auth: [], + staticCatalog: { + run: async () => { + throw new Error("catalog offline"); + }, + }, + }, + ]); + + await expect( + loadProviderCatalogModelsForList({ + ...baseParams, + providerFilter: "moonshot", + staticOnly: true, + }), + ).resolves.toEqual([]); + }); + it("only skips registry for providers with actual static catalogs", async () => { providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValue([catalogOnlyProvider]); diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index 788ee9930cc..8330aded170 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -16,6 +16,10 @@ type AllModelRowSources = { useProviderCatalogFastPath: boolean; }; +type AppendAllModelRowSourcesResult = { + requiresRegistryFallback: boolean; +}; + export function modelRowSourcesRequireRegistry(params: { all?: boolean; providerFilter?: string; @@ -30,7 +34,9 @@ export function modelRowSourcesRequireRegistry(params: { return true; } -export async function appendAllModelRowSources(params: AllModelRowSources): Promise { +export async function appendAllModelRowSources( + params: AllModelRowSources, +): Promise { if (params.context.filter.provider && params.useProviderCatalogFastPath) { let seenKeys = new Set(); appendConfiguredProviderRows({ @@ -45,13 +51,16 @@ export async function appendAllModelRowSources(params: AllModelRowSources): Prom staticOnly: true, }); if (catalogRows === 0) { - seenKeys = appendDiscoveredRows({ + if (!params.modelRegistry) { + return { requiresRegistryFallback: true }; + } + appendDiscoveredRows({ rows: params.rows, - models: params.modelRegistry?.getAll() ?? [], + models: params.modelRegistry.getAll(), context: params.context, }); } - return; + return { requiresRegistryFallback: false }; } const seenKeys = appendDiscoveredRows({ @@ -73,7 +82,7 @@ export async function appendAllModelRowSources(params: AllModelRowSources): Prom context: params.context, seenKeys, }); - return; + return { requiresRegistryFallback: false }; } await appendProviderCatalogRows({ @@ -81,6 +90,7 @@ export async function appendAllModelRowSources(params: AllModelRowSources): Prom context: params.context, seenKeys, }); + return { requiresRegistryFallback: false }; } export function appendConfiguredModelRowSources(params: {