From c3a81166fcd65817cc46507ff48a8fe1fb88006c Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:10 -0700 Subject: [PATCH 01/13] fix(cli): lazy load model commands --- src/cli/capability-cli.test.ts | 4 ++++ src/cli/capability-cli.ts | 2 +- src/cli/models-cli.test.ts | 6 ++++++ src/cli/models-cli.ts | 4 +++- src/cli/program/routed-command-definitions.ts | 21 ++++++++++++------- src/cli/program/routes.test.ts | 6 ++++++ 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 5e2e1e5c33b..24cfbb27ad3 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -170,6 +170,10 @@ vi.mock("../commands/models/list.js", () => ({ modelsStatusCommand: mocks.modelsStatusCommand as typeof import("../commands/models/list.js").modelsStatusCommand, })); +vi.mock("../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: + mocks.modelsStatusCommand as typeof import("../commands/models/list.status-command.js").modelsStatusCommand, +})); vi.mock("../gateway/call.js", () => ({ callGateway: mocks.callGateway as typeof import("../gateway/call.js").callGateway, diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index 8eb73b1d42c..a5ba86618b7 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -13,7 +13,6 @@ import { import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { modelsStatusCommand } from "../commands/models/list.js"; import { loadConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -683,6 +682,7 @@ async function buildModelProviders() { async function runModelAuthStatus() { const captured: string[] = []; + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: true }, { diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 30f3f2fc03f..6b46baa3f1a 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -49,6 +49,12 @@ vi.mock("../commands/models/list.js", () => ({ modelsListCommand: mocks.noopAsync, modelsStatusCommand: mocks.modelsStatusCommand, })); +vi.mock("../commands/models/list.list-command.js", () => ({ + modelsListCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: mocks.modelsStatusCommand, +})); vi.mock("../commands/models/auth.js", () => ({ modelsAuthAddCommand: mocks.modelsAuthAddCommand, modelsAuthLoginCommand: mocks.modelsAuthLoginCommand, diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 0773a55bb54..a769aae2113 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -1,5 +1,4 @@ import type { Command } from "commander"; -import { modelsListCommand, modelsStatusCommand } from "../commands/models/list.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; @@ -35,6 +34,7 @@ export function registerModelsCli(program: Command) { .option("--plain", "Plain line output", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsListCommand } = await import("../commands/models/list.list-command.js"); await modelsListCommand(opts, defaultRuntime); }); }); @@ -71,6 +71,7 @@ export function registerModelsCli(program: Command) { const agent = resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); await runModelsCommand(async () => { + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: Boolean(opts.json), @@ -270,6 +271,7 @@ export function registerModelsCli(program: Command) { models.action(async (opts) => { await runModelsCommand(async () => { + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: Boolean(opts?.statusJson), diff --git a/src/cli/program/routed-command-definitions.ts b/src/cli/program/routed-command-definitions.ts index 8557039ac19..09873d6cdd3 100644 --- a/src/cli/program/routed-command-definitions.ts +++ b/src/cli/program/routed-command-definitions.ts @@ -17,7 +17,8 @@ type RouteArgParser = (argv: string[]) => TArgs | null; type ParsedRouteArgs> = Exclude, null>; type ConfigCliModule = typeof import("../config-cli.js"); -type ModelsListModule = typeof import("../../commands/models/list.js"); +type ModelsListCommandModule = typeof import("../../commands/models/list.list-command.js"); +type ModelsStatusCommandModule = typeof import("../../commands/models/list.status-command.js"); export type RoutedCommandDefinition> = { parseArgs: TParse; @@ -36,16 +37,22 @@ function defineRoutedCommand>( } let configCliPromise: Promise | undefined; -let modelsListPromise: Promise | undefined; +let modelsListCommandPromise: Promise | undefined; +let modelsStatusCommandPromise: Promise | undefined; function loadConfigCli(): Promise { configCliPromise ??= import("../config-cli.js"); return configCliPromise; } -function loadModelsList(): Promise { - modelsListPromise ??= import("../../commands/models/list.js"); - return modelsListPromise; +function loadModelsListCommand(): Promise { + modelsListCommandPromise ??= import("../../commands/models/list.list-command.js"); + return modelsListCommandPromise; +} + +function loadModelsStatusCommand(): Promise { + modelsStatusCommandPromise ??= import("../../commands/models/list.status-command.js"); + return modelsStatusCommandPromise; } export const routedCommandDefinitions = { @@ -114,14 +121,14 @@ export const routedCommandDefinitions = { "models-list": defineRoutedCommand({ parseArgs: parseModelsListRouteArgs, runParsedArgs: async (args) => { - const { modelsListCommand } = await loadModelsList(); + const { modelsListCommand } = await loadModelsListCommand(); await modelsListCommand(args, defaultRuntime); }, }), "models-status": defineRoutedCommand({ parseArgs: parseModelsStatusRouteArgs, runParsedArgs: async (args) => { - const { modelsStatusCommand } = await loadModelsList(); + const { modelsStatusCommand } = await loadModelsStatusCommand(); await modelsStatusCommand(args, defaultRuntime); }, }), diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 631d564f865..468458762b3 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -23,6 +23,12 @@ vi.mock("../../commands/models/list.js", () => ({ modelsListCommand: modelsListCommandMock, modelsStatusCommand: modelsStatusCommandMock, })); +vi.mock("../../commands/models/list.list-command.js", () => ({ + modelsListCommand: modelsListCommandMock, +})); +vi.mock("../../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: modelsStatusCommandMock, +})); vi.mock("../daemon-cli/status.js", () => ({ runDaemonStatus: runDaemonStatusMock, From b7533f5112aed3dd642b973964fc4b02d15e3239 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:28 -0700 Subject: [PATCH 02/13] fix(models): avoid registry for configured list --- .../list.list-command.forward-compat.test.ts | 56 ++--- src/commands/models/list.list-command.ts | 90 ++++++-- src/commands/models/list.registry-load.ts | 3 +- src/commands/models/list.registry.ts | 17 +- src/commands/models/list.row-sources.ts | 27 +-- src/commands/models/list.rows.test.ts | 8 +- src/commands/models/list.rows.ts | 210 ++++++++++++++---- 7 files changed, 292 insertions(+), 119 deletions(-) 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 0e539e0689c..55c12fa3317 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -147,6 +147,7 @@ function installModelsListCommandForwardCompatMocks() { vi.doMock("./list.provider-catalog.js", () => ({ hasProviderStaticCatalogForFilter: mocks.hasProviderStaticCatalogForFilter, + loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, })); vi.doMock("./list.manifest-catalog.js", () => ({ @@ -190,14 +191,27 @@ function installModelsListCommandForwardCompatMocks() { }, })); - vi.doMock("./list.runtime.js", () => ({ - ensureOpenClawModelsJson: mocks.ensureOpenClawModelsJson, - ensureAuthProfileStore: mocks.ensureAuthProfileStore, + vi.doMock("../../agents/auth-profiles/store.js", () => ({ + loadAuthProfileStoreWithoutExternalProfiles: mocks.ensureAuthProfileStore, + })); + + vi.doMock("../../agents/agent-paths.js", () => ({ resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, + })); + + vi.doMock("../../agents/auth-profiles/profile-list.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, + })); + + vi.doMock("../../agents/model-catalog.js", () => ({ loadModelCatalog: mocks.loadModelCatalog, - loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, + })); + + vi.doMock("../../agents/pi-embedded-runner/model.js", () => ({ resolveModelWithRegistry: mocks.resolveModelWithRegistry, + })); + + vi.doMock("../../agents/model-auth.js", () => ({ resolveEnvApiKey: vi.fn().mockReturnValue(undefined), resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), @@ -232,7 +246,7 @@ async function buildAllOpenAiCodexRows(opts: { supplementCatalog?: boolean } = { ), filter: { provider: "openai-codex" }, }; - const seenKeys = listRowsModule.appendDiscoveredRows({ + const seenKeys = await listRowsModule.appendDiscoveredRows({ rows: rows as never, models: loaded.models as never, modelRegistry: loaded.registry as never, @@ -256,17 +270,14 @@ beforeEach(() => { describe("modelsListCommand forward-compat", () => { describe("configured rows", () => { - it("passes provider filters into registry loading before row assembly", async () => { + it("keeps configured provider filters on the registry-free row path", async () => { const runtime = createRuntime(); await modelsListCommand({ json: true, provider: "moonshot" }, runtime as never); - expect(mocks.loadModelRegistry).toHaveBeenCalledWith( - mocks.resolvedConfig, - expect.objectContaining({ - providerFilter: "moonshot", - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.printModelTable).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("No models found."); }); it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => { @@ -345,17 +356,12 @@ describe("modelsListCommand forward-compat", () => { expect(codexPro?.tags).not.toContain("missing"); }); - it("loads model registry without source config persistence input", async () => { + it("does not load the model registry for configured-mode listing", async () => { const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); - expect(mocks.loadModelRegistry).toHaveBeenCalledWith( - mocks.resolvedConfig, - expect.not.objectContaining({ - sourceConfig: expect.anything(), - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); }); it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { @@ -568,19 +574,15 @@ describe("modelsListCommand forward-compat", () => { ]); }); - it("keeps the registry path for provider filters without static catalog coverage", async () => { + it("does not fall back to the registry for provider filters without 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", - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("No models found."); }); it("includes provider-owned supplemental catalog rows with provider filters", async () => { @@ -748,7 +750,7 @@ describe("modelsListCommand forward-compat", () => { it("suppresses direct openai gpt-5.3-codex-spark rows in --all output", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); const rows: unknown[] = []; - listRowsModule.appendDiscoveredRows({ + await listRowsModule.appendDiscoveredRows({ rows: rows as never, models: [ { diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index be3f411c0d5..52cba5f94e5 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -5,13 +5,6 @@ import type { RuntimeEnv } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveConfiguredEntries } from "./list.configured.js"; import { formatErrorWithStack } from "./list.errors.js"; -import { hasProviderStaticCatalogForFilter } from "./list.provider-catalog.js"; -import { loadConfiguredListModelRegistry, loadListModelRegistry } from "./list.registry-load.js"; -import { - appendAllModelRowSources, - appendConfiguredModelRowSources, - modelRowSourcesRequireRegistry, -} from "./list.row-sources.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; import { loadModelsConfigWithSource } from "./load-config.js"; @@ -19,6 +12,45 @@ import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; +type RegistryLoadModule = typeof import("./list.registry-load.js"); +type RowSourcesModule = typeof import("./list.row-sources.js"); +type ProviderCatalogModule = typeof import("./list.provider-catalog.js"); + +let registryLoadModulePromise: Promise | undefined; +let rowSourcesModulePromise: Promise | undefined; +let providerCatalogModulePromise: Promise | undefined; + +function loadRegistryLoadModule(): Promise { + registryLoadModulePromise ??= import("./list.registry-load.js"); + return registryLoadModulePromise; +} + +function loadRowSourcesModule(): Promise { + rowSourcesModulePromise ??= import("./list.row-sources.js"); + return rowSourcesModulePromise; +} + +function loadProviderCatalogModule(): Promise { + providerCatalogModulePromise ??= import("./list.provider-catalog.js"); + return providerCatalogModulePromise; +} + +function modelRowSourcesRequireRegistry(params: { + all?: boolean; + providerFilter?: string; + useManifestCatalogFastPath: boolean; + useProviderCatalogFastPath: boolean; + useProviderIndexCatalogFastPath: boolean; +}): boolean { + if (!params.all) { + return false; + } + if (params.providerFilter) { + return false; + } + return true; +} + export async function modelsListCommand( opts: { all?: boolean; @@ -48,12 +80,16 @@ export async function modelsListCommand( if (providerFilter === null) { return; } - const { ensureAuthProfileStore, resolveOpenClawAgentDir } = await import("./list.runtime.js"); + const [{ loadAuthProfileStoreWithoutExternalProfiles }, { resolveOpenClawAgentDir }] = + await Promise.all([ + import("../../agents/auth-profiles/store.js"), + import("../../agents/agent-paths.js"), + ]); const { resolvedConfig: cfg } = await loadModelsConfigWithSource({ commandName: "models list", runtime, }); - const authStore = ensureAuthProfileStore(); + const authStore = loadAuthProfileStoreWithoutExternalProfiles(); const agentDir = resolveOpenClawAgentDir(); let modelRegistry: ModelRegistry | undefined; @@ -69,16 +105,24 @@ export async function modelsListCommand( manifestCatalogRows = loadStaticManifestCatalogRowsForList({ cfg, providerFilter }); } const useManifestCatalogFastPath = manifestCatalogRows.length > 0; - const useProviderCatalogFastPath = - !useManifestCatalogFastPath && opts.all && providerFilter - ? await hasProviderStaticCatalogForFilter({ cfg, providerFilter }) - : false; - if (!useManifestCatalogFastPath && !useProviderCatalogFastPath && opts.all && providerFilter) { + if (!useManifestCatalogFastPath && opts.all && providerFilter) { const { loadProviderIndexCatalogRowsForList } = await import("./list.provider-index-catalog.js"); providerIndexCatalogRows = loadProviderIndexCatalogRowsForList({ cfg, providerFilter }); } const useProviderIndexCatalogFastPath = providerIndexCatalogRows.length > 0; + const useProviderCatalogFastPath = await (async () => { + if ( + useManifestCatalogFastPath || + useProviderIndexCatalogFastPath || + !opts.all || + !providerFilter + ) { + return false; + } + const { hasProviderStaticCatalogForFilter } = await loadProviderCatalogModule(); + return hasProviderStaticCatalogForFilter({ cfg, providerFilter }); + })(); const shouldLoadRegistry = modelRowSourcesRequireRegistry({ all: opts.all, providerFilter, @@ -87,6 +131,7 @@ export async function modelsListCommand( useProviderIndexCatalogFastPath, }); const loadRegistryState = async () => { + const { loadListModelRegistry } = await loadRegistryLoadModule(); const loaded = await loadListModelRegistry(cfg, { providerFilter }); modelRegistry = loaded.registry; discoveredKeys = loaded.discoveredKeys; @@ -96,7 +141,8 @@ export async function modelsListCommand( try { if (shouldLoadRegistry) { await loadRegistryState(); - } else if (!opts.all) { + } else if (!opts.all && opts.local) { + const { loadConfiguredListModelRegistry } = await loadRegistryLoadModule(); const loaded = loadConfiguredListModelRegistry(cfg, entries, { providerFilter }); modelRegistry = loaded.registry; discoveredKeys = loaded.discoveredKeys; @@ -123,6 +169,7 @@ export async function modelsListCommand( const rows: ModelRow[] = []; if (opts.all) { + const { appendAllModelRowSources } = await loadRowSourcesModule(); let rowContext = buildRowContext( useManifestCatalogFastPath || useProviderCatalogFastPath || useProviderIndexCatalogFastPath, ); @@ -158,17 +205,12 @@ export async function modelsListCommand( }); } } else { - const registry = modelRegistry; - if (!registry) { - runtime.error("Model registry unavailable."); - process.exitCode = 1; - return; - } - appendConfiguredModelRowSources({ + const { appendConfiguredModelRowSources } = await loadRowSourcesModule(); + await appendConfiguredModelRowSources({ rows, entries, - modelRegistry: registry, - context: buildRowContext(false), + modelRegistry, + context: buildRowContext(!modelRegistry), }); } diff --git a/src/commands/models/list.registry-load.ts b/src/commands/models/list.registry-load.ts index e4114be6a04..d7e683c3c38 100644 --- a/src/commands/models/list.registry-load.ts +++ b/src/commands/models/list.registry-load.ts @@ -1,9 +1,10 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; +import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadModelRegistry } from "./list.registry.js"; -import { discoverAuthStorage, discoverModels, resolveOpenClawAgentDir } from "./list.runtime.js"; import type { ConfiguredEntry } from "./list.types.js"; import { modelKey } from "./shared.js"; diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 29b4e67ddd5..972b8ab43ac 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -1,7 +1,15 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; +import { listProfilesForProvider } from "../../agents/auth-profiles/profile-list.js"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { + hasUsableCustomProviderApiKey, + resolveAwsSdkEnvVarName, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; +import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../../plugins/synthetic-auth.runtime.js"; import { @@ -10,15 +18,6 @@ import { shouldFallbackToAuthHeuristics, } from "./list.errors.js"; import { toModelRow as toModelRowBase } from "./list.model-row.js"; -import { - discoverAuthStorage, - discoverModels, - hasUsableCustomProviderApiKey, - listProfilesForProvider, - resolveAwsSdkEnvVarName, - resolveEnvApiKey, - resolveOpenClawAgentDir, -} from "./list.runtime.js"; import type { ModelRow } from "./list.types.js"; import { modelKey } from "./shared.js"; diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index bb868a5706d..ec5dc6363f0 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -37,12 +37,7 @@ export function modelRowSourcesRequireRegistry(params: { if (!params.all) { return false; } - if ( - params.providerFilter && - (params.useManifestCatalogFastPath || - params.useProviderCatalogFastPath || - params.useProviderIndexCatalogFastPath) - ) { + if (params.providerFilter) { return false; } return true; @@ -58,14 +53,14 @@ export async function appendAllModelRowSources( params.useProviderIndexCatalogFastPath) ) { let seenKeys = new Set(); - appendConfiguredProviderRows({ + await appendConfiguredProviderRows({ rows: params.rows, context: params.context, seenKeys, }); let catalogRows = 0; if (params.useManifestCatalogFastPath) { - catalogRows = appendManifestCatalogRows({ + catalogRows = await appendManifestCatalogRows({ rows: params.rows, context: params.context, seenKeys, @@ -81,7 +76,7 @@ export async function appendAllModelRowSources( }); } if (catalogRows === 0 && params.useProviderIndexCatalogFastPath) { - catalogRows = appendModelCatalogRows({ + catalogRows = await appendModelCatalogRows({ rows: params.rows, context: params.context, seenKeys, @@ -92,7 +87,7 @@ export async function appendAllModelRowSources( if (!params.modelRegistry) { return { requiresRegistryFallback: true }; } - appendDiscoveredRows({ + await appendDiscoveredRows({ rows: params.rows, models: params.modelRegistry.getAll(), modelRegistry: params.modelRegistry, @@ -102,14 +97,14 @@ export async function appendAllModelRowSources( return { requiresRegistryFallback: false }; } - const seenKeys = appendDiscoveredRows({ + const seenKeys = await appendDiscoveredRows({ rows: params.rows, models: params.modelRegistry?.getAll() ?? [], modelRegistry: params.modelRegistry, context: params.context, }); - appendConfiguredProviderRows({ + await appendConfiguredProviderRows({ rows: params.rows, context: params.context, seenKeys, @@ -133,11 +128,11 @@ export async function appendAllModelRowSources( return { requiresRegistryFallback: false }; } -export function appendConfiguredModelRowSources(params: { +export async function appendConfiguredModelRowSources(params: { rows: ModelRow[]; entries: ConfiguredEntry[]; - modelRegistry: ModelRegistry; + modelRegistry?: ModelRegistry; context: RowBuilderContext; -}): void { - appendConfiguredRows(params); +}): Promise { + await appendConfiguredRows(params); } diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index e2f77819e47..663596dc9f6 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -23,9 +23,15 @@ vi.mock("../../agents/model-suppression.js", () => ({ shouldSuppressBuiltInModel: mocks.shouldSuppressBuiltInModel, })); -vi.mock("./list.runtime.js", () => ({ +vi.mock("./list.provider-catalog.js", () => ({ loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, +})); + +vi.mock("../../agents/auth-profiles/profile-list.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, +})); + +vi.mock("../../agents/model-auth.js", () => ({ resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), resolveEnvApiKey: vi.fn().mockReturnValue(null), hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 18d6a0097a5..df6b9bd5dc5 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -2,22 +2,27 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { + hasUsableCustomProviderApiKey, + resolveAwsSdkEnvVarName, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; import type { ListRowModel } from "./list.model-row.js"; -import { toModelRow } from "./list.registry.js"; -import { - loadModelCatalog, - loadProviderCatalogModelsForList, - resolveModelWithRegistry, -} from "./list.runtime.js"; +import { toModelRow } from "./list.model-row.js"; import type { ConfiguredEntry, ModelRow } from "./list.types.js"; import { isLocalBaseUrl, modelKey } from "./shared.js"; type ConfiguredByKey = Map; +type ModelCatalogModule = typeof import("../../agents/model-catalog.js"); +type ModelResolverModule = typeof import("../../agents/pi-embedded-runner/model.js"); +type ProfileListModule = typeof import("../../agents/auth-profiles/profile-list.js"); +type ProviderCatalogModule = typeof import("./list.provider-catalog.js"); +type SyntheticAuthModule = typeof import("../../plugins/synthetic-auth.runtime.js"); type RowFilter = { provider?: string; @@ -35,6 +40,37 @@ export type RowBuilderContext = { skipRuntimeModelSuppression?: boolean; }; +let modelCatalogModulePromise: Promise | undefined; +let modelResolverModulePromise: Promise | undefined; +let profileListModulePromise: Promise | undefined; +let providerCatalogModulePromise: Promise | undefined; +let syntheticAuthModulePromise: Promise | undefined; + +function loadModelCatalogModule(): Promise { + modelCatalogModulePromise ??= import("../../agents/model-catalog.js"); + return modelCatalogModulePromise; +} + +function loadModelResolverModule(): Promise { + modelResolverModulePromise ??= import("../../agents/pi-embedded-runner/model.js"); + return modelResolverModulePromise; +} + +function loadProfileListModule(): Promise { + profileListModulePromise ??= import("../../agents/auth-profiles/profile-list.js"); + return profileListModulePromise; +} + +function loadProviderCatalogModule(): Promise { + providerCatalogModulePromise ??= import("./list.provider-catalog.js"); + return providerCatalogModulePromise; +} + +function loadSyntheticAuthModule(): Promise { + syntheticAuthModulePromise ??= import("../../plugins/synthetic-auth.runtime.js"); + return syntheticAuthModulePromise; +} + function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) { if (filter.provider && normalizeProviderId(model.provider) !== filter.provider) { return false; @@ -45,13 +81,44 @@ function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl? return true; } -function buildRow(params: { +async function hasAuthForProvider(params: { + provider: string; + cfg: OpenClawConfig; + authStore: AuthProfileStore; +}): Promise { + const { listProfilesForProvider } = await loadProfileListModule(); + if (listProfilesForProvider(params.authStore, params.provider).length > 0) { + return true; + } + if (params.provider === "amazon-bedrock" && resolveAwsSdkEnvVarName()) { + return true; + } + if (resolveEnvApiKey(params.provider)) { + return true; + } + if (hasUsableCustomProviderApiKey(params.cfg, params.provider)) { + return true; + } + const { resolveRuntimeSyntheticAuthProviderRefs } = await loadSyntheticAuthModule(); + return resolveRuntimeSyntheticAuthProviderRefs().includes(params.provider); +} + +async function buildRow(params: { model: ListRowModel; key: string; context: RowBuilderContext; allowProviderAvailabilityFallback?: boolean; -}): ModelRow { +}): Promise { const configured = params.context.configuredByKey.get(params.key); + const shouldResolveProviderAuth = + params.context.availableKeys === undefined || params.allowProviderAvailabilityFallback === true; + const hasProviderAuth = shouldResolveProviderAuth + ? await hasAuthForProvider({ + provider: params.model.provider, + cfg: params.context.cfg, + authStore: params.context.authStore, + }) + : false; return toModelRow({ model: params.model, key: params.key, @@ -61,6 +128,7 @@ function buildRow(params: { cfg: params.context.cfg, authStore: params.context.authStore, allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback ?? false, + hasAuthForProvider: shouldResolveProviderAuth ? () => hasProviderAuth : undefined, }); } @@ -79,14 +147,14 @@ function shouldSuppressListModel(params: { }); } -function appendVisibleRow(params: { +async function appendVisibleRow(params: { rows: ModelRow[]; model: ListRowModel; key: string; context: RowBuilderContext; seenKeys?: Set; allowProviderAvailabilityFallback?: boolean; -}): boolean { +}): Promise { if (params.seenKeys?.has(params.key)) { return false; } @@ -97,7 +165,7 @@ function appendVisibleRow(params: { return false; } params.rows.push( - buildRow({ + await buildRow({ model: params.model, key: params.key, context: params.context, @@ -153,13 +221,49 @@ function shouldListConfiguredProviderModel(params: { return params.providerConfig.api !== undefined || params.model.api !== undefined; } -export function appendDiscoveredRows(params: { +function findConfiguredProviderModel(params: { + cfg: OpenClawConfig; + provider: string; + modelId: string; +}): ListRowModel | undefined { + const providerConfig = params.cfg.models?.providers?.[params.provider]; + const configuredModel = providerConfig?.models?.find((model) => model.id === params.modelId); + if (!providerConfig || !configuredModel) { + return undefined; + } + return toConfiguredProviderListModel({ + provider: params.provider, + providerConfig, + model: configuredModel, + }); +} + +function toFallbackConfiguredListModel(entry: ConfiguredEntry, cfg: OpenClawConfig): ListRowModel { + return ( + findConfiguredProviderModel({ + cfg, + provider: entry.ref.provider, + modelId: entry.ref.model, + }) ?? { + provider: entry.ref.provider, + id: entry.ref.model, + name: entry.ref.model, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_TOKENS, + } + ); +} + +export async function appendDiscoveredRows(params: { rows: ModelRow[]; models: Model[]; modelRegistry?: ModelRegistry; context: RowBuilderContext; -}): Set { +}): Promise> { const seenKeys = new Set(); + const modelResolver = params.modelRegistry + ? (await loadModelResolverModule()).resolveModelWithRegistry + : undefined; const sorted = [...params.models].toSorted((a, b) => { const providerCompare = a.provider.localeCompare(b.provider); if (providerCompare !== 0) { @@ -170,20 +274,21 @@ export function appendDiscoveredRows(params: { for (const model of sorted) { const key = modelKey(model.provider, model.id); - const resolvedModel = params.modelRegistry - ? resolveModelWithRegistry({ - provider: model.provider, - modelId: model.id, - modelRegistry: params.modelRegistry, - cfg: params.context.cfg, - agentDir: params.context.agentDir, - }) - : undefined; + const resolvedModel = + params.modelRegistry && modelResolver + ? modelResolver({ + provider: model.provider, + modelId: model.id, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + agentDir: params.context.agentDir, + }) + : undefined; const rowModel = resolvedModel && modelKey(resolvedModel.provider, resolvedModel.id) === key ? resolvedModel : model; - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model: rowModel, key, @@ -195,11 +300,11 @@ export function appendDiscoveredRows(params: { return seenKeys; } -export function appendConfiguredProviderRows(params: { +export async function appendConfiguredProviderRows(params: { rows: ModelRow[]; context: RowBuilderContext; seenKeys: Set; -}): void { +}): Promise { for (const [provider, providerConfig] of Object.entries( params.context.cfg.models?.providers ?? {}, )) { @@ -213,7 +318,7 @@ export function appendConfiguredProviderRows(params: { providerConfig, model: configuredModel, }); - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -225,17 +330,17 @@ export function appendConfiguredProviderRows(params: { } } -export function appendModelCatalogRows(params: { +export async function appendModelCatalogRows(params: { rows: ModelRow[]; context: RowBuilderContext; seenKeys: Set; catalogRows: readonly NormalizedModelCatalogRow[]; -}): number { +}): Promise { let appended = 0; for (const catalogRow of params.catalogRows) { const key = modelKey(catalogRow.provider, catalogRow.id); if ( - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model: toManifestCatalogListModel(catalogRow), key, @@ -255,7 +360,7 @@ export function appendManifestCatalogRows(params: { context: RowBuilderContext; seenKeys: Set; manifestRows: readonly NormalizedModelCatalogRow[]; -}): number { +}): Promise { return appendModelCatalogRows({ ...params, catalogRows: params.manifestRows, @@ -268,6 +373,10 @@ export async function appendCatalogSupplementRows(params: { context: RowBuilderContext; seenKeys: Set; }): Promise { + const [{ loadModelCatalog }, { resolveModelWithRegistry }] = await Promise.all([ + loadModelCatalogModule(), + loadModelResolverModule(), + ]); const catalog = await loadModelCatalog({ config: params.context.cfg, readOnly: true }); for (const entry of catalog) { if ( @@ -286,7 +395,7 @@ export async function appendCatalogSupplementRows(params: { if (!model) { continue; } - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -314,6 +423,7 @@ export async function appendProviderCatalogRows(params: { staticOnly?: boolean; }): Promise { let appended = 0; + const { loadProviderCatalogModelsForList } = await loadProviderCatalogModule(); for (const model of await loadProviderCatalogModelsForList({ cfg: params.context.cfg, agentDir: params.context.agentDir, @@ -322,7 +432,7 @@ export async function appendProviderCatalogRows(params: { })) { const key = modelKey(model.provider, model.id); if ( - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -337,12 +447,15 @@ export async function appendProviderCatalogRows(params: { return appended; } -export function appendConfiguredRows(params: { +export async function appendConfiguredRows(params: { rows: ModelRow[]; entries: ConfiguredEntry[]; - modelRegistry: ModelRegistry; + modelRegistry?: ModelRegistry; context: RowBuilderContext; -}) { +}): Promise { + const resolveModelWithRegistry = params.modelRegistry + ? (await loadModelResolverModule()).resolveModelWithRegistry + : undefined; for (const entry of params.entries) { if ( params.context.filter.provider && @@ -350,12 +463,15 @@ export function appendConfiguredRows(params: { ) { continue; } - const model = resolveModelWithRegistry({ - provider: entry.ref.provider, - modelId: entry.ref.model, - modelRegistry: params.modelRegistry, - cfg: params.context.cfg, - }); + const model = + params.modelRegistry && resolveModelWithRegistry + ? resolveModelWithRegistry({ + provider: entry.ref.provider, + modelId: entry.ref.model, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + }) + : toFallbackConfiguredListModel(entry, params.context.cfg); if (params.context.filter.local && model && !isLocalBaseUrl(model.baseUrl ?? "")) { continue; } @@ -365,6 +481,17 @@ export function appendConfiguredRows(params: { if (model && shouldSuppressListModel({ model, context: params.context })) { continue; } + const shouldResolveProviderAuth = + model && + (params.context.availableKeys === undefined || + !params.context.discoveredKeys.has(modelKey(model.provider, model.id))); + const hasProviderAuth = shouldResolveProviderAuth + ? await hasAuthForProvider({ + provider: model.provider, + cfg: params.context.cfg, + authStore: params.context.authStore, + }) + : false; params.rows.push( toModelRow({ model, @@ -377,6 +504,7 @@ export function appendConfiguredRows(params: { allowProviderAvailabilityFallback: model ? !params.context.discoveredKeys.has(modelKey(model.provider, model.id)) : false, + hasAuthForProvider: shouldResolveProviderAuth ? () => hasProviderAuth : undefined, }), ); } From 9c25c697dd59392522ec6592335a0b0f6aede443 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:48 -0700 Subject: [PATCH 03/13] fix(models): keep cold catalog lookup registry indexed --- .../models/list.provider-catalog.test.ts | 15 ++-- src/commands/models/list.provider-catalog.ts | 9 ++- src/plugins/synthetic-auth.runtime.test.ts | 72 +++++++++++-------- src/plugins/synthetic-auth.runtime.ts | 10 +-- 4 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index e599126a945..9752770dfa0 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -6,7 +6,7 @@ import { } from "./list.provider-catalog.js"; const providerDiscoveryMocks = vi.hoisted(() => ({ - loadPluginRegistrySnapshot: vi.fn(), + loadPluginRegistrySnapshotWithMetadata: vi.fn(), resolvePluginContributionOwners: vi.fn(), resolveProviderOwners: vi.fn(), resolveBundledProviderCompatPluginIds: vi.fn(), @@ -17,7 +17,8 @@ const providerDiscoveryMocks = vi.hoisted(() => ({ vi.mock("../../plugins/plugin-registry.js", () => ({ loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }), - loadPluginRegistrySnapshot: providerDiscoveryMocks.loadPluginRegistrySnapshot, + loadPluginRegistrySnapshotWithMetadata: + providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata, resolvePluginContributionOwners: providerDiscoveryMocks.resolvePluginContributionOwners, resolveProviderOwners: providerDiscoveryMocks.resolveProviderOwners, })); @@ -115,8 +116,11 @@ const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider]; describe("loadProviderCatalogModelsForList", () => { beforeEach(() => { vi.clearAllMocks(); - providerDiscoveryMocks.loadPluginRegistrySnapshot.mockReturnValue({ - plugins: [], + providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: { + plugins: [], + }, diagnostics: [], }); providerDiscoveryMocks.resolveProviderOwners.mockImplementation( @@ -197,9 +201,10 @@ describe("loadProviderCatalogModelsForList", () => { }), ).resolves.toEqual(["moonshot"]); - expect(providerDiscoveryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({ + expect(providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ config: baseParams.cfg, env: baseParams.env, + cache: true, }); expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); }); diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index 26b33b272f8..14491a15beb 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { - loadPluginRegistrySnapshot, + loadPluginRegistrySnapshotWithMetadata, resolvePluginContributionOwners, resolveProviderOwners, type PluginRegistrySnapshot, @@ -70,10 +70,15 @@ function resolveInstalledIndexPluginIdsForProviderFilter(params: { env?: NodeJS.ProcessEnv; providerFilter: string; }): string[] | undefined { - const index = loadPluginRegistrySnapshot({ + const snapshot = loadPluginRegistrySnapshotWithMetadata({ config: params.cfg, env: params.env, + cache: true, }); + if (snapshot.source !== "persisted" && snapshot.source !== "provided") { + return []; + } + const index = snapshot.snapshot; const pluginIds = [ ...collectMatchingContributionOwners(index, "providers", params.providerFilter, params.cfg), ...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter, params.cfg), diff --git a/src/plugins/synthetic-auth.runtime.test.ts b/src/plugins/synthetic-auth.runtime.test.ts index 0c3ecd81e61..1d51d37b8a3 100644 --- a/src/plugins/synthetic-auth.runtime.test.ts +++ b/src/plugins/synthetic-auth.runtime.test.ts @@ -2,26 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const getPluginRegistryState = vi.hoisted(() => vi.fn()); const pluginRegistryMocks = vi.hoisted(() => ({ - loadPluginManifestRegistryForInstalledIndex: vi.fn(), - loadPluginRegistrySnapshot: vi.fn((_params?: unknown) => ({ plugins: [] })), + loadPluginRegistrySnapshotWithMetadata: vi.fn((_params?: unknown) => ({ + source: "persisted", + snapshot: { plugins: [] }, + diagnostics: [], + })), })); vi.mock("./runtime-state.js", () => ({ getPluginRegistryState, })); -vi.mock("./manifest-registry-installed.js", () => ({ - loadPluginManifestRegistryForInstalledIndex: - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex, -})); - vi.mock("./plugin-registry.js", () => ({ - loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot, - loadPluginManifestRegistryForPluginRegistry: () => - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex({ - index: pluginRegistryMocks.loadPluginRegistrySnapshot({ cache: true }), - includeDisabled: true, - }), + loadPluginRegistrySnapshotWithMetadata: + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata, })); import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtime.js"; @@ -29,19 +23,24 @@ import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtim describe("synthetic auth runtime refs", () => { beforeEach(() => { getPluginRegistryState.mockReset(); - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex - .mockReset() - .mockReturnValue({ plugins: [] }); - pluginRegistryMocks.loadPluginRegistrySnapshot.mockReset().mockReturnValue({ plugins: [] }); + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset().mockReturnValue({ + source: "persisted", + snapshot: { plugins: [] }, + diagnostics: [], + }); }); - it("uses manifest-owned synthetic auth refs before the runtime registry exists", () => { - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({ - plugins: [ - { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, - { syntheticAuthRefs: ["remote-provider"] }, - { syntheticAuthRefs: [] }, - ], + it("uses persisted registry synthetic auth refs before the runtime registry exists", () => { + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: { + plugins: [ + { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, + { syntheticAuthRefs: ["remote-provider"] }, + { syntheticAuthRefs: [] }, + ], + }, + diagnostics: [], }); expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([ @@ -49,13 +48,27 @@ describe("synthetic auth runtime refs", () => { "local-cli", "remote-provider", ]); - expect(pluginRegistryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({ cache: true }); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith({ - index: expect.anything(), - includeDisabled: true, + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ + cache: true, }); }); + it("does not derive the registry just to resolve synthetic auth refs", () => { + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "derived", + snapshot: { + plugins: [ + { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, + { syntheticAuthRefs: ["remote-provider"] }, + { syntheticAuthRefs: [] }, + ], + }, + diagnostics: [], + }); + + expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([]); + }); + it("prefers the active runtime registry when plugins are already loaded", () => { getPluginRegistryState.mockReturnValue({ activeRegistry: { @@ -84,7 +97,6 @@ describe("synthetic auth runtime refs", () => { }); expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual(["runtime-provider", "runtime-cli"]); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); - expect(pluginRegistryMocks.loadPluginRegistrySnapshot).not.toHaveBeenCalled(); + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/synthetic-auth.runtime.ts b/src/plugins/synthetic-auth.runtime.ts index efed10f1de3..b8d6b4b1c46 100644 --- a/src/plugins/synthetic-auth.runtime.ts +++ b/src/plugins/synthetic-auth.runtime.ts @@ -1,5 +1,5 @@ import { normalizeProviderId } from "../agents/provider-id.js"; -import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry.js"; import { getPluginRegistryState } from "./runtime-state.js"; function uniqueProviderRefs(values: readonly string[]): string[] { @@ -18,10 +18,12 @@ function uniqueProviderRefs(values: readonly string[]): string[] { } function resolveManifestSyntheticAuthProviderRefs(): string[] { + const result = loadPluginRegistrySnapshotWithMetadata({ cache: true }); + if (result.source !== "persisted" && result.source !== "provided") { + return []; + } return uniqueProviderRefs( - loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true }).plugins.flatMap( - (plugin) => plugin.syntheticAuthRefs ?? [], - ), + result.snapshot.plugins.flatMap((plugin) => plugin.syntheticAuthRefs ?? []), ); } From 38ea99ec7425de95b21c1669552a883b61632b88 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:25:04 -0700 Subject: [PATCH 04/13] fix(cli): lazy load plugin maintenance paths --- src/cli/plugins-cli.ts | 90 ++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index ffa833639c4..c6a20d1a2f9 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -5,51 +5,14 @@ import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { enablePluginInConfig } from "../plugins/enable.js"; -import { - loadInstalledPluginIndexInstallRecords, - removePluginInstallRecordFromRecords, - withoutPluginInstallRecords, - withPluginInstallRecords, -} from "../plugins/installed-plugin-index-records.js"; -import { listMarketplacePlugins } from "../plugins/marketplace.js"; -import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js"; import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; -import { - buildAllPluginInspectReports, - buildPluginDiagnosticsReport, - buildPluginCompatibilityNotices, - buildPluginInspectReport, - buildPluginRegistrySnapshotReport, - formatPluginCompatibilityNotice, -} from "../plugins/status.js"; import type { PluginLogger } from "../plugins/types.js"; -import { - applyPluginUninstallDirectoryRemoval, - formatUninstallActionLabels, - formatUninstallSlotResetPreview, - planPluginUninstall, - resolveUninstallChannelConfigKeys, - UNINSTALL_ACTION_LABELS, -} from "../plugins/uninstall.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; -import { - applySlotSelectionForPlugin, - createPluginInstallLogger, - logSlotWarnings, -} from "./plugins-command-helpers.js"; -import { setPluginEnabledInConfig } from "./plugins-config.js"; -import { runPluginInstallCommand } from "./plugins-install-command.js"; -import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js"; import { formatPluginLine } from "./plugins-list-format.js"; -import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js"; -import { resolvePluginUninstallId } from "./plugins-uninstall-selection.js"; -import { runPluginUpdateCommand } from "./plugins-update-command.js"; -import { promptYesNo } from "./prompt.js"; export type PluginsListOptions = { json?: boolean; @@ -182,7 +145,8 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .option("--enabled", "Only show enabled plugins", false) .option("--verbose", "Show detailed entries", false) - .action((opts: PluginsListOptions) => { + .action(async (opts: PluginsListOptions) => { + const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); const cfg = loadConfig(); const report = buildPluginRegistrySnapshotReport({ config: cfg, @@ -290,6 +254,14 @@ export function registerPluginsCli(program: Command) { .option("--all", "Inspect all plugins") .option("--json", "Print JSON") .action(async (id: string | undefined, opts: PluginInspectOptions) => { + const { + buildAllPluginInspectReports, + buildPluginDiagnosticsReport, + buildPluginInspectReport, + formatPluginCompatibilityNotice, + } = await import("../plugins/status.js"); + const { loadInstalledPluginIndexInstallRecords } = + await import("../plugins/installed-plugin-index-records.js"); const cfg = loadConfig(); const installRecords = await loadInstalledPluginIndexInstallRecords(); const report = buildPluginDiagnosticsReport({ @@ -523,6 +495,11 @@ export function registerPluginsCli(program: Command) { .description("Enable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { + const { enablePluginInConfig } = await import("../plugins/enable.js"); + const { applySlotSelectionForPlugin, logSlotWarnings } = + await import("./plugins-command-helpers.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); const snapshot = await readConfigFileSnapshot(); const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const enableResult = enablePluginInConfig(cfg, id); @@ -557,6 +534,9 @@ export function registerPluginsCli(program: Command) { .description("Disable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { + const { setPluginEnabledInConfig } = await import("./plugins-config.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); const snapshot = await readConfigFileSnapshot(); const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const next = setPluginEnabledInConfig(cfg, id, false); @@ -583,6 +563,27 @@ export function registerPluginsCli(program: Command) { .option("--force", "Skip confirmation prompt", false) .option("--dry-run", "Show what would be removed without making changes", false) .action(async (id: string, opts: PluginUninstallOptions) => { + const { + loadInstalledPluginIndexInstallRecords, + removePluginInstallRecordFromRecords, + withoutPluginInstallRecords, + withPluginInstallRecords, + } = await import("../plugins/installed-plugin-index-records.js"); + const { buildPluginDiagnosticsReport } = await import("../plugins/status.js"); + const { + formatUninstallActionLabels, + formatUninstallSlotResetPreview, + resolveUninstallChannelConfigKeys, + resolveUninstallDirectoryTarget, + UNINSTALL_ACTION_LABELS, + uninstallPlugin, + } = await import("../plugins/uninstall.js"); + const { commitPluginInstallRecordsWithConfig } = + await import("./plugins-install-record-commit.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); + const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js"); + const { promptYesNo } = await import("./prompt.js"); const snapshot = await readConfigFileSnapshot(); const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const installRecords = await loadInstalledPluginIndexInstallRecords(); @@ -744,6 +745,7 @@ export function registerPluginsCli(program: Command) { marketplace?: string; }, ) => { + const { runPluginInstallCommand } = await import("./plugins-install-command.js"); await runPluginInstallCommand({ raw, opts }); }, ); @@ -760,6 +762,7 @@ export function registerPluginsCli(program: Command) { false, ) .action(async (id: string | undefined, opts: PluginUpdateOptions) => { + const { runPluginUpdateCommand } = await import("./plugins-update-command.js"); await runPluginUpdateCommand({ id, opts }); }); @@ -769,6 +772,8 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .option("--refresh", "Rebuild the persisted registry from current plugin manifests", false) .action(async (opts: PluginRegistryOptions) => { + const { inspectPluginRegistry, refreshPluginRegistry } = + await import("../plugins/plugin-registry.js"); const cfg = loadConfig(); if (opts.refresh) { @@ -825,7 +830,12 @@ export function registerPluginsCli(program: Command) { plugins .command("doctor") .description("Report plugin load issues") - .action(() => { + .action(async () => { + const { + buildPluginCompatibilityNotices, + buildPluginDiagnosticsReport, + formatPluginCompatibilityNotice, + } = await import("../plugins/status.js"); const report = buildPluginDiagnosticsReport(); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((d) => d.level === "error"); @@ -880,6 +890,8 @@ export function registerPluginsCli(program: Command) { .argument("", "Local marketplace path/repo or git/GitHub source") .option("--json", "Print JSON") .action(async (source: string, opts: PluginMarketplaceListOptions) => { + const { listMarketplacePlugins } = await import("../plugins/marketplace.js"); + const { createPluginInstallLogger } = await import("./plugins-command-helpers.js"); const result = await listMarketplacePlugins({ marketplace: source, logger: createPluginInstallLogger(), From fc483ef5d0faf8b1fd30bc37b6c411fddedaf4a4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:27:17 -0700 Subject: [PATCH 05/13] test(cli): cover lazy plugin inspect mocks --- src/cli/plugins-cli-test-helpers.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index a6a45cf9b4d..4b96322d54b 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -56,6 +56,7 @@ export const loadPluginManifestRegistry: UnknownMock = vi.fn(); export const buildPluginSnapshotReport: UnknownMock = vi.fn(); export const buildPluginRegistrySnapshotReport: UnknownMock = vi.fn(); export const buildPluginInspectReport: UnknownMock = vi.fn(); +export const buildAllPluginInspectReports: UnknownMock = vi.fn(); export const buildPluginDiagnosticsReport: UnknownMock = vi.fn(); export const buildPluginCompatibilityNotices: UnknownMock = vi.fn(); export const inspectPluginRegistry: AsyncUnknownMock = vi.fn(); @@ -248,6 +249,16 @@ vi.mock("../plugins/status.js", () => ({ buildPluginInspectReport, ...args, )) as (typeof import("../plugins/status.js"))["buildPluginInspectReport"], + buildAllPluginInspectReports: (( + ...args: Parameters<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]>, + ReturnType<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]> + >( + buildAllPluginInspectReports, + ...args, + )) as (typeof import("../plugins/status.js"))["buildAllPluginInspectReports"], buildPluginDiagnosticsReport: (( ...args: Parameters<(typeof import("../plugins/status.js"))["buildPluginDiagnosticsReport"]> ) => From 3a9463edac9e7501a5edb889a6bb6c4087abe817 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:45:26 -0700 Subject: [PATCH 06/13] test(models): stabilize provider index list mocks --- .../list.list-command.forward-compat.test.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) 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 55c12fa3317..5d836302c6b 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -63,6 +63,7 @@ const mocks = vi.hoisted(() => { loadModelCatalog: vi.fn(), loadProviderCatalogModelsForList: vi.fn(), loadStaticManifestCatalogRowsForList: vi.fn(), + loadProviderIndexCatalogRowsForList: vi.fn(), hasProviderStaticCatalogForFilter: vi.fn(), resolveConfiguredEntries: vi.fn(), printModelTable: vi.fn(), @@ -91,6 +92,7 @@ function resetMocks() { mocks.loadModelCatalog.mockResolvedValue([]); mocks.loadProviderCatalogModelsForList.mockResolvedValue([]); mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([]); + mocks.loadProviderIndexCatalogRowsForList.mockReturnValue([]); mocks.hasProviderStaticCatalogForFilter.mockResolvedValue(false); mocks.resolveConfiguredEntries.mockReturnValue({ entries: [ @@ -154,6 +156,10 @@ function installModelsListCommandForwardCompatMocks() { loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList, })); + vi.doMock("./list.provider-index-catalog.js", () => ({ + loadProviderIndexCatalogRowsForList: mocks.loadProviderIndexCatalogRowsForList, + })); + vi.doMock("./list.registry-load.js", () => ({ loadListModelRegistry: async ( cfg: unknown, @@ -309,7 +315,6 @@ describe("modelsListCommand forward-compat", () => { }, ], }); - mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_MINI_MODEL }); const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); @@ -338,7 +343,6 @@ describe("modelsListCommand forward-compat", () => { }, ], }); - mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_PRO_MODEL }); const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); @@ -422,11 +426,6 @@ describe("modelsListCommand forward-compat", () => { it("does not require the all-model registry result for configured-mode listing", async () => { const previousExitCode = process.exitCode; process.exitCode = undefined; - mocks.loadModelRegistry.mockResolvedValueOnce({ - models: [], - availableKeys: new Set(), - registry: undefined, - }); const runtime = createRuntime(); let observedExitCode: number | undefined; @@ -439,6 +438,7 @@ describe("modelsListCommand forward-compat", () => { expect(runtime.error).not.toHaveBeenCalled(); expect(observedExitCode).toBeUndefined(); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); expect(mocks.printModelTable).toHaveBeenCalled(); }); }); @@ -515,12 +515,27 @@ 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); + mocks.loadProviderIndexCatalogRowsForList.mockReturnValueOnce([ + { + 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", "image"], + reasoning: false, + status: "available", + baseUrl: "https://api.moonshot.ai/v1", + contextWindow: 262_144, + }, + ]); const runtime = createRuntime(); await modelsListCommand({ all: true, provider: "moonshot", json: true }, runtime as never); expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.hasProviderStaticCatalogForFilter).not.toHaveBeenCalled(); expect(mocks.loadProviderCatalogModelsForList).not.toHaveBeenCalled(); expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([ expect.objectContaining({ @@ -587,6 +602,7 @@ describe("modelsListCommand forward-compat", () => { it("includes provider-owned supplemental catalog rows with provider filters", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [], availableKeys: new Set(["opencode-go/deepseek-v4-pro"]), @@ -681,6 +697,7 @@ describe("modelsListCommand forward-compat", () => { it("uses provider runtime metadata for discovered codex gpt-5.5 rows", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [ { @@ -798,6 +815,7 @@ describe("modelsListCommand forward-compat", () => { describe("provider filter canonicalization", () => { it("matches alias-valued discovered providers against canonical provider filters", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [ { From 23710167cd19ece313c924a14cda00d6972acad6 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:10 -0700 Subject: [PATCH 07/13] fix(cli): lazy load model commands --- src/cli/capability-cli.test.ts | 4 ++++ src/cli/capability-cli.ts | 2 +- src/cli/models-cli.test.ts | 6 ++++++ src/cli/models-cli.ts | 4 +++- src/cli/program/routed-command-definitions.ts | 21 ++++++++++++------- src/cli/program/routes.test.ts | 6 ++++++ 6 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 5e2e1e5c33b..24cfbb27ad3 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -170,6 +170,10 @@ vi.mock("../commands/models/list.js", () => ({ modelsStatusCommand: mocks.modelsStatusCommand as typeof import("../commands/models/list.js").modelsStatusCommand, })); +vi.mock("../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: + mocks.modelsStatusCommand as typeof import("../commands/models/list.status-command.js").modelsStatusCommand, +})); vi.mock("../gateway/call.js", () => ({ callGateway: mocks.callGateway as typeof import("../gateway/call.js").callGateway, diff --git a/src/cli/capability-cli.ts b/src/cli/capability-cli.ts index 8eb73b1d42c..a5ba86618b7 100644 --- a/src/cli/capability-cli.ts +++ b/src/cli/capability-cli.ts @@ -13,7 +13,6 @@ import { import { updateAuthProfileStoreWithLock } from "../agents/auth-profiles/store.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; -import { modelsStatusCommand } from "../commands/models/list.js"; import { loadConfig } from "../config/config.js"; import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -683,6 +682,7 @@ async function buildModelProviders() { async function runModelAuthStatus() { const captured: string[] = []; + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: true }, { diff --git a/src/cli/models-cli.test.ts b/src/cli/models-cli.test.ts index 30f3f2fc03f..6b46baa3f1a 100644 --- a/src/cli/models-cli.test.ts +++ b/src/cli/models-cli.test.ts @@ -49,6 +49,12 @@ vi.mock("../commands/models/list.js", () => ({ modelsListCommand: mocks.noopAsync, modelsStatusCommand: mocks.modelsStatusCommand, })); +vi.mock("../commands/models/list.list-command.js", () => ({ + modelsListCommand: mocks.noopAsync, +})); +vi.mock("../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: mocks.modelsStatusCommand, +})); vi.mock("../commands/models/auth.js", () => ({ modelsAuthAddCommand: mocks.modelsAuthAddCommand, modelsAuthLoginCommand: mocks.modelsAuthLoginCommand, diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index 0773a55bb54..a769aae2113 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -1,5 +1,4 @@ import type { Command } from "commander"; -import { modelsListCommand, modelsStatusCommand } from "../commands/models/list.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; @@ -35,6 +34,7 @@ export function registerModelsCli(program: Command) { .option("--plain", "Plain line output", false) .action(async (opts) => { await runModelsCommand(async () => { + const { modelsListCommand } = await import("../commands/models/list.list-command.js"); await modelsListCommand(opts, defaultRuntime); }); }); @@ -71,6 +71,7 @@ export function registerModelsCli(program: Command) { const agent = resolveOptionFromCommand(command, "agent") ?? (opts.agent as string | undefined); await runModelsCommand(async () => { + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: Boolean(opts.json), @@ -270,6 +271,7 @@ export function registerModelsCli(program: Command) { models.action(async (opts) => { await runModelsCommand(async () => { + const { modelsStatusCommand } = await import("../commands/models/list.status-command.js"); await modelsStatusCommand( { json: Boolean(opts?.statusJson), diff --git a/src/cli/program/routed-command-definitions.ts b/src/cli/program/routed-command-definitions.ts index 8557039ac19..09873d6cdd3 100644 --- a/src/cli/program/routed-command-definitions.ts +++ b/src/cli/program/routed-command-definitions.ts @@ -17,7 +17,8 @@ type RouteArgParser = (argv: string[]) => TArgs | null; type ParsedRouteArgs> = Exclude, null>; type ConfigCliModule = typeof import("../config-cli.js"); -type ModelsListModule = typeof import("../../commands/models/list.js"); +type ModelsListCommandModule = typeof import("../../commands/models/list.list-command.js"); +type ModelsStatusCommandModule = typeof import("../../commands/models/list.status-command.js"); export type RoutedCommandDefinition> = { parseArgs: TParse; @@ -36,16 +37,22 @@ function defineRoutedCommand>( } let configCliPromise: Promise | undefined; -let modelsListPromise: Promise | undefined; +let modelsListCommandPromise: Promise | undefined; +let modelsStatusCommandPromise: Promise | undefined; function loadConfigCli(): Promise { configCliPromise ??= import("../config-cli.js"); return configCliPromise; } -function loadModelsList(): Promise { - modelsListPromise ??= import("../../commands/models/list.js"); - return modelsListPromise; +function loadModelsListCommand(): Promise { + modelsListCommandPromise ??= import("../../commands/models/list.list-command.js"); + return modelsListCommandPromise; +} + +function loadModelsStatusCommand(): Promise { + modelsStatusCommandPromise ??= import("../../commands/models/list.status-command.js"); + return modelsStatusCommandPromise; } export const routedCommandDefinitions = { @@ -114,14 +121,14 @@ export const routedCommandDefinitions = { "models-list": defineRoutedCommand({ parseArgs: parseModelsListRouteArgs, runParsedArgs: async (args) => { - const { modelsListCommand } = await loadModelsList(); + const { modelsListCommand } = await loadModelsListCommand(); await modelsListCommand(args, defaultRuntime); }, }), "models-status": defineRoutedCommand({ parseArgs: parseModelsStatusRouteArgs, runParsedArgs: async (args) => { - const { modelsStatusCommand } = await loadModelsList(); + const { modelsStatusCommand } = await loadModelsStatusCommand(); await modelsStatusCommand(args, defaultRuntime); }, }), diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 631d564f865..468458762b3 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -23,6 +23,12 @@ vi.mock("../../commands/models/list.js", () => ({ modelsListCommand: modelsListCommandMock, modelsStatusCommand: modelsStatusCommandMock, })); +vi.mock("../../commands/models/list.list-command.js", () => ({ + modelsListCommand: modelsListCommandMock, +})); +vi.mock("../../commands/models/list.status-command.js", () => ({ + modelsStatusCommand: modelsStatusCommandMock, +})); vi.mock("../daemon-cli/status.js", () => ({ runDaemonStatus: runDaemonStatusMock, From 8740ca7dee334f66216755422ee9c10b95637cde Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:28 -0700 Subject: [PATCH 08/13] fix(models): avoid registry for configured list --- .../list.list-command.forward-compat.test.ts | 56 ++--- src/commands/models/list.list-command.ts | 90 ++++++-- src/commands/models/list.registry-load.ts | 3 +- src/commands/models/list.registry.ts | 17 +- src/commands/models/list.row-sources.ts | 27 +-- src/commands/models/list.rows.test.ts | 8 +- src/commands/models/list.rows.ts | 210 ++++++++++++++---- 7 files changed, 292 insertions(+), 119 deletions(-) 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 0e539e0689c..55c12fa3317 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -147,6 +147,7 @@ function installModelsListCommandForwardCompatMocks() { vi.doMock("./list.provider-catalog.js", () => ({ hasProviderStaticCatalogForFilter: mocks.hasProviderStaticCatalogForFilter, + loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, })); vi.doMock("./list.manifest-catalog.js", () => ({ @@ -190,14 +191,27 @@ function installModelsListCommandForwardCompatMocks() { }, })); - vi.doMock("./list.runtime.js", () => ({ - ensureOpenClawModelsJson: mocks.ensureOpenClawModelsJson, - ensureAuthProfileStore: mocks.ensureAuthProfileStore, + vi.doMock("../../agents/auth-profiles/store.js", () => ({ + loadAuthProfileStoreWithoutExternalProfiles: mocks.ensureAuthProfileStore, + })); + + vi.doMock("../../agents/agent-paths.js", () => ({ resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, + })); + + vi.doMock("../../agents/auth-profiles/profile-list.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, + })); + + vi.doMock("../../agents/model-catalog.js", () => ({ loadModelCatalog: mocks.loadModelCatalog, - loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, + })); + + vi.doMock("../../agents/pi-embedded-runner/model.js", () => ({ resolveModelWithRegistry: mocks.resolveModelWithRegistry, + })); + + vi.doMock("../../agents/model-auth.js", () => ({ resolveEnvApiKey: vi.fn().mockReturnValue(undefined), resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), @@ -232,7 +246,7 @@ async function buildAllOpenAiCodexRows(opts: { supplementCatalog?: boolean } = { ), filter: { provider: "openai-codex" }, }; - const seenKeys = listRowsModule.appendDiscoveredRows({ + const seenKeys = await listRowsModule.appendDiscoveredRows({ rows: rows as never, models: loaded.models as never, modelRegistry: loaded.registry as never, @@ -256,17 +270,14 @@ beforeEach(() => { describe("modelsListCommand forward-compat", () => { describe("configured rows", () => { - it("passes provider filters into registry loading before row assembly", async () => { + it("keeps configured provider filters on the registry-free row path", async () => { const runtime = createRuntime(); await modelsListCommand({ json: true, provider: "moonshot" }, runtime as never); - expect(mocks.loadModelRegistry).toHaveBeenCalledWith( - mocks.resolvedConfig, - expect.objectContaining({ - providerFilter: "moonshot", - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.printModelTable).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("No models found."); }); it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => { @@ -345,17 +356,12 @@ describe("modelsListCommand forward-compat", () => { expect(codexPro?.tags).not.toContain("missing"); }); - it("loads model registry without source config persistence input", async () => { + it("does not load the model registry for configured-mode listing", async () => { const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); - expect(mocks.loadModelRegistry).toHaveBeenCalledWith( - mocks.resolvedConfig, - expect.not.objectContaining({ - sourceConfig: expect.anything(), - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); }); it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => { @@ -568,19 +574,15 @@ describe("modelsListCommand forward-compat", () => { ]); }); - it("keeps the registry path for provider filters without static catalog coverage", async () => { + it("does not fall back to the registry for provider filters without 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", - }), - ); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(runtime.log).toHaveBeenCalledWith("No models found."); }); it("includes provider-owned supplemental catalog rows with provider filters", async () => { @@ -748,7 +750,7 @@ describe("modelsListCommand forward-compat", () => { it("suppresses direct openai gpt-5.3-codex-spark rows in --all output", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); const rows: unknown[] = []; - listRowsModule.appendDiscoveredRows({ + await listRowsModule.appendDiscoveredRows({ rows: rows as never, models: [ { diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index be3f411c0d5..52cba5f94e5 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -5,13 +5,6 @@ import type { RuntimeEnv } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveConfiguredEntries } from "./list.configured.js"; import { formatErrorWithStack } from "./list.errors.js"; -import { hasProviderStaticCatalogForFilter } from "./list.provider-catalog.js"; -import { loadConfiguredListModelRegistry, loadListModelRegistry } from "./list.registry-load.js"; -import { - appendAllModelRowSources, - appendConfiguredModelRowSources, - modelRowSourcesRequireRegistry, -} from "./list.row-sources.js"; import { printModelTable } from "./list.table.js"; import type { ModelRow } from "./list.types.js"; import { loadModelsConfigWithSource } from "./load-config.js"; @@ -19,6 +12,45 @@ import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; const DISPLAY_MODEL_PARSE_OPTIONS = { allowPluginNormalization: false } as const; +type RegistryLoadModule = typeof import("./list.registry-load.js"); +type RowSourcesModule = typeof import("./list.row-sources.js"); +type ProviderCatalogModule = typeof import("./list.provider-catalog.js"); + +let registryLoadModulePromise: Promise | undefined; +let rowSourcesModulePromise: Promise | undefined; +let providerCatalogModulePromise: Promise | undefined; + +function loadRegistryLoadModule(): Promise { + registryLoadModulePromise ??= import("./list.registry-load.js"); + return registryLoadModulePromise; +} + +function loadRowSourcesModule(): Promise { + rowSourcesModulePromise ??= import("./list.row-sources.js"); + return rowSourcesModulePromise; +} + +function loadProviderCatalogModule(): Promise { + providerCatalogModulePromise ??= import("./list.provider-catalog.js"); + return providerCatalogModulePromise; +} + +function modelRowSourcesRequireRegistry(params: { + all?: boolean; + providerFilter?: string; + useManifestCatalogFastPath: boolean; + useProviderCatalogFastPath: boolean; + useProviderIndexCatalogFastPath: boolean; +}): boolean { + if (!params.all) { + return false; + } + if (params.providerFilter) { + return false; + } + return true; +} + export async function modelsListCommand( opts: { all?: boolean; @@ -48,12 +80,16 @@ export async function modelsListCommand( if (providerFilter === null) { return; } - const { ensureAuthProfileStore, resolveOpenClawAgentDir } = await import("./list.runtime.js"); + const [{ loadAuthProfileStoreWithoutExternalProfiles }, { resolveOpenClawAgentDir }] = + await Promise.all([ + import("../../agents/auth-profiles/store.js"), + import("../../agents/agent-paths.js"), + ]); const { resolvedConfig: cfg } = await loadModelsConfigWithSource({ commandName: "models list", runtime, }); - const authStore = ensureAuthProfileStore(); + const authStore = loadAuthProfileStoreWithoutExternalProfiles(); const agentDir = resolveOpenClawAgentDir(); let modelRegistry: ModelRegistry | undefined; @@ -69,16 +105,24 @@ export async function modelsListCommand( manifestCatalogRows = loadStaticManifestCatalogRowsForList({ cfg, providerFilter }); } const useManifestCatalogFastPath = manifestCatalogRows.length > 0; - const useProviderCatalogFastPath = - !useManifestCatalogFastPath && opts.all && providerFilter - ? await hasProviderStaticCatalogForFilter({ cfg, providerFilter }) - : false; - if (!useManifestCatalogFastPath && !useProviderCatalogFastPath && opts.all && providerFilter) { + if (!useManifestCatalogFastPath && opts.all && providerFilter) { const { loadProviderIndexCatalogRowsForList } = await import("./list.provider-index-catalog.js"); providerIndexCatalogRows = loadProviderIndexCatalogRowsForList({ cfg, providerFilter }); } const useProviderIndexCatalogFastPath = providerIndexCatalogRows.length > 0; + const useProviderCatalogFastPath = await (async () => { + if ( + useManifestCatalogFastPath || + useProviderIndexCatalogFastPath || + !opts.all || + !providerFilter + ) { + return false; + } + const { hasProviderStaticCatalogForFilter } = await loadProviderCatalogModule(); + return hasProviderStaticCatalogForFilter({ cfg, providerFilter }); + })(); const shouldLoadRegistry = modelRowSourcesRequireRegistry({ all: opts.all, providerFilter, @@ -87,6 +131,7 @@ export async function modelsListCommand( useProviderIndexCatalogFastPath, }); const loadRegistryState = async () => { + const { loadListModelRegistry } = await loadRegistryLoadModule(); const loaded = await loadListModelRegistry(cfg, { providerFilter }); modelRegistry = loaded.registry; discoveredKeys = loaded.discoveredKeys; @@ -96,7 +141,8 @@ export async function modelsListCommand( try { if (shouldLoadRegistry) { await loadRegistryState(); - } else if (!opts.all) { + } else if (!opts.all && opts.local) { + const { loadConfiguredListModelRegistry } = await loadRegistryLoadModule(); const loaded = loadConfiguredListModelRegistry(cfg, entries, { providerFilter }); modelRegistry = loaded.registry; discoveredKeys = loaded.discoveredKeys; @@ -123,6 +169,7 @@ export async function modelsListCommand( const rows: ModelRow[] = []; if (opts.all) { + const { appendAllModelRowSources } = await loadRowSourcesModule(); let rowContext = buildRowContext( useManifestCatalogFastPath || useProviderCatalogFastPath || useProviderIndexCatalogFastPath, ); @@ -158,17 +205,12 @@ export async function modelsListCommand( }); } } else { - const registry = modelRegistry; - if (!registry) { - runtime.error("Model registry unavailable."); - process.exitCode = 1; - return; - } - appendConfiguredModelRowSources({ + const { appendConfiguredModelRowSources } = await loadRowSourcesModule(); + await appendConfiguredModelRowSources({ rows, entries, - modelRegistry: registry, - context: buildRowContext(false), + modelRegistry, + context: buildRowContext(!modelRegistry), }); } diff --git a/src/commands/models/list.registry-load.ts b/src/commands/models/list.registry-load.ts index e4114be6a04..d7e683c3c38 100644 --- a/src/commands/models/list.registry-load.ts +++ b/src/commands/models/list.registry-load.ts @@ -1,9 +1,10 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; +import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { loadModelRegistry } from "./list.registry.js"; -import { discoverAuthStorage, discoverModels, resolveOpenClawAgentDir } from "./list.runtime.js"; import type { ConfiguredEntry } from "./list.types.js"; import { modelKey } from "./shared.js"; diff --git a/src/commands/models/list.registry.ts b/src/commands/models/list.registry.ts index 29b4e67ddd5..972b8ab43ac 100644 --- a/src/commands/models/list.registry.ts +++ b/src/commands/models/list.registry.ts @@ -1,7 +1,15 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import { resolveOpenClawAgentDir } from "../../agents/agent-paths.js"; +import { listProfilesForProvider } from "../../agents/auth-profiles/profile-list.js"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; +import { + hasUsableCustomProviderApiKey, + resolveAwsSdkEnvVarName, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; +import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../../plugins/synthetic-auth.runtime.js"; import { @@ -10,15 +18,6 @@ import { shouldFallbackToAuthHeuristics, } from "./list.errors.js"; import { toModelRow as toModelRowBase } from "./list.model-row.js"; -import { - discoverAuthStorage, - discoverModels, - hasUsableCustomProviderApiKey, - listProfilesForProvider, - resolveAwsSdkEnvVarName, - resolveEnvApiKey, - resolveOpenClawAgentDir, -} from "./list.runtime.js"; import type { ModelRow } from "./list.types.js"; import { modelKey } from "./shared.js"; diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index bb868a5706d..ec5dc6363f0 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -37,12 +37,7 @@ export function modelRowSourcesRequireRegistry(params: { if (!params.all) { return false; } - if ( - params.providerFilter && - (params.useManifestCatalogFastPath || - params.useProviderCatalogFastPath || - params.useProviderIndexCatalogFastPath) - ) { + if (params.providerFilter) { return false; } return true; @@ -58,14 +53,14 @@ export async function appendAllModelRowSources( params.useProviderIndexCatalogFastPath) ) { let seenKeys = new Set(); - appendConfiguredProviderRows({ + await appendConfiguredProviderRows({ rows: params.rows, context: params.context, seenKeys, }); let catalogRows = 0; if (params.useManifestCatalogFastPath) { - catalogRows = appendManifestCatalogRows({ + catalogRows = await appendManifestCatalogRows({ rows: params.rows, context: params.context, seenKeys, @@ -81,7 +76,7 @@ export async function appendAllModelRowSources( }); } if (catalogRows === 0 && params.useProviderIndexCatalogFastPath) { - catalogRows = appendModelCatalogRows({ + catalogRows = await appendModelCatalogRows({ rows: params.rows, context: params.context, seenKeys, @@ -92,7 +87,7 @@ export async function appendAllModelRowSources( if (!params.modelRegistry) { return { requiresRegistryFallback: true }; } - appendDiscoveredRows({ + await appendDiscoveredRows({ rows: params.rows, models: params.modelRegistry.getAll(), modelRegistry: params.modelRegistry, @@ -102,14 +97,14 @@ export async function appendAllModelRowSources( return { requiresRegistryFallback: false }; } - const seenKeys = appendDiscoveredRows({ + const seenKeys = await appendDiscoveredRows({ rows: params.rows, models: params.modelRegistry?.getAll() ?? [], modelRegistry: params.modelRegistry, context: params.context, }); - appendConfiguredProviderRows({ + await appendConfiguredProviderRows({ rows: params.rows, context: params.context, seenKeys, @@ -133,11 +128,11 @@ export async function appendAllModelRowSources( return { requiresRegistryFallback: false }; } -export function appendConfiguredModelRowSources(params: { +export async function appendConfiguredModelRowSources(params: { rows: ModelRow[]; entries: ConfiguredEntry[]; - modelRegistry: ModelRegistry; + modelRegistry?: ModelRegistry; context: RowBuilderContext; -}): void { - appendConfiguredRows(params); +}): Promise { + await appendConfiguredRows(params); } diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index e2f77819e47..663596dc9f6 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -23,9 +23,15 @@ vi.mock("../../agents/model-suppression.js", () => ({ shouldSuppressBuiltInModel: mocks.shouldSuppressBuiltInModel, })); -vi.mock("./list.runtime.js", () => ({ +vi.mock("./list.provider-catalog.js", () => ({ loadProviderCatalogModelsForList: mocks.loadProviderCatalogModelsForList, +})); + +vi.mock("../../agents/auth-profiles/profile-list.js", () => ({ listProfilesForProvider: mocks.listProfilesForProvider, +})); + +vi.mock("../../agents/model-auth.js", () => ({ resolveAwsSdkEnvVarName: vi.fn().mockReturnValue(undefined), resolveEnvApiKey: vi.fn().mockReturnValue(null), hasUsableCustomProviderApiKey: vi.fn().mockReturnValue(false), diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 18d6a0097a5..df6b9bd5dc5 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -2,22 +2,27 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import type { AuthProfileStore } from "../../agents/auth-profiles/types.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; +import { + hasUsableCustomProviderApiKey, + resolveAwsSdkEnvVarName, + resolveEnvApiKey, +} from "../../agents/model-auth.js"; import { shouldSuppressBuiltInModel } from "../../agents/model-suppression.js"; import { normalizeProviderId } from "../../agents/provider-id.js"; import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.models.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; import type { ListRowModel } from "./list.model-row.js"; -import { toModelRow } from "./list.registry.js"; -import { - loadModelCatalog, - loadProviderCatalogModelsForList, - resolveModelWithRegistry, -} from "./list.runtime.js"; +import { toModelRow } from "./list.model-row.js"; import type { ConfiguredEntry, ModelRow } from "./list.types.js"; import { isLocalBaseUrl, modelKey } from "./shared.js"; type ConfiguredByKey = Map; +type ModelCatalogModule = typeof import("../../agents/model-catalog.js"); +type ModelResolverModule = typeof import("../../agents/pi-embedded-runner/model.js"); +type ProfileListModule = typeof import("../../agents/auth-profiles/profile-list.js"); +type ProviderCatalogModule = typeof import("./list.provider-catalog.js"); +type SyntheticAuthModule = typeof import("../../plugins/synthetic-auth.runtime.js"); type RowFilter = { provider?: string; @@ -35,6 +40,37 @@ export type RowBuilderContext = { skipRuntimeModelSuppression?: boolean; }; +let modelCatalogModulePromise: Promise | undefined; +let modelResolverModulePromise: Promise | undefined; +let profileListModulePromise: Promise | undefined; +let providerCatalogModulePromise: Promise | undefined; +let syntheticAuthModulePromise: Promise | undefined; + +function loadModelCatalogModule(): Promise { + modelCatalogModulePromise ??= import("../../agents/model-catalog.js"); + return modelCatalogModulePromise; +} + +function loadModelResolverModule(): Promise { + modelResolverModulePromise ??= import("../../agents/pi-embedded-runner/model.js"); + return modelResolverModulePromise; +} + +function loadProfileListModule(): Promise { + profileListModulePromise ??= import("../../agents/auth-profiles/profile-list.js"); + return profileListModulePromise; +} + +function loadProviderCatalogModule(): Promise { + providerCatalogModulePromise ??= import("./list.provider-catalog.js"); + return providerCatalogModulePromise; +} + +function loadSyntheticAuthModule(): Promise { + syntheticAuthModulePromise ??= import("../../plugins/synthetic-auth.runtime.js"); + return syntheticAuthModulePromise; +} + function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) { if (filter.provider && normalizeProviderId(model.provider) !== filter.provider) { return false; @@ -45,13 +81,44 @@ function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl? return true; } -function buildRow(params: { +async function hasAuthForProvider(params: { + provider: string; + cfg: OpenClawConfig; + authStore: AuthProfileStore; +}): Promise { + const { listProfilesForProvider } = await loadProfileListModule(); + if (listProfilesForProvider(params.authStore, params.provider).length > 0) { + return true; + } + if (params.provider === "amazon-bedrock" && resolveAwsSdkEnvVarName()) { + return true; + } + if (resolveEnvApiKey(params.provider)) { + return true; + } + if (hasUsableCustomProviderApiKey(params.cfg, params.provider)) { + return true; + } + const { resolveRuntimeSyntheticAuthProviderRefs } = await loadSyntheticAuthModule(); + return resolveRuntimeSyntheticAuthProviderRefs().includes(params.provider); +} + +async function buildRow(params: { model: ListRowModel; key: string; context: RowBuilderContext; allowProviderAvailabilityFallback?: boolean; -}): ModelRow { +}): Promise { const configured = params.context.configuredByKey.get(params.key); + const shouldResolveProviderAuth = + params.context.availableKeys === undefined || params.allowProviderAvailabilityFallback === true; + const hasProviderAuth = shouldResolveProviderAuth + ? await hasAuthForProvider({ + provider: params.model.provider, + cfg: params.context.cfg, + authStore: params.context.authStore, + }) + : false; return toModelRow({ model: params.model, key: params.key, @@ -61,6 +128,7 @@ function buildRow(params: { cfg: params.context.cfg, authStore: params.context.authStore, allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback ?? false, + hasAuthForProvider: shouldResolveProviderAuth ? () => hasProviderAuth : undefined, }); } @@ -79,14 +147,14 @@ function shouldSuppressListModel(params: { }); } -function appendVisibleRow(params: { +async function appendVisibleRow(params: { rows: ModelRow[]; model: ListRowModel; key: string; context: RowBuilderContext; seenKeys?: Set; allowProviderAvailabilityFallback?: boolean; -}): boolean { +}): Promise { if (params.seenKeys?.has(params.key)) { return false; } @@ -97,7 +165,7 @@ function appendVisibleRow(params: { return false; } params.rows.push( - buildRow({ + await buildRow({ model: params.model, key: params.key, context: params.context, @@ -153,13 +221,49 @@ function shouldListConfiguredProviderModel(params: { return params.providerConfig.api !== undefined || params.model.api !== undefined; } -export function appendDiscoveredRows(params: { +function findConfiguredProviderModel(params: { + cfg: OpenClawConfig; + provider: string; + modelId: string; +}): ListRowModel | undefined { + const providerConfig = params.cfg.models?.providers?.[params.provider]; + const configuredModel = providerConfig?.models?.find((model) => model.id === params.modelId); + if (!providerConfig || !configuredModel) { + return undefined; + } + return toConfiguredProviderListModel({ + provider: params.provider, + providerConfig, + model: configuredModel, + }); +} + +function toFallbackConfiguredListModel(entry: ConfiguredEntry, cfg: OpenClawConfig): ListRowModel { + return ( + findConfiguredProviderModel({ + cfg, + provider: entry.ref.provider, + modelId: entry.ref.model, + }) ?? { + provider: entry.ref.provider, + id: entry.ref.model, + name: entry.ref.model, + input: ["text"], + contextWindow: DEFAULT_CONTEXT_TOKENS, + } + ); +} + +export async function appendDiscoveredRows(params: { rows: ModelRow[]; models: Model[]; modelRegistry?: ModelRegistry; context: RowBuilderContext; -}): Set { +}): Promise> { const seenKeys = new Set(); + const modelResolver = params.modelRegistry + ? (await loadModelResolverModule()).resolveModelWithRegistry + : undefined; const sorted = [...params.models].toSorted((a, b) => { const providerCompare = a.provider.localeCompare(b.provider); if (providerCompare !== 0) { @@ -170,20 +274,21 @@ export function appendDiscoveredRows(params: { for (const model of sorted) { const key = modelKey(model.provider, model.id); - const resolvedModel = params.modelRegistry - ? resolveModelWithRegistry({ - provider: model.provider, - modelId: model.id, - modelRegistry: params.modelRegistry, - cfg: params.context.cfg, - agentDir: params.context.agentDir, - }) - : undefined; + const resolvedModel = + params.modelRegistry && modelResolver + ? modelResolver({ + provider: model.provider, + modelId: model.id, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + agentDir: params.context.agentDir, + }) + : undefined; const rowModel = resolvedModel && modelKey(resolvedModel.provider, resolvedModel.id) === key ? resolvedModel : model; - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model: rowModel, key, @@ -195,11 +300,11 @@ export function appendDiscoveredRows(params: { return seenKeys; } -export function appendConfiguredProviderRows(params: { +export async function appendConfiguredProviderRows(params: { rows: ModelRow[]; context: RowBuilderContext; seenKeys: Set; -}): void { +}): Promise { for (const [provider, providerConfig] of Object.entries( params.context.cfg.models?.providers ?? {}, )) { @@ -213,7 +318,7 @@ export function appendConfiguredProviderRows(params: { providerConfig, model: configuredModel, }); - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -225,17 +330,17 @@ export function appendConfiguredProviderRows(params: { } } -export function appendModelCatalogRows(params: { +export async function appendModelCatalogRows(params: { rows: ModelRow[]; context: RowBuilderContext; seenKeys: Set; catalogRows: readonly NormalizedModelCatalogRow[]; -}): number { +}): Promise { let appended = 0; for (const catalogRow of params.catalogRows) { const key = modelKey(catalogRow.provider, catalogRow.id); if ( - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model: toManifestCatalogListModel(catalogRow), key, @@ -255,7 +360,7 @@ export function appendManifestCatalogRows(params: { context: RowBuilderContext; seenKeys: Set; manifestRows: readonly NormalizedModelCatalogRow[]; -}): number { +}): Promise { return appendModelCatalogRows({ ...params, catalogRows: params.manifestRows, @@ -268,6 +373,10 @@ export async function appendCatalogSupplementRows(params: { context: RowBuilderContext; seenKeys: Set; }): Promise { + const [{ loadModelCatalog }, { resolveModelWithRegistry }] = await Promise.all([ + loadModelCatalogModule(), + loadModelResolverModule(), + ]); const catalog = await loadModelCatalog({ config: params.context.cfg, readOnly: true }); for (const entry of catalog) { if ( @@ -286,7 +395,7 @@ export async function appendCatalogSupplementRows(params: { if (!model) { continue; } - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -314,6 +423,7 @@ export async function appendProviderCatalogRows(params: { staticOnly?: boolean; }): Promise { let appended = 0; + const { loadProviderCatalogModelsForList } = await loadProviderCatalogModule(); for (const model of await loadProviderCatalogModelsForList({ cfg: params.context.cfg, agentDir: params.context.agentDir, @@ -322,7 +432,7 @@ export async function appendProviderCatalogRows(params: { })) { const key = modelKey(model.provider, model.id); if ( - appendVisibleRow({ + await appendVisibleRow({ rows: params.rows, model, key, @@ -337,12 +447,15 @@ export async function appendProviderCatalogRows(params: { return appended; } -export function appendConfiguredRows(params: { +export async function appendConfiguredRows(params: { rows: ModelRow[]; entries: ConfiguredEntry[]; - modelRegistry: ModelRegistry; + modelRegistry?: ModelRegistry; context: RowBuilderContext; -}) { +}): Promise { + const resolveModelWithRegistry = params.modelRegistry + ? (await loadModelResolverModule()).resolveModelWithRegistry + : undefined; for (const entry of params.entries) { if ( params.context.filter.provider && @@ -350,12 +463,15 @@ export function appendConfiguredRows(params: { ) { continue; } - const model = resolveModelWithRegistry({ - provider: entry.ref.provider, - modelId: entry.ref.model, - modelRegistry: params.modelRegistry, - cfg: params.context.cfg, - }); + const model = + params.modelRegistry && resolveModelWithRegistry + ? resolveModelWithRegistry({ + provider: entry.ref.provider, + modelId: entry.ref.model, + modelRegistry: params.modelRegistry, + cfg: params.context.cfg, + }) + : toFallbackConfiguredListModel(entry, params.context.cfg); if (params.context.filter.local && model && !isLocalBaseUrl(model.baseUrl ?? "")) { continue; } @@ -365,6 +481,17 @@ export function appendConfiguredRows(params: { if (model && shouldSuppressListModel({ model, context: params.context })) { continue; } + const shouldResolveProviderAuth = + model && + (params.context.availableKeys === undefined || + !params.context.discoveredKeys.has(modelKey(model.provider, model.id))); + const hasProviderAuth = shouldResolveProviderAuth + ? await hasAuthForProvider({ + provider: model.provider, + cfg: params.context.cfg, + authStore: params.context.authStore, + }) + : false; params.rows.push( toModelRow({ model, @@ -377,6 +504,7 @@ export function appendConfiguredRows(params: { allowProviderAvailabilityFallback: model ? !params.context.discoveredKeys.has(modelKey(model.provider, model.id)) : false, + hasAuthForProvider: shouldResolveProviderAuth ? () => hasProviderAuth : undefined, }), ); } From aec1bfa0bbc82b1e123225f4026b57d913dea4cf Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:24:48 -0700 Subject: [PATCH 09/13] fix(models): keep cold catalog lookup registry indexed --- .../models/list.provider-catalog.test.ts | 15 ++-- src/commands/models/list.provider-catalog.ts | 9 ++- src/plugins/synthetic-auth.runtime.test.ts | 72 +++++++++++-------- src/plugins/synthetic-auth.runtime.ts | 10 +-- 4 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/commands/models/list.provider-catalog.test.ts b/src/commands/models/list.provider-catalog.test.ts index e599126a945..9752770dfa0 100644 --- a/src/commands/models/list.provider-catalog.test.ts +++ b/src/commands/models/list.provider-catalog.test.ts @@ -6,7 +6,7 @@ import { } from "./list.provider-catalog.js"; const providerDiscoveryMocks = vi.hoisted(() => ({ - loadPluginRegistrySnapshot: vi.fn(), + loadPluginRegistrySnapshotWithMetadata: vi.fn(), resolvePluginContributionOwners: vi.fn(), resolveProviderOwners: vi.fn(), resolveBundledProviderCompatPluginIds: vi.fn(), @@ -17,7 +17,8 @@ const providerDiscoveryMocks = vi.hoisted(() => ({ vi.mock("../../plugins/plugin-registry.js", () => ({ loadPluginManifestRegistryForPluginRegistry: () => ({ diagnostics: [], plugins: [] }), - loadPluginRegistrySnapshot: providerDiscoveryMocks.loadPluginRegistrySnapshot, + loadPluginRegistrySnapshotWithMetadata: + providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata, resolvePluginContributionOwners: providerDiscoveryMocks.resolvePluginContributionOwners, resolveProviderOwners: providerDiscoveryMocks.resolveProviderOwners, })); @@ -115,8 +116,11 @@ const defaultProviders = [chutesProvider, moonshotProvider, openaiProvider]; describe("loadProviderCatalogModelsForList", () => { beforeEach(() => { vi.clearAllMocks(); - providerDiscoveryMocks.loadPluginRegistrySnapshot.mockReturnValue({ - plugins: [], + providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: { + plugins: [], + }, diagnostics: [], }); providerDiscoveryMocks.resolveProviderOwners.mockImplementation( @@ -197,9 +201,10 @@ describe("loadProviderCatalogModelsForList", () => { }), ).resolves.toEqual(["moonshot"]); - expect(providerDiscoveryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({ + expect(providerDiscoveryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ config: baseParams.cfg, env: baseParams.env, + cache: true, }); expect(providerDiscoveryMocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); }); diff --git a/src/commands/models/list.provider-catalog.ts b/src/commands/models/list.provider-catalog.ts index 26b33b272f8..14491a15beb 100644 --- a/src/commands/models/list.provider-catalog.ts +++ b/src/commands/models/list.provider-catalog.ts @@ -5,7 +5,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { - loadPluginRegistrySnapshot, + loadPluginRegistrySnapshotWithMetadata, resolvePluginContributionOwners, resolveProviderOwners, type PluginRegistrySnapshot, @@ -70,10 +70,15 @@ function resolveInstalledIndexPluginIdsForProviderFilter(params: { env?: NodeJS.ProcessEnv; providerFilter: string; }): string[] | undefined { - const index = loadPluginRegistrySnapshot({ + const snapshot = loadPluginRegistrySnapshotWithMetadata({ config: params.cfg, env: params.env, + cache: true, }); + if (snapshot.source !== "persisted" && snapshot.source !== "provided") { + return []; + } + const index = snapshot.snapshot; const pluginIds = [ ...collectMatchingContributionOwners(index, "providers", params.providerFilter, params.cfg), ...collectMatchingContributionOwners(index, "cliBackends", params.providerFilter, params.cfg), diff --git a/src/plugins/synthetic-auth.runtime.test.ts b/src/plugins/synthetic-auth.runtime.test.ts index 0c3ecd81e61..1d51d37b8a3 100644 --- a/src/plugins/synthetic-auth.runtime.test.ts +++ b/src/plugins/synthetic-auth.runtime.test.ts @@ -2,26 +2,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const getPluginRegistryState = vi.hoisted(() => vi.fn()); const pluginRegistryMocks = vi.hoisted(() => ({ - loadPluginManifestRegistryForInstalledIndex: vi.fn(), - loadPluginRegistrySnapshot: vi.fn((_params?: unknown) => ({ plugins: [] })), + loadPluginRegistrySnapshotWithMetadata: vi.fn((_params?: unknown) => ({ + source: "persisted", + snapshot: { plugins: [] }, + diagnostics: [], + })), })); vi.mock("./runtime-state.js", () => ({ getPluginRegistryState, })); -vi.mock("./manifest-registry-installed.js", () => ({ - loadPluginManifestRegistryForInstalledIndex: - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex, -})); - vi.mock("./plugin-registry.js", () => ({ - loadPluginRegistrySnapshot: pluginRegistryMocks.loadPluginRegistrySnapshot, - loadPluginManifestRegistryForPluginRegistry: () => - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex({ - index: pluginRegistryMocks.loadPluginRegistrySnapshot({ cache: true }), - includeDisabled: true, - }), + loadPluginRegistrySnapshotWithMetadata: + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata, })); import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtime.js"; @@ -29,19 +23,24 @@ import { resolveRuntimeSyntheticAuthProviderRefs } from "./synthetic-auth.runtim describe("synthetic auth runtime refs", () => { beforeEach(() => { getPluginRegistryState.mockReset(); - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex - .mockReset() - .mockReturnValue({ plugins: [] }); - pluginRegistryMocks.loadPluginRegistrySnapshot.mockReset().mockReturnValue({ plugins: [] }); + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReset().mockReturnValue({ + source: "persisted", + snapshot: { plugins: [] }, + diagnostics: [], + }); }); - it("uses manifest-owned synthetic auth refs before the runtime registry exists", () => { - pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({ - plugins: [ - { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, - { syntheticAuthRefs: ["remote-provider"] }, - { syntheticAuthRefs: [] }, - ], + it("uses persisted registry synthetic auth refs before the runtime registry exists", () => { + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "persisted", + snapshot: { + plugins: [ + { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, + { syntheticAuthRefs: ["remote-provider"] }, + { syntheticAuthRefs: [] }, + ], + }, + diagnostics: [], }); expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([ @@ -49,13 +48,27 @@ describe("synthetic auth runtime refs", () => { "local-cli", "remote-provider", ]); - expect(pluginRegistryMocks.loadPluginRegistrySnapshot).toHaveBeenCalledWith({ cache: true }); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).toHaveBeenCalledWith({ - index: expect.anything(), - includeDisabled: true, + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ + cache: true, }); }); + it("does not derive the registry just to resolve synthetic auth refs", () => { + pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata.mockReturnValue({ + source: "derived", + snapshot: { + plugins: [ + { syntheticAuthRefs: [" local-provider ", "local-provider", "local-cli"] }, + { syntheticAuthRefs: ["remote-provider"] }, + { syntheticAuthRefs: [] }, + ], + }, + diagnostics: [], + }); + + expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual([]); + }); + it("prefers the active runtime registry when plugins are already loaded", () => { getPluginRegistryState.mockReturnValue({ activeRegistry: { @@ -84,7 +97,6 @@ describe("synthetic auth runtime refs", () => { }); expect(resolveRuntimeSyntheticAuthProviderRefs()).toEqual(["runtime-provider", "runtime-cli"]); - expect(pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex).not.toHaveBeenCalled(); - expect(pluginRegistryMocks.loadPluginRegistrySnapshot).not.toHaveBeenCalled(); + expect(pluginRegistryMocks.loadPluginRegistrySnapshotWithMetadata).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/synthetic-auth.runtime.ts b/src/plugins/synthetic-auth.runtime.ts index efed10f1de3..b8d6b4b1c46 100644 --- a/src/plugins/synthetic-auth.runtime.ts +++ b/src/plugins/synthetic-auth.runtime.ts @@ -1,5 +1,5 @@ import { normalizeProviderId } from "../agents/provider-id.js"; -import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry.js"; import { getPluginRegistryState } from "./runtime-state.js"; function uniqueProviderRefs(values: readonly string[]): string[] { @@ -18,10 +18,12 @@ function uniqueProviderRefs(values: readonly string[]): string[] { } function resolveManifestSyntheticAuthProviderRefs(): string[] { + const result = loadPluginRegistrySnapshotWithMetadata({ cache: true }); + if (result.source !== "persisted" && result.source !== "provided") { + return []; + } return uniqueProviderRefs( - loadPluginManifestRegistryForPluginRegistry({ includeDisabled: true }).plugins.flatMap( - (plugin) => plugin.syntheticAuthRefs ?? [], - ), + result.snapshot.plugins.flatMap((plugin) => plugin.syntheticAuthRefs ?? []), ); } From e76bac5d147c47fc522ddabc3cd26d023092629e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:25:04 -0700 Subject: [PATCH 10/13] fix(cli): lazy load plugin maintenance paths --- src/cli/plugins-cli.ts | 90 ++++++++++++++++++++++++------------------ 1 file changed, 51 insertions(+), 39 deletions(-) diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index ffa833639c4..c6a20d1a2f9 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -5,51 +5,14 @@ import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../config import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; -import { enablePluginInConfig } from "../plugins/enable.js"; -import { - loadInstalledPluginIndexInstallRecords, - removePluginInstallRecordFromRecords, - withoutPluginInstallRecords, - withPluginInstallRecords, -} from "../plugins/installed-plugin-index-records.js"; -import { listMarketplacePlugins } from "../plugins/marketplace.js"; -import { inspectPluginRegistry, refreshPluginRegistry } from "../plugins/plugin-registry.js"; import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; -import { - buildAllPluginInspectReports, - buildPluginDiagnosticsReport, - buildPluginCompatibilityNotices, - buildPluginInspectReport, - buildPluginRegistrySnapshotReport, - formatPluginCompatibilityNotice, -} from "../plugins/status.js"; import type { PluginLogger } from "../plugins/types.js"; -import { - applyPluginUninstallDirectoryRemoval, - formatUninstallActionLabels, - formatUninstallSlotResetPreview, - planPluginUninstall, - resolveUninstallChannelConfigKeys, - UNINSTALL_ACTION_LABELS, -} from "../plugins/uninstall.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; -import { - applySlotSelectionForPlugin, - createPluginInstallLogger, - logSlotWarnings, -} from "./plugins-command-helpers.js"; -import { setPluginEnabledInConfig } from "./plugins-config.js"; -import { runPluginInstallCommand } from "./plugins-install-command.js"; -import { commitPluginInstallRecordsWithConfig } from "./plugins-install-record-commit.js"; import { formatPluginLine } from "./plugins-list-format.js"; -import { refreshPluginRegistryAfterConfigMutation } from "./plugins-registry-refresh.js"; -import { resolvePluginUninstallId } from "./plugins-uninstall-selection.js"; -import { runPluginUpdateCommand } from "./plugins-update-command.js"; -import { promptYesNo } from "./prompt.js"; export type PluginsListOptions = { json?: boolean; @@ -182,7 +145,8 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .option("--enabled", "Only show enabled plugins", false) .option("--verbose", "Show detailed entries", false) - .action((opts: PluginsListOptions) => { + .action(async (opts: PluginsListOptions) => { + const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); const cfg = loadConfig(); const report = buildPluginRegistrySnapshotReport({ config: cfg, @@ -290,6 +254,14 @@ export function registerPluginsCli(program: Command) { .option("--all", "Inspect all plugins") .option("--json", "Print JSON") .action(async (id: string | undefined, opts: PluginInspectOptions) => { + const { + buildAllPluginInspectReports, + buildPluginDiagnosticsReport, + buildPluginInspectReport, + formatPluginCompatibilityNotice, + } = await import("../plugins/status.js"); + const { loadInstalledPluginIndexInstallRecords } = + await import("../plugins/installed-plugin-index-records.js"); const cfg = loadConfig(); const installRecords = await loadInstalledPluginIndexInstallRecords(); const report = buildPluginDiagnosticsReport({ @@ -523,6 +495,11 @@ export function registerPluginsCli(program: Command) { .description("Enable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { + const { enablePluginInConfig } = await import("../plugins/enable.js"); + const { applySlotSelectionForPlugin, logSlotWarnings } = + await import("./plugins-command-helpers.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); const snapshot = await readConfigFileSnapshot(); const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const enableResult = enablePluginInConfig(cfg, id); @@ -557,6 +534,9 @@ export function registerPluginsCli(program: Command) { .description("Disable a plugin in config") .argument("", "Plugin id") .action(async (id: string) => { + const { setPluginEnabledInConfig } = await import("./plugins-config.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); const snapshot = await readConfigFileSnapshot(); const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const next = setPluginEnabledInConfig(cfg, id, false); @@ -583,6 +563,27 @@ export function registerPluginsCli(program: Command) { .option("--force", "Skip confirmation prompt", false) .option("--dry-run", "Show what would be removed without making changes", false) .action(async (id: string, opts: PluginUninstallOptions) => { + const { + loadInstalledPluginIndexInstallRecords, + removePluginInstallRecordFromRecords, + withoutPluginInstallRecords, + withPluginInstallRecords, + } = await import("../plugins/installed-plugin-index-records.js"); + const { buildPluginDiagnosticsReport } = await import("../plugins/status.js"); + const { + formatUninstallActionLabels, + formatUninstallSlotResetPreview, + resolveUninstallChannelConfigKeys, + resolveUninstallDirectoryTarget, + UNINSTALL_ACTION_LABELS, + uninstallPlugin, + } = await import("../plugins/uninstall.js"); + const { commitPluginInstallRecordsWithConfig } = + await import("./plugins-install-record-commit.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); + const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js"); + const { promptYesNo } = await import("./prompt.js"); const snapshot = await readConfigFileSnapshot(); const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; const installRecords = await loadInstalledPluginIndexInstallRecords(); @@ -744,6 +745,7 @@ export function registerPluginsCli(program: Command) { marketplace?: string; }, ) => { + const { runPluginInstallCommand } = await import("./plugins-install-command.js"); await runPluginInstallCommand({ raw, opts }); }, ); @@ -760,6 +762,7 @@ export function registerPluginsCli(program: Command) { false, ) .action(async (id: string | undefined, opts: PluginUpdateOptions) => { + const { runPluginUpdateCommand } = await import("./plugins-update-command.js"); await runPluginUpdateCommand({ id, opts }); }); @@ -769,6 +772,8 @@ export function registerPluginsCli(program: Command) { .option("--json", "Print JSON") .option("--refresh", "Rebuild the persisted registry from current plugin manifests", false) .action(async (opts: PluginRegistryOptions) => { + const { inspectPluginRegistry, refreshPluginRegistry } = + await import("../plugins/plugin-registry.js"); const cfg = loadConfig(); if (opts.refresh) { @@ -825,7 +830,12 @@ export function registerPluginsCli(program: Command) { plugins .command("doctor") .description("Report plugin load issues") - .action(() => { + .action(async () => { + const { + buildPluginCompatibilityNotices, + buildPluginDiagnosticsReport, + formatPluginCompatibilityNotice, + } = await import("../plugins/status.js"); const report = buildPluginDiagnosticsReport(); const errors = report.plugins.filter((p) => p.status === "error"); const diags = report.diagnostics.filter((d) => d.level === "error"); @@ -880,6 +890,8 @@ export function registerPluginsCli(program: Command) { .argument("", "Local marketplace path/repo or git/GitHub source") .option("--json", "Print JSON") .action(async (source: string, opts: PluginMarketplaceListOptions) => { + const { listMarketplacePlugins } = await import("../plugins/marketplace.js"); + const { createPluginInstallLogger } = await import("./plugins-command-helpers.js"); const result = await listMarketplacePlugins({ marketplace: source, logger: createPluginInstallLogger(), From be388084c2522ea269e91aa38494e51c7a66d1b2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:27:17 -0700 Subject: [PATCH 11/13] test(cli): cover lazy plugin inspect mocks --- src/cli/plugins-cli-test-helpers.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index a6a45cf9b4d..4b96322d54b 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -56,6 +56,7 @@ export const loadPluginManifestRegistry: UnknownMock = vi.fn(); export const buildPluginSnapshotReport: UnknownMock = vi.fn(); export const buildPluginRegistrySnapshotReport: UnknownMock = vi.fn(); export const buildPluginInspectReport: UnknownMock = vi.fn(); +export const buildAllPluginInspectReports: UnknownMock = vi.fn(); export const buildPluginDiagnosticsReport: UnknownMock = vi.fn(); export const buildPluginCompatibilityNotices: UnknownMock = vi.fn(); export const inspectPluginRegistry: AsyncUnknownMock = vi.fn(); @@ -248,6 +249,16 @@ vi.mock("../plugins/status.js", () => ({ buildPluginInspectReport, ...args, )) as (typeof import("../plugins/status.js"))["buildPluginInspectReport"], + buildAllPluginInspectReports: (( + ...args: Parameters<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]> + ) => + invokeMock< + Parameters<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]>, + ReturnType<(typeof import("../plugins/status.js"))["buildAllPluginInspectReports"]> + >( + buildAllPluginInspectReports, + ...args, + )) as (typeof import("../plugins/status.js"))["buildAllPluginInspectReports"], buildPluginDiagnosticsReport: (( ...args: Parameters<(typeof import("../plugins/status.js"))["buildPluginDiagnosticsReport"]> ) => From 5411f9d2179a569dab6b79134b2088000e9404db Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:45:26 -0700 Subject: [PATCH 12/13] test(models): stabilize provider index list mocks --- .../list.list-command.forward-compat.test.ts | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) 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 55c12fa3317..5d836302c6b 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -63,6 +63,7 @@ const mocks = vi.hoisted(() => { loadModelCatalog: vi.fn(), loadProviderCatalogModelsForList: vi.fn(), loadStaticManifestCatalogRowsForList: vi.fn(), + loadProviderIndexCatalogRowsForList: vi.fn(), hasProviderStaticCatalogForFilter: vi.fn(), resolveConfiguredEntries: vi.fn(), printModelTable: vi.fn(), @@ -91,6 +92,7 @@ function resetMocks() { mocks.loadModelCatalog.mockResolvedValue([]); mocks.loadProviderCatalogModelsForList.mockResolvedValue([]); mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([]); + mocks.loadProviderIndexCatalogRowsForList.mockReturnValue([]); mocks.hasProviderStaticCatalogForFilter.mockResolvedValue(false); mocks.resolveConfiguredEntries.mockReturnValue({ entries: [ @@ -154,6 +156,10 @@ function installModelsListCommandForwardCompatMocks() { loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList, })); + vi.doMock("./list.provider-index-catalog.js", () => ({ + loadProviderIndexCatalogRowsForList: mocks.loadProviderIndexCatalogRowsForList, + })); + vi.doMock("./list.registry-load.js", () => ({ loadListModelRegistry: async ( cfg: unknown, @@ -309,7 +315,6 @@ describe("modelsListCommand forward-compat", () => { }, ], }); - mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_MINI_MODEL }); const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); @@ -338,7 +343,6 @@ describe("modelsListCommand forward-compat", () => { }, ], }); - mocks.resolveModelWithRegistry.mockReturnValueOnce({ ...OPENAI_CODEX_PRO_MODEL }); const runtime = createRuntime(); await modelsListCommand({ json: true }, runtime as never); @@ -422,11 +426,6 @@ describe("modelsListCommand forward-compat", () => { it("does not require the all-model registry result for configured-mode listing", async () => { const previousExitCode = process.exitCode; process.exitCode = undefined; - mocks.loadModelRegistry.mockResolvedValueOnce({ - models: [], - availableKeys: new Set(), - registry: undefined, - }); const runtime = createRuntime(); let observedExitCode: number | undefined; @@ -439,6 +438,7 @@ describe("modelsListCommand forward-compat", () => { expect(runtime.error).not.toHaveBeenCalled(); expect(observedExitCode).toBeUndefined(); + expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); expect(mocks.printModelTable).toHaveBeenCalled(); }); }); @@ -515,12 +515,27 @@ 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); + mocks.loadProviderIndexCatalogRowsForList.mockReturnValueOnce([ + { + 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", "image"], + reasoning: false, + status: "available", + baseUrl: "https://api.moonshot.ai/v1", + contextWindow: 262_144, + }, + ]); const runtime = createRuntime(); await modelsListCommand({ all: true, provider: "moonshot", json: true }, runtime as never); expect(mocks.loadModelRegistry).not.toHaveBeenCalled(); + expect(mocks.hasProviderStaticCatalogForFilter).not.toHaveBeenCalled(); expect(mocks.loadProviderCatalogModelsForList).not.toHaveBeenCalled(); expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([ expect.objectContaining({ @@ -587,6 +602,7 @@ describe("modelsListCommand forward-compat", () => { it("includes provider-owned supplemental catalog rows with provider filters", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [], availableKeys: new Set(["opencode-go/deepseek-v4-pro"]), @@ -681,6 +697,7 @@ describe("modelsListCommand forward-compat", () => { it("uses provider runtime metadata for discovered codex gpt-5.5 rows", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [ { @@ -798,6 +815,7 @@ describe("modelsListCommand forward-compat", () => { describe("provider filter canonicalization", () => { it("matches alias-valued discovered providers against canonical provider filters", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); mocks.loadModelRegistry.mockResolvedValueOnce({ models: [ { From 301391623208cbde3540d43bc08706030bf292d2 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 26 Apr 2026 03:50:31 -0700 Subject: [PATCH 13/13] Update docker.md --- docs/install/docker.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/install/docker.md b/docs/install/docker.md index 24589c5e35e..6d71876d486 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -160,9 +160,13 @@ export OTEL_SERVICE_NAME="openclaw-gateway" ./scripts/docker/setup.sh ``` -The official OpenClaw Docker release image includes `diagnostics-otel` -dependencies. To enable export, allow and enable the `diagnostics-otel` plugin -in config, then set `diagnostics.otel.enabled=true` or use the config example in +The official OpenClaw Docker release image includes the bundled +`diagnostics-otel` plugin source. Depending on the image and cache state, the +Gateway may still stage plugin-local OpenTelemetry runtime dependencies the +first time the plugin is enabled, so allow that first boot to reach the package +registry or prewarm the image in your release lane. To enable export, allow and +enable the `diagnostics-otel` plugin in config, then set +`diagnostics.otel.enabled=true` or use the config example in [OpenTelemetry export](/gateway/opentelemetry). Collector auth headers are configured through `diagnostics.otel.headers`, not through Docker environment variables.