From cc78dd204488c939bdef530af05527f41ecb5cc8 Mon Sep 17 00:00:00 2001 From: Shakker Date: Wed, 22 Apr 2026 01:48:18 +0100 Subject: [PATCH] fix: show provider catalog models in all list --- src/commands/models.list.e2e.test.ts | 32 +++++ .../list.list-command.forward-compat.test.ts | 7 + src/commands/models/list.list-command.ts | 5 +- src/commands/models/list.provider-catalog.ts | 121 ++++++++++++++++++ src/commands/models/list.rows.ts | 40 +++++- src/commands/models/list.runtime.ts | 1 + 6 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 src/commands/models/list.provider-catalog.ts diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index 7a26e170686..44cb43bdd5f 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -17,6 +17,9 @@ const listProfilesForProvider = vi.fn().mockReturnValue([]); const resolveEnvApiKey = vi.fn().mockReturnValue(undefined); const resolveAwsSdkEnvVarName = vi.fn().mockReturnValue(undefined); const hasUsableCustomProviderApiKey = vi.fn().mockReturnValue(false); +const loadProviderCatalogModelsForList = vi.fn<() => Promise>>>( + async () => [], +); const shouldSuppressBuiltInModel = vi.fn().mockReturnValue(false); const modelRegistryState = { models: [] as Array>, @@ -72,6 +75,7 @@ vi.mock("./models/list.runtime.js", () => { resolveAwsSdkEnvVarName, hasUsableCustomProviderApiKey, loadModelCatalog: vi.fn(async () => []), + loadProviderCatalogModelsForList, discoverAuthStorage: () => ({}) as unknown, discoverModels: () => new MockModelRegistry() as unknown, resolveModelWithRegistry: ({ @@ -132,6 +136,8 @@ beforeEach(() => { getRuntimeConfig.mockReturnValue({}); listProfilesForProvider.mockReturnValue([]); ensureOpenClawModelsJson.mockClear(); + loadProviderCatalogModelsForList.mockReset(); + loadProviderCatalogModelsForList.mockResolvedValue([]); shouldSuppressBuiltInModel.mockReset(); shouldSuppressBuiltInModel.mockReturnValue(false); readConfigFileSnapshotForWrite.mockClear(); @@ -179,6 +185,14 @@ describe("models list/status", () => { baseUrl: "https://chatgpt.com/backend-api", contextWindow: 128000, }; + const MOONSHOT_MODEL = { + provider: "moonshot", + id: "kimi-k2.6", + name: "Kimi K2.6", + input: ["text", "image"], + baseUrl: "https://api.moonshot.ai/v1", + contextWindow: 262144, + }; const AZURE_OPENAI_SPARK_MODEL = { provider: "azure-openai-responses", id: "gpt-5.3-codex-spark", @@ -337,6 +351,24 @@ describe("models list/status", () => { expect(payload.models[0]?.available).toBe(false); }); + it("models list all includes unauthenticated provider catalog rows", async () => { + setDefaultZaiRegistry({ available: false }); + loadProviderCatalogModelsForList.mockResolvedValueOnce([MOONSHOT_MODEL]); + const runtime = makeRuntime(); + + await modelsListCommand({ all: true, provider: "moonshot", json: true }, runtime); + + const payload = parseJsonLog(runtime); + expect(payload.models).toEqual([ + expect.objectContaining({ + key: "moonshot/kimi-k2.6", + name: "Kimi K2.6", + available: false, + missing: false, + }), + ]); + }); + it("models list does not treat availability-unavailable code as discovery fallback", async () => { configureGoogleAntigravityModel("claude-opus-4-6-thinking"); modelRegistryState.getAllError = Object.assign(new Error("model discovery failed"), { 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 334e7efcc47..19f9034a00b 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -58,8 +58,10 @@ const mocks = vi.hoisted(() => { loadModelsConfigWithSource: vi.fn(), ensureOpenClawModelsJson: vi.fn(), ensureAuthProfileStore: vi.fn(), + resolveOpenClawAgentDir: vi.fn(), loadModelRegistry: vi.fn(), loadModelCatalog: vi.fn(), + loadProviderCatalogModelsForList: vi.fn(), resolveConfiguredEntries: vi.fn(), printModelTable: vi.fn(), listProfilesForProvider: vi.fn(), @@ -75,6 +77,7 @@ function resetMocks() { }); mocks.ensureOpenClawModelsJson.mockResolvedValue({ wrote: false }); mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {}, order: {} }); + mocks.resolveOpenClawAgentDir.mockReturnValue("/tmp/openclaw-agent"); mocks.loadModelRegistry.mockResolvedValue({ models: [], availableKeys: new Set(), @@ -83,6 +86,7 @@ function resetMocks() { }, }); mocks.loadModelCatalog.mockResolvedValue([]); + mocks.loadProviderCatalogModelsForList.mockResolvedValue([]); mocks.resolveConfiguredEntries.mockReturnValue({ entries: [ { @@ -138,8 +142,10 @@ function installModelsListCommandForwardCompatMocks() { vi.doMock("./list.runtime.js", () => ({ ensureOpenClawModelsJson: mocks.ensureOpenClawModelsJson, ensureAuthProfileStore: mocks.ensureAuthProfileStore, + resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, listProfilesForProvider: mocks.listProfilesForProvider, loadModelCatalog: mocks.loadModelCatalog, + loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, resolveModelWithRegistry: mocks.resolveModelWithRegistry, resolveEnvApiKey: vi.fn().mockReturnValue(undefined), resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), @@ -160,6 +166,7 @@ async function buildAllOpenAiCodexRows(opts: { supplementCatalog?: boolean } = { const rows: unknown[] = []; const context = { cfg: mocks.resolvedConfig, + agentDir: "/tmp/openclaw-agent", authStore: mocks.ensureAuthProfileStore(), availableKeys: loaded.availableKeys, configuredByKey: new Map(), diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index 435faab1ec1..3c00dee3bbc 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -26,12 +26,14 @@ export async function modelsListCommand( runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); - const { ensureAuthProfileStore, ensureOpenClawModelsJson } = await import("./list.runtime.js"); + const { ensureAuthProfileStore, ensureOpenClawModelsJson, resolveOpenClawAgentDir } = + await import("./list.runtime.js"); const { sourceConfig, resolvedConfig: cfg } = await loadModelsConfigWithSource({ commandName: "models list", runtime, }); const authStore = ensureAuthProfileStore(); + const agentDir = resolveOpenClawAgentDir(); const providerFilter = (() => { const raw = opts.provider?.trim(); if (!raw) { @@ -70,6 +72,7 @@ export async function modelsListCommand( const rows: ModelRow[] = []; const rowContext = { cfg, + agentDir, authStore, availableKeys, configuredByKey, diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts new file mode 100644 index 00000000000..6bc05b7bae7 --- /dev/null +++ b/src/commands/models/list.provider-catalog.ts @@ -0,0 +1,121 @@ +import type { Api, Model } from "@mariozechner/pi-ai"; +import { normalizeProviderId } from "../../agents/provider-id.js"; +import type { ModelProviderConfig } from "../../config/types.models.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + groupPluginDiscoveryProvidersByOrder, + normalizePluginDiscoveryResult, + resolvePluginDiscoveryProviders, + runProviderCatalog, +} from "../../plugins/provider-discovery.js"; +import { resolveOwningPluginIdsForProvider } from "../../plugins/providers.js"; + +const CATALOG_DISPLAY_API_KEY = "__openclaw_catalog_display__"; +const DISCOVERY_ORDERS = ["simple", "profile", "paired", "late"] as const; +const SELF_HOSTED_DISCOVERY_PROVIDER_IDS = new Set(["lmstudio", "ollama", "sglang", "vllm"]); + +function modelFromProviderCatalog(params: { + provider: string; + providerConfig: ModelProviderConfig; + model: ModelProviderConfig["models"][number]; +}): Model { + return { + id: params.model.id, + name: params.model.name || params.model.id, + provider: params.provider, + api: params.model.api ?? params.providerConfig.api ?? "openai-responses", + baseUrl: params.providerConfig.baseUrl, + reasoning: params.model.reasoning, + input: params.model.input ?? ["text"], + cost: params.model.cost, + contextWindow: params.model.contextWindow, + contextTokens: params.model.contextTokens, + maxTokens: params.model.maxTokens, + headers: params.model.headers, + compat: params.model.compat, + } as Model; +} + +export async function loadProviderCatalogModelsForList(params: { + cfg: OpenClawConfig; + agentDir: string; + env?: NodeJS.ProcessEnv; + providerFilter?: string; +}): Promise[]> { + const env = params.env ?? process.env; + const providerFilter = params.providerFilter ? normalizeProviderId(params.providerFilter) : ""; + const onlyPluginIds = providerFilter + ? resolveOwningPluginIdsForProvider({ + provider: providerFilter, + config: params.cfg, + env, + }) + : undefined; + const providers = await resolvePluginDiscoveryProviders({ + config: params.cfg, + env, + ...(onlyPluginIds ? { onlyPluginIds } : {}), + }); + const byOrder = groupPluginDiscoveryProvidersByOrder(providers); + const rows: Model[] = []; + const seen = new Set(); + + for (const order of DISCOVERY_ORDERS) { + for (const provider of byOrder[order] ?? []) { + if (!providerFilter && SELF_HOSTED_DISCOVERY_PROVIDER_IDS.has(provider.id)) { + continue; + } + let result: Awaited> | null; + try { + result = await runProviderCatalog({ + provider, + config: params.cfg, + agentDir: params.agentDir, + env, + resolveProviderApiKey: () => ({ + apiKey: CATALOG_DISPLAY_API_KEY, + }), + resolveProviderAuth: () => ({ + apiKey: CATALOG_DISPLAY_API_KEY, + mode: "api_key", + source: "env", + }), + }); + } catch { + result = null; + } + const normalized = normalizePluginDiscoveryResult({ provider, result }); + for (const [providerIdRaw, providerConfig] of Object.entries(normalized)) { + const providerId = normalizeProviderId(providerIdRaw); + if (providerFilter && providerId !== providerFilter) { + continue; + } + if (!providerId || !Array.isArray(providerConfig.models)) { + continue; + } + for (const model of providerConfig.models) { + const key = `${providerId}/${model.id}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + rows.push( + modelFromProviderCatalog({ + provider: providerId, + providerConfig, + model, + }), + ); + } + } + } + } + + return rows.toSorted((left, right) => { + const provider = left.provider.localeCompare(right.provider); + if (provider !== 0) { + return provider; + } + return left.id.localeCompare(right.id); + }); +} diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 7f404c167f3..e856b16a894 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -5,7 +5,11 @@ import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadModelRegistry, toModelRow } from "./list.registry.js"; -import { loadModelCatalog, resolveModelWithRegistry } from "./list.runtime.js"; +import { + loadModelCatalog, + loadProviderCatalogModelsForList, + resolveModelWithRegistry, +} from "./list.runtime.js"; import type { ConfiguredEntry, ModelRow } from "./list.types.js"; import { isLocalBaseUrl, modelKey } from "./shared.js"; @@ -18,6 +22,7 @@ type RowFilter = { type RowBuilderContext = { cfg: OpenClawConfig; + agentDir: string; authStore: AuthProfileStore; availableKeys?: Set; configuredByKey: ConfiguredByKey; @@ -154,6 +159,39 @@ export async function appendCatalogSupplementRows(params: { ); params.seenKeys.add(key); } + + for (const model of await loadProviderCatalogModelsForList({ + cfg: params.context.cfg, + agentDir: params.context.agentDir, + providerFilter: params.context.filter.provider, + })) { + if (!matchesRowFilter(params.context.filter, model)) { + continue; + } + if ( + shouldSuppressBuiltInModel({ + provider: model.provider, + id: model.id, + baseUrl: model.baseUrl, + config: params.context.cfg, + }) + ) { + continue; + } + const key = modelKey(model.provider, model.id); + if (params.seenKeys.has(key)) { + continue; + } + params.rows.push( + buildRow({ + model, + key, + context: params.context, + allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key), + }), + ); + params.seenKeys.add(key); + } } export function appendConfiguredRows(params: { diff --git a/src/commands/models/list.runtime.ts b/src/commands/models/list.runtime.ts index 56c6c38ac79..8548faec3ef 100644 --- a/src/commands/models/list.runtime.ts +++ b/src/commands/models/list.runtime.ts @@ -10,3 +10,4 @@ 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";