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 a486477b3dd..b166b150f77 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -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({ diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index b6afc796360..e537be6d0b2 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -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(); let availableKeys: Set | 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; diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index 876efa71337..e894c9ccbf2 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -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({ diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index b8d83e00f93..815bd4caae7 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -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; + 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 { + 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[]> { 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) => diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 89aaa4fbd79..09c16a6fb74 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -177,11 +177,13 @@ export async function appendProviderCatalogRows(params: { rows: ModelRow[]; context: RowBuilderContext; seenKeys: Set; + staticOnly?: boolean; }): Promise { 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; diff --git a/src/commands/models/list.runtime.ts b/src/commands/models/list.runtime.ts index ad446fa4b78..a459fd50052 100644 --- a/src/commands/models/list.runtime.ts +++ b/src/commands/models/list.runtime.ts @@ -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";