fix(models): preserve provider index catalog fallback (#71985)

* fix(models): preserve provider index catalog fallback

* fix(models): mark provider index rows as previews
This commit is contained in:
Vincent Koc
2026-04-25 22:31:52 -07:00
committed by GitHub
parent a57d681db9
commit 639cd50261
12 changed files with 308 additions and 8 deletions

View File

@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
- Plugins/registry: keep installed plugin index records focused on install/state/load paths and resolve plugin capabilities from manifests scoped to indexed plugins. Thanks @shakkernerd.
- Plugins/registry: route cold manifest and capability lookups through the installed plugin index so setup, channels, config, secrets, doctor, and provider metadata paths avoid broad plugin-root scans before runtime execution. Thanks @shakkernerd.
- CLI/models: speed up `models list --all --provider <id>` for static manifest-backed providers by loading catalog rows through the installed plugin index instead of broad manifest scans or runtime suppression hooks. Thanks @shakkernerd.
- CLI/models: use OpenClaw Provider Index preview rows as the final cold fallback for installable providers, while keeping user config, installed manifests, and refreshed cache rows above provider-index metadata. Thanks @vincentkoc.
- Plugins/chat commands: refresh the persisted plugin registry after `/plugins enable` and `/plugins disable`, matching the CLI mutation path. Thanks @vincentkoc.
- Plugins/compat: mark `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY` as a deprecated break-glass switch and point operators at registry repair instead. Thanks @vincentkoc.
- Plugins/registry: ignore stale persisted registry reads when plugin policy no longer matches current config, and stamp generated registry files with a do-not-edit warning. Thanks @vincentkoc.

View File

@@ -507,6 +507,23 @@ describe("modelsListCommand forward-compat", () => {
]);
});
it("uses provider index preview rows when an installable provider is not installed", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(false);
const runtime = createRuntime();
await modelsListCommand({ all: true, provider: "moonshot", json: true }, runtime as never);
expect(mocks.loadModelRegistry).not.toHaveBeenCalled();
expect(mocks.loadProviderCatalogModelsForList).not.toHaveBeenCalled();
expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([
expect.objectContaining({
key: "moonshot/kimi-k2.6",
available: false,
}),
]);
});
it("falls back to registry-backed rows when the fast-path catalog is empty", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);

View File

