fix: restore models list registry fallback

This commit is contained in:
Peter Steinberger
2026-04-24 06:40:28 +01:00
parent 18664077b0
commit 72f831a05f
6 changed files with 295 additions and 28 deletions

View File

@@ -62,6 +62,7 @@ const mocks = vi.hoisted(() => {
loadModelRegistry: vi.fn(),
loadModelCatalog: vi.fn(),
loadProviderCatalogModelsForList: vi.fn(),
hasProviderStaticCatalogForFilter: vi.fn(),
resolveConfiguredEntries: vi.fn(),
printModelTable: vi.fn(),
listProfilesForProvider: vi.fn(),
@@ -88,6 +89,7 @@ function resetMocks() {
});
mocks.loadModelCatalog.mockResolvedValue([]);
mocks.loadProviderCatalogModelsForList.mockResolvedValue([]);
mocks.hasProviderStaticCatalogForFilter.mockResolvedValue(false);
mocks.resolveConfiguredEntries.mockReturnValue({
entries: [
{
@@ -148,6 +150,7 @@ function installModelsListCommandForwardCompatMocks() {
listProfilesForProvider: mocks.listProfilesForProvider,
loadModelCatalog: mocks.loadModelCatalog,
loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList,
hasProviderStaticCatalogForFilter: mocks.hasProviderStaticCatalogForFilter,
resolveModelWithRegistry: mocks.resolveModelWithRegistry,
resolveEnvApiKey: vi.fn().mockReturnValue(undefined),
resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined),
@@ -387,6 +390,7 @@ describe("modelsListCommand forward-compat", () => {
describe("--all catalog supplementation", () => {
it("uses the provider catalog fast path for Codex provider lists", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true);
mocks.loadProviderCatalogModelsForList.mockResolvedValueOnce([
{
provider: "codex",
@@ -411,6 +415,7 @@ describe("modelsListCommand forward-compat", () => {
cfg: mocks.resolvedConfig,
agentDir: "/tmp/openclaw-agent",
providerFilter: "codex",
staticOnly: true,
});
expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([
expect.objectContaining({
@@ -420,6 +425,67 @@ 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",
sourceConfig: mocks.sourceConfig,
}),
);
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledWith(mocks.sourceConfig);
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);
const runtime = createRuntime();
await modelsListCommand({ all: true, provider: "openrouter", json: true }, runtime as never);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(
mocks.resolvedConfig,
expect.objectContaining({
providerFilter: "openrouter",
sourceConfig: mocks.sourceConfig,
}),
);
});
it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({

View File

@@ -47,8 +47,12 @@ export async function modelsListCommand(
if (providerFilter === null) {
return;
}
const { ensureAuthProfileStore, ensureOpenClawModelsJson, resolveOpenClawAgentDir } =
await import("./list.runtime.js");
const {
ensureAuthProfileStore,
ensureOpenClawModelsJson,
hasProviderStaticCatalogForFilter,
resolveOpenClawAgentDir,
} = await import("./list.runtime.js");
const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({
commandName: "models list",
runtime,
@@ -60,33 +64,32 @@ export async function modelsListCommand(
let discoveredKeys = new Set<string>();
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
const useProviderCatalogFastPath = Boolean(opts.all && providerFilter === "codex");
try {
const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
const useProviderCatalogFastPath =
opts.all && providerFilter
? await hasProviderStaticCatalogForFilter({ cfg, providerFilter })
: false;
const loadRegistryState = async () => {
// Keep command behavior explicit: sync models.json from the source config
// before building the read-only model registry view.
await ensureOpenClawModelsJson(sourceConfig ?? cfg);
const loaded = await loadListModelRegistry(cfg, { sourceConfig, providerFilter });
modelRegistry = loaded.registry;
discoveredKeys = loaded.discoveredKeys;
availableKeys = loaded.availableKeys;
availabilityErrorMessage = loaded.availabilityErrorMessage;
};
try {
if (!useProviderCatalogFastPath) {
await ensureOpenClawModelsJson(sourceConfig ?? cfg);
const loaded = await loadListModelRegistry(cfg, { sourceConfig, providerFilter });
modelRegistry = loaded.registry;
discoveredKeys = loaded.discoveredKeys;
availableKeys = loaded.availableKeys;
availabilityErrorMessage = loaded.availabilityErrorMessage;
await loadRegistryState();
}
} catch (err) {
runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`);
process.exitCode = 1;
return;
}
if (availabilityErrorMessage !== undefined) {
runtime.error(
`Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`,
);
}
const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
const rows: ModelRow[] = [];
const rowContext = {
const buildRowContext = (skipRuntimeModelSuppression: boolean) => ({
cfg,
agentDir,
authStore,
@@ -97,11 +100,13 @@ export async function modelsListCommand(
provider: providerFilter,
local: opts.local,
},
skipRuntimeModelSuppression: useProviderCatalogFastPath,
};
skipRuntimeModelSuppression,
});
const rows: ModelRow[] = [];
if (opts.all) {
const seenKeys = appendDiscoveredRows({
let rowContext = buildRowContext(useProviderCatalogFastPath);
let seenKeys = appendDiscoveredRows({
rows,
models: modelRegistry?.getAll() ?? [],
context: rowContext,
@@ -119,7 +124,32 @@ export async function modelsListCommand(
rows,
context: rowContext,
seenKeys,
staticOnly: true,
});
if (rows.length === 0) {
try {
await loadRegistryState();
} catch (err) {
runtime.error(`Model registry unavailable:\n${formatErrorWithStack(err)}`);
process.exitCode = 1;
return;
}
rows.length = 0;
rowContext = buildRowContext(false);
seenKeys = appendDiscoveredRows({
rows,
models: modelRegistry?.getAll() ?? [],
context: rowContext,
});
if (modelRegistry) {
await appendCatalogSupplementRows({
rows,
modelRegistry,
context: rowContext,
seenKeys,
});
}
}
}
} else {
const registry = modelRegistry;
@@ -132,10 +162,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;

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
hasProviderStaticCatalogForFilter,
loadProviderCatalogModelsForList,
resolveProviderCatalogPluginIdsForFilter,
} from "./list.provider-catalog.js";
@@ -87,6 +88,18 @@ const openaiProvider = {
},
};
const catalogOnlyProvider = {
id: "ollama",
pluginId: "ollama",
label: "Ollama",
auth: [],
catalog: {
run: async () => ({
provider: { baseUrl: "http://127.0.0.1:11434", models: [] },
}),
},
};
const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider];
describe("loadProviderCatalogModelsForList", () => {
@@ -96,10 +109,13 @@ describe("loadProviderCatalogModelsForList", () => {
"chutes",
"moonshot",
"openai",
"ollama",
]);
providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockImplementation(
({ provider }: { provider: string }) =>
defaultProviders.some((entry) => entry.id === provider) ? [provider] : undefined,
[...defaultProviders, catalogOnlyProvider].some((entry) => entry.id === provider)
? [provider]
: undefined,
);
providerDiscoveryMocks.resolveProviderContractPluginIdsForProviderAlias.mockImplementation(
(provider: string) => (provider === "azure-openai-responses" ? ["openai"] : undefined),
@@ -135,6 +151,95 @@ describe("loadProviderCatalogModelsForList", () => {
);
});
it("requires complete discovery-entry coverage for static-only loads", async () => {
await loadProviderCatalogModelsForList({
...baseParams,
providerFilter: "moonshot",
staticOnly: true,
});
expect(providerDiscoveryMocks.resolvePluginDiscoveryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["moonshot"],
requireCompleteDiscoveryEntryCoverage: true,
discoveryEntriesOnly: true,
}),
);
});
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]);
await expect(
hasProviderStaticCatalogForFilter({
cfg: baseParams.cfg,
env: baseParams.env,
providerFilter: "ollama",
}),
).resolves.toBe(false);
expect(providerDiscoveryMocks.resolvePluginDiscoveryProviders).toHaveBeenCalledWith(
expect.objectContaining({
onlyPluginIds: ["ollama"],
requireCompleteDiscoveryEntryCoverage: true,
discoveryEntriesOnly: true,
}),
);
});
it("does not skip registry when a bundled provider has no lightweight static entry", async () => {
providerDiscoveryMocks.resolvePluginDiscoveryProviders.mockResolvedValueOnce([]);
await expect(
hasProviderStaticCatalogForFilter({
cfg: baseParams.cfg,
env: baseParams.env,
providerFilter: "chutes",
}),
).resolves.toBe(false);
});
it("does not skip registry for non-bundled static catalog owners", async () => {
providerDiscoveryMocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce([
"workspace-static-provider",
]);
providerDiscoveryMocks.resolveBundledProviderCompatPluginIds.mockReturnValueOnce(["moonshot"]);
await expect(
hasProviderStaticCatalogForFilter({
cfg: baseParams.cfg,
env: baseParams.env,
providerFilter: "workspace-static-provider",
}),
).resolves.toBe(false);
expect(providerDiscoveryMocks.resolvePluginDiscoveryProviders).not.toHaveBeenCalled();
});
it("recognizes bundled provider hook aliases before the unknown-provider short-circuit", async () => {
await expect(
resolveProviderCatalogPluginIdsForFilter({

View File

@@ -14,11 +14,23 @@ import {
resolveBundledProviderCompatPluginIds,
resolveOwningPluginIdsForProvider,
} from "../../plugins/providers.js";
import type { ProviderPlugin } from "../../plugins/types.js";
const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const;
const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]);
const log = createSubsystemLogger("models/list-provider-catalog");
function providerMatchesFilter(params: {
provider: Pick<ProviderPlugin, "id" | "aliases" | "hookAliases">;
providerFilter: string;
}): boolean {
return [
params.provider.id,
...(params.provider.aliases ?? []),
...(params.provider.hookAliases ?? []),
].some((providerId) => normalizeProviderId(providerId) === params.providerFilter);
}
export async function resolveProviderCatalogPluginIdsForFilter(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
@@ -45,6 +57,47 @@ export async function resolveProviderCatalogPluginIdsForFilter(params: {
return undefined;
}
export async function hasProviderStaticCatalogForFilter(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
providerFilter: string;
}): Promise<boolean> {
const env = params.env ?? process.env;
const providerFilter = normalizeProviderId(params.providerFilter);
if (!providerFilter) {
return false;
}
const pluginIds = await resolveProviderCatalogPluginIdsForFilter({
...params,
env,
});
if (!pluginIds || pluginIds.length === 0) {
return false;
}
const bundledPluginIds = resolveBundledProviderCompatPluginIds({
config: params.cfg,
env,
});
const bundledPluginIdSet = new Set(bundledPluginIds);
const scopedPluginIds = pluginIds.filter((pluginId) => bundledPluginIdSet.has(pluginId));
if (scopedPluginIds.length === 0) {
return false;
}
const providers = await resolvePluginDiscoveryProviders({
config: params.cfg,
env,
onlyPluginIds: scopedPluginIds,
includeUntrustedWorkspacePlugins: false,
requireCompleteDiscoveryEntryCoverage: true,
discoveryEntriesOnly: true,
});
return providers.some(
(provider) =>
typeof provider.staticCatalog?.run === "function" &&
providerMatchesFilter({ provider, providerFilter }),
);
}
function modelFromProviderCatalog(params: {
provider: string;
providerConfig: ModelProviderConfig;
@@ -55,7 +108,7 @@ function modelFromProviderCatalog(params: {
name: params.model.name || params.model.id,
provider: params.provider,
api: params.model.api ?? params.providerConfig.api ?? "openai-responses",
baseUrl: params.providerConfig.baseUrl,
baseUrl: params.model.baseUrl ?? params.providerConfig.baseUrl,
reasoning: params.model.reasoning,
input: params.model.input ?? ["text"],
cost: params.model.cost,
@@ -72,6 +125,7 @@ export async function loadProviderCatalogModelsForList(params: {
agentDir: string;
env?: NodeJS.ProcessEnv;
providerFilter?: string;
staticOnly?: boolean;
}): Promise<Model<Api>[]> {
const env = params.env ?? process.env;
const providerFilter = params.providerFilter ? normalizeProviderId(params.providerFilter) : "";
@@ -104,7 +158,8 @@ export async function loadProviderCatalogModelsForList(params: {
env,
onlyPluginIds: scopedPluginIds,
includeUntrustedWorkspacePlugins: false,
requireCompleteDiscoveryEntryCoverage: true,
requireCompleteDiscoveryEntryCoverage: params.staticOnly === true,
discoveryEntriesOnly: params.staticOnly === true,
})
).filter(
(provider) =>

View File

@@ -177,11 +177,13 @@ export async function appendProviderCatalogRows(params: {
rows: ModelRow[];
context: RowBuilderContext;
seenKeys: Set<string>;
staticOnly?: boolean;
}): Promise<void> {
for (const model of await loadProviderCatalogModelsForList({
cfg: params.context.cfg,
agentDir: params.context.agentDir,
providerFilter: params.context.filter.provider,
staticOnly: params.staticOnly,
})) {
if (!matchesRowFilter(params.context.filter, model)) {
continue;

View File

@@ -10,4 +10,7 @@ export {
export { loadModelCatalog } from "../../agents/model-catalog.js";
export { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js";
export { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js";
export { loadProviderCatalogModelsForList } from "./list.provider-catalog.js";
export {
hasProviderStaticCatalogForFilter,
loadProviderCatalogModelsForList,
} from "./list.provider-catalog.js";