fix(models): restore provider catalog listing

This commit is contained in:
Peter Steinberger
2026-05-02 11:36:56 +01:00
parent eb3e4f20a0
commit 56c4f9761c
5 changed files with 216 additions and 13 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Models CLI: restore `openclaw models list --provider <id>` 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.

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -14,6 +14,7 @@ import type { ConfiguredEntry, ModelRow } from "./list.types.js";
type AllModelRowSources = {
rows: ModelRow[];
entries?: ConfiguredEntry[];
context: RowBuilderContext;
modelRegistry?: ModelRegistry;
registryModels?: ReturnType<ModelRegistry["getAll"]>;
@@ -28,12 +29,7 @@ export async function appendAllModelRowSources(
params: AllModelRowSources,
): Promise<AppendAllModelRowSourcesResult> {
if (params.context.filter.provider && params.sourcePlan.kind !== "registry") {
let seenKeys = new Set<string>();
await appendConfiguredProviderRows({
rows: params.rows,
context: params.context,
seenKeys,
});
const seenKeys = new Set<string>();
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,

View File

@@ -44,10 +44,12 @@ export function createRegistryModelListSourcePlan(): ModelListSourcePlan {
export async function planAllModelListSources(params: {
all?: boolean;
enableCascade?: boolean;
providerFilter?: string;
cfg: OpenClawConfig;
}): Promise<ModelListSourcePlan> {
if (!params.all) {
const enableCascade = params.enableCascade ?? params.all;
if (!enableCascade) {
return createRegistryModelListSourcePlan();
}