@@ -63,6 +63,7 @@ export async function modelsListCommand(
const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
let manifestCatalogRows: readonly NormalizedModelCatalogRow[] = [];
let providerIndexCatalogRows: readonly NormalizedModelCatalogRow[] = [];
if (opts.all && providerFilter) {
const { loadStaticManifestCatalogRowsForList } = await import("./list.manifest-catalog.js");
manifestCatalogRows = loadStaticManifestCatalogRowsForList({ cfg, providerFilter });
@@ -72,11 +73,18 @@ export async function modelsListCommand(
!useManifestCatalogFastPath && opts.all && providerFilter
? await hasProviderStaticCatalogForFilter({ cfg, providerFilter })
: false;
if (!useManifestCatalogFastPath && !useProviderCatalogFastPath && opts.all && providerFilter) {
const { loadProviderIndexCatalogRowsForList } =
await import("./list.provider-index-catalog.js");
providerIndexCatalogRows = loadProviderIndexCatalogRowsForList({ providerFilter });
}
const useProviderIndexCatalogFastPath = providerIndexCatalogRows.length > 0;
const shouldLoadRegistry = modelRowSourcesRequireRegistry({
all: opts.all,
providerFilter,
useManifestCatalogFastPath,
useProviderCatalogFastPath,
useProviderIndexCatalogFastPath,
});
const loadRegistryState = async () => {
const loaded = await loadListModelRegistry(cfg, { providerFilter });
@@ -115,14 +123,18 @@ export async function modelsListCommand(
const rows: ModelRow[] = [];
if (opts.all) {
let rowContext = buildRowContext(useManifestCatalogFastPath || useProviderCatalogFastPath);
let rowContext = buildRowContext(
useManifestCatalogFastPath || useProviderCatalogFastPath || useProviderIndexCatalogFastPath,
);
const initialAppend = await appendAllModelRowSources({
rows,
context: rowContext,
modelRegistry,
manifestCatalogRows,
providerIndexCatalogRows,
useManifestCatalogFastPath,
useProviderCatalogFastPath,
useProviderIndexCatalogFastPath,
});
if (initialAppend.requiresRegistryFallback) {
try {
@@ -139,8 +151,10 @@ export async function modelsListCommand(
context: rowContext,
modelRegistry,
manifestCatalogRows: [],
providerIndexCatalogRows: [],
useManifestCatalogFastPath: false,
useProviderCatalogFastPath: false,
useProviderIndexCatalogFastPath: false,
});
}
} else {

View File

@@ -0,0 +1,19 @@
import {
loadOpenClawProviderIndex,
normalizeModelCatalogProviderId,
planProviderIndexModelCatalogRows,
} from "../../model-catalog/index.js";
import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js";
export function loadProviderIndexCatalogRowsForList(params: {
providerFilter: string;
}): readonly NormalizedModelCatalogRow[] {
const providerFilter = normalizeModelCatalogProviderId(params.providerFilter);
if (!providerFilter) {
return [];
}
return planProviderIndexModelCatalogRows({
index: loadOpenClawProviderIndex(),
providerFilter,
}).rows;
}

View File

@@ -6,6 +6,7 @@ import {
appendConfiguredRows,
appendDiscoveredRows,
appendManifestCatalogRows,
appendModelCatalogRows,
appendProviderCatalogRows,
type RowBuilderContext,
} from "./list.rows.js";
@@ -16,8 +17,10 @@ type AllModelRowSources = {
context: RowBuilderContext;
modelRegistry?: ModelRegistry;
manifestCatalogRows?: readonly NormalizedModelCatalogRow[];
providerIndexCatalogRows?: readonly NormalizedModelCatalogRow[];
useManifestCatalogFastPath: boolean;
useProviderCatalogFastPath: boolean;
useProviderIndexCatalogFastPath: boolean;
};
type AppendAllModelRowSourcesResult = {
@@ -29,13 +32,16 @@ export function modelRowSourcesRequireRegistry(params: {
providerFilter?: string;
useManifestCatalogFastPath: boolean;
useProviderCatalogFastPath: boolean;
useProviderIndexCatalogFastPath: boolean;
}): boolean {
if (!params.all) {
return false;
}
if (
params.providerFilter &&
(params.useManifestCatalogFastPath || params.useProviderCatalogFastPath)
(params.useManifestCatalogFastPath ||
params.useProviderCatalogFastPath ||
params.useProviderIndexCatalogFastPath)
) {
return false;
}
@@ -47,7 +53,9 @@ export async function appendAllModelRowSources(
): Promise<AppendAllModelRowSourcesResult> {
if (
params.context.filter.provider &&
(params.useManifestCatalogFastPath || params.useProviderCatalogFastPath)
(params.useManifestCatalogFastPath ||
params.useProviderCatalogFastPath ||
params.useProviderIndexCatalogFastPath)
) {
let seenKeys = new Set<string>();
appendConfiguredProviderRows({
@@ -72,6 +80,14 @@ export async function appendAllModelRowSources(
staticOnly: true,
});
}
if (catalogRows === 0 && params.useProviderIndexCatalogFastPath) {
catalogRows = appendModelCatalogRows({
rows: params.rows,
context: params.context,
seenKeys,
catalogRows: params.providerIndexCatalogRows ?? [],
});
}
if (catalogRows === 0) {
if (!params.modelRegistry) {
return { requiresRegistryFallback: true };

View File

@@ -225,19 +225,19 @@ export function appendConfiguredProviderRows(params: {
}
}
export function appendManifestCatalogRows(params: {
export function appendModelCatalogRows(params: {
rows: ModelRow[];
context: RowBuilderContext;
seenKeys: Set<string>;
manifestRows: readonly NormalizedModelCatalogRow[];
catalogRows: readonly NormalizedModelCatalogRow[];
}): number {
let appended = 0;
for (const manifestRow of params.manifestRows) {
const key = modelKey(manifestRow.provider, manifestRow.id);
for (const catalogRow of params.catalogRows) {
const key = modelKey(catalogRow.provider, catalogRow.id);
if (
appendVisibleRow({
rows: params.rows,
model: toManifestCatalogListModel(manifestRow),
model: toManifestCatalogListModel(catalogRow),
key,
context: params.context,
seenKeys: params.seenKeys,
@@ -250,6 +250,18 @@ export function appendManifestCatalogRows(params: {
return appended;
}
export function appendManifestCatalogRows(params: {
rows: ModelRow[];
context: RowBuilderContext;
seenKeys: Set<string>;
manifestRows: readonly NormalizedModelCatalogRow[];
}): number {
return appendModelCatalogRows({
...params,
catalogRows: params.manifestRows,
});
}
export async function appendCatalogSupplementRows(params: {
rows: ModelRow[];
modelRegistry: ModelRegistry;

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { mergeModelCatalogRowsByAuthority } from "./index.js";
import type { ModelCatalogSource, NormalizedModelCatalogRow } from "./index.js";
function row(source: ModelCatalogSource, name: string): NormalizedModelCatalogRow {
return {
provider: "moonshot",
id: "kimi-k2.6",
ref: "moonshot/kimi-k2.6",
mergeKey: "moonshot::kimi-k2.6",
name,
source,
input: ["text"],
reasoning: false,
status: source === "provider-index" ? "preview" : "available",
};
}
describe("model catalog authority", () => {
it("keeps user config above manifest, cache, and provider-index preview rows", () => {
expect(
mergeModelCatalogRowsByAuthority([
row("provider-index", "Preview"),
row("cache", "Cached"),
row("manifest", "Manifest"),
row("config", "Configured"),
]),
).toEqual([expect.objectContaining({ name: "Configured", source: "config" })]);
});
it("keeps installed manifest rows above cache and provider-index preview rows", () => {
expect(
mergeModelCatalogRowsByAuthority([
row("provider-index", "Preview"),
row("runtime-refresh", "Refreshed"),
row("cache", "Cached"),
row("manifest", "Manifest"),
]),
).toEqual([expect.objectContaining({ name: "Manifest", source: "manifest" })]);
});
it("uses cache rows above provider-index preview rows", () => {
expect(
mergeModelCatalogRowsByAuthority([row("provider-index", "Preview"), row("cache", "Cached")]),
).toEqual([expect.objectContaining({ name: "Cached", source: "cache" })]);
});
it("uses provider-index preview rows when no higher-authority row exists", () => {
expect(mergeModelCatalogRowsByAuthority([row("provider-index", "Preview")])).toEqual([
expect.objectContaining({ name: "Preview", source: "provider-index" }),
]);
});
});

View File

@@ -0,0 +1,31 @@
import type { ModelCatalogSource, NormalizedModelCatalogRow } from "./types.js";
const MODEL_CATALOG_SOURCE_AUTHORITY: Readonly<Record<ModelCatalogSource, number>> = {
config: 0,
manifest: 1,
cache: 2,
"runtime-refresh": 2,
"provider-index": 3,
};
export function compareModelCatalogSourceAuthority(
left: ModelCatalogSource,
right: ModelCatalogSource,
): number {
return MODEL_CATALOG_SOURCE_AUTHORITY[left] - MODEL_CATALOG_SOURCE_AUTHORITY[right];
}
export function mergeModelCatalogRowsByAuthority(
rows: Iterable<NormalizedModelCatalogRow>,
): NormalizedModelCatalogRow[] {
const byMergeKey = new Map<string, NormalizedModelCatalogRow>();
for (const row of rows) {
const existing = byMergeKey.get(row.mergeKey);
if (!existing || compareModelCatalogSourceAuthority(row.source, existing.source) < 0) {
byMergeKey.set(row.mergeKey, row);
}
}
return [...byMergeKey.values()].toSorted(
(left, right) => left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id),
);
}

View File

@@ -1,3 +1,7 @@
export {
compareModelCatalogSourceAuthority,
mergeModelCatalogRowsByAuthority,
} from "./authority.js";
export {
buildModelCatalogMergeKey,
buildModelCatalogRef,
@@ -13,6 +17,11 @@ export {
normalizeOpenClawProviderIndex,
} from "./provider-index/index.js";
export { planManifestModelCatalogRows } from "./manifest-planner.js";
export { planProviderIndexModelCatalogRows } from "./provider-index-planner.js";
export type {
ProviderIndexModelCatalogPlan,
ProviderIndexModelCatalogPlanEntry,
} from "./provider-index-planner.js";
export type {
ManifestModelCatalogConflict,
ManifestModelCatalogPlan,

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { planProviderIndexModelCatalogRows } from "./index.js";
describe("provider index model catalog planner", () => {
it("builds preview rows from installable provider metadata", () => {
const plan = planProviderIndexModelCatalogRows({
providerFilter: "Moonshot",
index: {
version: 1,
providers: {
moonshot: {
id: "moonshot",
name: "Moonshot AI",
plugin: {
id: "moonshot",
package: "@openclaw/plugin-moonshot",
},
previewCatalog: {
models: [{ id: "kimi-k2.6", name: "Kimi K2.6", contextWindow: 262144 }],
},
},
deepseek: {
id: "deepseek",
name: "DeepSeek",
plugin: { id: "deepseek" },
previewCatalog: {
models: [{ id: "deepseek-chat" }],
},
},
},
},
});
expect(plan.entries).toEqual([
{
provider: "moonshot",
pluginId: "moonshot",
rows: [
{
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"],
reasoning: false,
status: "preview",
contextWindow: 262144,
},
],
},
]);
expect(plan.rows.map((row) => row.ref)).toEqual(["moonshot/kimi-k2.6"]);
});
});

View File

@@ -0,0 +1,69 @@
import { normalizeModelCatalogProviderRows } from "./normalize.js";
import type { OpenClawProviderIndex } from "./provider-index/index.js";
import { normalizeModelCatalogProviderId } from "./refs.js";
import type { ModelCatalogProvider, NormalizedModelCatalogRow } from "./types.js";
export type ProviderIndexModelCatalogPlanEntry = {
provider: string;
pluginId: string;
rows: readonly NormalizedModelCatalogRow[];
};
export type ProviderIndexModelCatalogPlan = {
rows: readonly NormalizedModelCatalogRow[];
entries: readonly ProviderIndexModelCatalogPlanEntry[];
};
function withPreviewStatusDefaults(providerCatalog: ModelCatalogProvider): ModelCatalogProvider {
return {
...providerCatalog,
models: providerCatalog.models.map((model) => ({
...model,
status: model.status ?? "preview",
})),
};
}
export function planProviderIndexModelCatalogRows(params: {
index: OpenClawProviderIndex;
providerFilter?: string;
}): ProviderIndexModelCatalogPlan {
const providerFilter = params.providerFilter
? normalizeModelCatalogProviderId(params.providerFilter)
: undefined;
const entries: ProviderIndexModelCatalogPlanEntry[] = [];
for (const [providerId, provider] of Object.entries(params.index.providers)) {
const normalizedProvider = normalizeModelCatalogProviderId(providerId);
if (
!normalizedProvider ||
(providerFilter && normalizedProvider !== providerFilter) ||
!provider.previewCatalog
) {
continue;
}
const rows = normalizeModelCatalogProviderRows({
provider: normalizedProvider,
providerCatalog: withPreviewStatusDefaults(provider.previewCatalog),
source: "provider-index",
});
if (rows.length === 0) {
continue;
}
entries.push({
provider: normalizedProvider,
pluginId: provider.plugin.id,
rows,
});
}
return {
entries,
rows: entries
.flatMap((entry) => entry.rows)
.toSorted(
(left, right) =>
left.provider.localeCompare(right.provider) || left.id.localeCompare(right.id),
),
};
}

View File

@@ -6,6 +6,9 @@ import type { OpenClawProviderIndex } from "./types.js";
// Preview catalogs use the shared model catalog type, but intentionally keep to
// stable display fields unless runtime adapter metadata is kept in sync with
// the installed plugin manifest.
// When a bundled provider moves to an external package, keep its provider id
// here and add plugin package metadata so pre-install surfaces do not disappear
// before the user installs the new package.
export const OPENCLAW_PROVIDER_INDEX = {
version: 1,
providers: {