mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:20:43 +00:00
fix: restore models list registry fallback
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user