From 56c4f9761c75d93e13c515024cfa058a3c678c68 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 11:36:56 +0100 Subject: [PATCH] fix(models): restore provider catalog listing --- CHANGELOG.md | 1 + .../list.list-command.forward-compat.test.ts | 167 +++++++++++++++++- src/commands/models/list.list-command.ts | 8 +- src/commands/models/list.row-sources.ts | 49 ++++- src/commands/models/list.source-plan.ts | 4 +- 5 files changed, 216 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1836f665699..985758a33a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Models CLI: restore `openclaw models list --provider ` catalog and registry fallback rows for unconfigured providers, so provider-specific verification commands no longer report "No models found." Fixes #75517; supersedes #75615. Thanks @lotsoftick and @koshaji. - Control UI/chat: show inline feedback when local slash-command dispatch is unavailable or fails unexpectedly instead of clearing the composer silently. Fixes #52105. Thanks @MooreQiao. - Memory/markdown: replace CRLF managed blocks in place and collapse duplicate marker blocks without rewriting unmanaged markdown, so Dreaming and Memory Wiki files self-heal from repeated generated sections. Fixes #75491; supersedes #75495, #75810, and #76008. Thanks @asaenokkostya-coder, @ottodeng, @everettjf, and @lrg913427-dot. - Agents/tools: return critical tool-loop circuit-breaker stops as blocked tool results instead of thrown tool failures, so models see the guardrail and stop retrying the same call. Thanks @rayraiser. 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 a4a0c55c194..a6111207a59 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -279,14 +279,175 @@ beforeEach(() => { describe("modelsListCommand forward-compat", () => { describe("configured rows", () => { - it("keeps configured provider filters on the registry-free row path", async () => { + it("returns manifest catalog rows for provider filters without --all", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([ + { + provider: "moonshot", + id: "kimi-k2.6", + ref: "moonshot/kimi-k2.6", + mergeKey: "moonshot::kimi-k2.6", + name: "Kimi K2.6", + source: "manifest", + input: ["text", "image"], + reasoning: false, + status: "available", + baseUrl: "https://api.moonshot.ai/v1", + contextWindow: 262_144, + }, + ]); const runtime = createRuntime(); await modelsListCommand({ json: true, provider: "moonshot" }, runtime as never); expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); - expect(mocks.printModelTable).not.toHaveBeenCalled(); - expect(runtime.log).toHaveBeenCalledWith("No models found."); + expect(runtime.log).not.toHaveBeenCalledWith("No models found."); + expect(lastPrintedRows<{ key: string }>()).toEqual([ + expect.objectContaining({ key: "moonshot/kimi-k2.6" }), + ]); + }); + + it("keeps catalog metadata when provider-filtered configured entries overlap", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ + entries: [ + { + key: "moonshot/kimi-k2.6", + ref: { provider: "moonshot", model: "kimi-k2.6" }, + tags: new Set(["configured"]), + aliases: [], + }, + ], + }); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([ + { + provider: "moonshot", + id: "kimi-k2.6", + ref: "moonshot/kimi-k2.6", + mergeKey: "moonshot::kimi-k2.6", + name: "Kimi K2.6", + source: "manifest", + input: ["text", "image"], + reasoning: false, + status: "available", + baseUrl: "https://api.moonshot.ai/v1", + contextWindow: 262_144, + }, + ]); + const runtime = createRuntime(); + + await modelsListCommand({ json: true, provider: "moonshot" }, runtime as never); + + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(lastPrintedRows<{ key: string; name: string; tags: string[] }>()).toEqual([ + expect.objectContaining({ + key: "moonshot/kimi-k2.6", + name: "Kimi K2.6", + tags: ["configured"], + }), + ]); + }); + + it("falls back to registry rows for unknown provider filters without --all", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadModelRegistry.mockResolvedValueOnce({ + models: [ + { + provider: "google", + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "google-gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + input: ["text", "image"], + contextWindow: 1_048_576, + maxTokens: 65_536, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + availableKeys: undefined, + registry: { + getAll: () => [ + { + provider: "google", + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + api: "google-gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + input: ["text", "image"], + contextWindow: 1_048_576, + maxTokens: 65_536, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }); + const runtime = createRuntime(); + + await modelsListCommand({ json: true, provider: "google" }, runtime as never); + + expect(mocks.loadModelRegistry).toHaveBeenCalled(); + expect(runtime.log).not.toHaveBeenCalledWith("No models found."); + expect(lastPrintedRows<{ key: string }>()).toEqual([ + expect.objectContaining({ key: "google/gemini-2.5-pro" }), + ]); + }); + + it("uses provider static catalog rows for provider filters without --all", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); + mocks.loadProviderCatalogModelsForList.mockResolvedValueOnce([ + { + provider: "google", + id: "gemini-2.5-pro", + name: "gemini-2.5-pro", + api: "google-gemini", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + input: ["text", "image"], + contextWindow: 1_048_576, + maxTokens: 65_536, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ]); + const runtime = createRuntime(); + + await modelsListCommand({ json: true, provider: "google" }, runtime as never); + + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.loadProviderCatalogModelsForList).toHaveBeenCalledWith( + expect.objectContaining({ + providerFilter: "google", + staticOnly: true, + }), + ); + expect(lastPrintedRows<{ key: string }>()).toEqual([ + expect.objectContaining({ key: "google/gemini-2.5-pro" }), + ]); + }); + + it("uses provider-index catalog rows for provider filters without --all", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadProviderIndexCatalogRowsForList.mockReturnValueOnce([ + { + provider: "moonshot", + id: "kimi-k2.6", + ref: "moonshot/kimi-k2.6", + mergeKey: "moonshot::kimi-k2.6", + name: "Kimi K2.6", + source: "provider-index", + input: ["text", "image"], + reasoning: false, + status: "available", + baseUrl: "https://api.moonshot.ai/v1", + contextWindow: 262_144, + }, + ]); + const runtime = createRuntime(); + + await modelsListCommand({ json: true, provider: "moonshot" }, runtime as never); + + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(lastPrintedRows<{ key: string }>()).toEqual([ + expect.objectContaining({ key: "moonshot/kimi-k2.6" }), + ]); }); it("includes configured provider model rows for provider-filtered lists", async () => { diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 4318e1b4c3b..320b165d9f8 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -97,10 +97,12 @@ export async function modelsListCommand( let availabilityErrorMessage: string | undefined; const { entries } = resolveConfiguredEntries(cfg); const configuredByKey = new Map(entries.map((entry) => [entry.key, entry])); - const sourcePlanModule = opts.all ? await loadSourcePlanModule() : undefined; + const enableSourcePlanCascade = Boolean(opts.all) || Boolean(providerFilter); + const sourcePlanModule = enableSourcePlanCascade ? await loadSourcePlanModule() : undefined; const sourcePlan = sourcePlanModule ? await sourcePlanModule.planAllModelListSources({ all: opts.all, + enableCascade: enableSourcePlanCascade, providerFilter, cfg, }) @@ -156,7 +158,7 @@ export async function modelsListCommand( }); const rows: ModelRow[] = []; - if (opts.all) { + if (enableSourcePlanCascade) { const { appendAllModelRowSources } = await loadRowSourcesModule(); if (!sourcePlan || !sourcePlanModule) { throw new Error("models list source plan was not initialized"); @@ -164,6 +166,7 @@ export async function modelsListCommand( let rowContext = buildRowContext(sourcePlan.skipRuntimeModelSuppression); const initialAppend = await appendAllModelRowSources({ rows, + entries, context: rowContext, modelRegistry, registryModels, @@ -189,6 +192,7 @@ export async function modelsListCommand( rowContext = buildRowContext(useScopedRegistryFallback); await appendAllModelRowSources({ rows, + entries, context: rowContext, modelRegistry, registryModels, diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index de5e5170b60..d609803f59a 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -14,6 +14,7 @@ import type { ConfiguredEntry, ModelRow } from "./list.types.js"; type AllModelRowSources = { rows: ModelRow[]; + entries?: ConfiguredEntry[]; context: RowBuilderContext; modelRegistry?: ModelRegistry; registryModels?: ReturnType; @@ -28,12 +29,7 @@ export async function appendAllModelRowSources( params: AllModelRowSources, ): Promise { if (params.context.filter.provider && params.sourcePlan.kind !== "registry") { - let seenKeys = new Set(); - await appendConfiguredProviderRows({ - rows: params.rows, - context: params.context, - seenKeys, - }); + const seenKeys = new Set(); let catalogRows = 0; if (params.sourcePlan.kind === "manifest") { catalogRows = await appendManifestCatalogRows({ @@ -63,7 +59,30 @@ export async function appendAllModelRowSources( staticOnly: params.sourcePlan.kind === "provider-runtime-static", }); } - if (catalogRows === 0 && params.sourcePlan.fallbackToRegistryWhenEmpty) { + if (params.entries && params.entries.length > 0) { + const missingEntries = params.entries.filter((entry) => !seenKeys.has(entry.key)); + if (missingEntries.length > 0) { + await appendConfiguredRows({ + rows: params.rows, + entries: missingEntries, + modelRegistry: params.modelRegistry, + context: params.context, + }); + for (const row of params.rows) { + seenKeys.add(row.key); + } + } + } + await appendConfiguredProviderRows({ + rows: params.rows, + context: params.context, + seenKeys, + }); + if ( + catalogRows === 0 && + params.rows.length === 0 && + params.sourcePlan.fallbackToRegistryWhenEmpty + ) { if (!params.modelRegistry) { return { requiresRegistryFallback: true }; } @@ -88,6 +107,22 @@ export async function appendAllModelRowSources( skipSuppression: Boolean(params.modelRegistry), }); + if (params.context.filter.provider && params.entries && params.entries.length > 0) { + const missingEntries = params.entries.filter((entry) => !seenKeys.has(entry.key)); + if (missingEntries.length > 0) { + const appendedRowsStart = params.rows.length; + await appendConfiguredRows({ + rows: params.rows, + entries: missingEntries, + modelRegistry: params.modelRegistry, + context: params.context, + }); + for (const row of params.rows.slice(appendedRowsStart)) { + seenKeys.add(row.key); + } + } + } + await appendConfiguredProviderRows({ rows: params.rows, context: params.context, diff --git a/src/commands/models/list.source-plan.ts b/src/commands/models/list.source-plan.ts index 2582d5334ca..19f48574990 100644 --- a/src/commands/models/list.source-plan.ts +++ b/src/commands/models/list.source-plan.ts @@ -44,10 +44,12 @@ export function createRegistryModelListSourcePlan(): ModelListSourcePlan { export async function planAllModelListSources(params: { all?: boolean; + enableCascade?: boolean; providerFilter?: string; cfg: OpenClawConfig; }): Promise { - if (!params.all) { + const enableCascade = params.enableCascade ?? params.all; + if (!enableCascade) { return createRegistryModelListSourcePlan(); }