diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 6ee2b4ed055..d68da9c4767 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -781,9 +781,11 @@ Suppression fields: | `when.baseUrlHosts` | `string[]` | Optional list of effective provider base URL hosts required before the suppression applies. | | `when.providerConfigApiIn` | `string[]` | Optional list of exact provider-config `api` values required before the suppression applies. | -Do not put runtime-only data in `modelCatalog`. If a provider needs account -state, an API request, or local process discovery to know the complete model -set, declare that provider as `refreshable` or `runtime` in `discovery`. +Do not put runtime-only data in `modelCatalog`. Use `static` only when manifest +rows are complete enough for provider-filtered list and picker surfaces to skip +registry/runtime discovery. Use `refreshable` when manifest rows are useful +seeds or supplements but a registry/cache refresh may add more rows. Use +`runtime` when OpenClaw must load provider runtime to know the list. ## modelIdNormalization reference diff --git a/src/commands/models/list.manifest-catalog.test.ts b/src/commands/models/list.manifest-catalog.test.ts index 36dce1d902a..c6158ae81a4 100644 --- a/src/commands/models/list.manifest-catalog.test.ts +++ b/src/commands/models/list.manifest-catalog.test.ts @@ -70,4 +70,20 @@ describe("loadStaticManifestCatalogRowsForList", () => { env: undefined, }); }); + + it("can load refreshable manifest rows for broad registry-backed lists", async () => { + const { loadManifestCatalogRowsForList } = await import("./list.manifest-catalog.js"); + mocks.loadPluginRegistrySnapshot.mockReturnValueOnce({ plugins: [], diagnostics: [] }); + mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValueOnce({ + plugins: [openrouterPlugin, moonshotPlugin], + diagnostics: [], + }); + + expect( + loadManifestCatalogRowsForList({ + cfg: {}, + staticOnly: false, + }).map((row) => row.ref), + ).toEqual(["moonshot/kimi-k2.6", "openrouter/auto"]); + }); }); diff --git a/src/commands/models/list.manifest-catalog.ts b/src/commands/models/list.manifest-catalog.ts index 65f82d30f52..cc5b13a7831 100644 --- a/src/commands/models/list.manifest-catalog.ts +++ b/src/commands/models/list.manifest-catalog.ts @@ -13,12 +13,13 @@ import { type PluginRegistrySnapshot, } from "../../plugins/plugin-registry.js"; -function loadStaticManifestCatalogRowsForPluginIds(params: { +function loadManifestCatalogRowsForPluginIds(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; index: PluginRegistrySnapshot; pluginIds?: readonly string[]; providerFilter?: string; + staticOnly?: boolean; }): readonly NormalizedModelCatalogRow[] { if (params.pluginIds && params.pluginIds.length === 0) { return []; @@ -33,6 +34,9 @@ function loadStaticManifestCatalogRowsForPluginIds(params: { registry, ...(params.providerFilter ? { providerFilter: params.providerFilter } : {}), }); + if (params.staticOnly === false) { + return plan.rows; + } const staticProviders = new Set( plan.entries.filter((entry) => entry.discovery === "static").map((entry) => entry.provider), ); @@ -77,10 +81,11 @@ function resolveDeclaredModelCatalogPluginIds(params: { }); } -export function loadStaticManifestCatalogRowsForList(params: { +export function loadManifestCatalogRowsForList(params: { cfg: OpenClawConfig; providerFilter?: string; env?: NodeJS.ProcessEnv; + staticOnly?: boolean; }): readonly NormalizedModelCatalogRow[] { const providerFilter = params.providerFilter ? normalizeModelCatalogProviderId(params.providerFilter) @@ -90,13 +95,14 @@ export function loadStaticManifestCatalogRowsForList(params: { env: params.env, }); if (!providerFilter) { - return loadStaticManifestCatalogRowsForPluginIds({ + return loadManifestCatalogRowsForPluginIds({ cfg: params.cfg, env: params.env, index, + staticOnly: params.staticOnly, }); } - const conventionRows = loadStaticManifestCatalogRowsForPluginIds({ + const conventionRows = loadManifestCatalogRowsForPluginIds({ cfg: params.cfg, env: params.env, index, @@ -106,11 +112,12 @@ export function loadStaticManifestCatalogRowsForList(params: { providerFilter, }), providerFilter, + staticOnly: params.staticOnly, }); if (conventionRows.length > 0) { return conventionRows; } - return loadStaticManifestCatalogRowsForPluginIds({ + return loadManifestCatalogRowsForPluginIds({ cfg: params.cfg, env: params.env, index, @@ -120,5 +127,17 @@ export function loadStaticManifestCatalogRowsForList(params: { providerFilter, }), providerFilter, + staticOnly: params.staticOnly, + }); +} + +export function loadStaticManifestCatalogRowsForList(params: { + cfg: OpenClawConfig; + providerFilter?: string; + env?: NodeJS.ProcessEnv; +}): readonly NormalizedModelCatalogRow[] { + return loadManifestCatalogRowsForList({ + ...params, + staticOnly: true, }); } diff --git a/src/commands/models/list.source-plan.test.ts b/src/commands/models/list.source-plan.test.ts index b28d33532c0..00b88a08a1b 100644 --- a/src/commands/models/list.source-plan.test.ts +++ b/src/commands/models/list.source-plan.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - loadStaticManifestCatalogRowsForList: vi.fn(), + loadManifestCatalogRowsForList: vi.fn(), loadProviderIndexCatalogRowsForList: vi.fn(), hasProviderStaticCatalogForFilter: vi.fn(), })); vi.mock("./list.manifest-catalog.js", () => ({ - loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList, + loadManifestCatalogRowsForList: mocks.loadManifestCatalogRowsForList, })); vi.mock("./list.provider-index-catalog.js", () => ({ @@ -33,14 +33,14 @@ const catalogRow = { describe("planAllModelListSources", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([]); + mocks.loadManifestCatalogRowsForList.mockReturnValue([]); mocks.loadProviderIndexCatalogRowsForList.mockReturnValue([]); mocks.hasProviderStaticCatalogForFilter.mockResolvedValue(false); }); it("uses installed manifest rows before provider index or runtime catalog sources", async () => { const { planAllModelListSources } = await import("./list.source-plan.js"); - mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([catalogRow]); + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([catalogRow]); const plan = await planAllModelListSources({ all: true, @@ -54,6 +54,11 @@ describe("planAllModelListSources", () => { skipRuntimeModelSuppression: true, }); expect(plan.manifestCatalogRows).toEqual([catalogRow]); + expect(mocks.loadManifestCatalogRowsForList).toHaveBeenCalledWith({ + cfg: {}, + providerFilter: "moonshot", + staticOnly: true, + }); expect(mocks.loadProviderIndexCatalogRowsForList).not.toHaveBeenCalled(); expect(mocks.hasProviderStaticCatalogForFilter).not.toHaveBeenCalled(); }); @@ -78,6 +83,35 @@ describe("planAllModelListSources", () => { expect(mocks.hasProviderStaticCatalogForFilter).not.toHaveBeenCalled(); }); + it("uses the registry when provider-filtered manifest rows are refreshable", async () => { + const { planAllModelListSources } = await import("./list.source-plan.js"); + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([]).mockReturnValueOnce([catalogRow]); + + const plan = await planAllModelListSources({ + all: true, + providerFilter: "openai", + cfg: {}, + }); + + expect(plan).toMatchObject({ + kind: "registry", + requiresInitialRegistry: true, + skipRuntimeModelSuppression: false, + }); + expect(plan.manifestCatalogRows).toEqual([catalogRow]); + expect(mocks.loadManifestCatalogRowsForList).toHaveBeenNthCalledWith(1, { + cfg: {}, + providerFilter: "openai", + staticOnly: true, + }); + expect(mocks.loadManifestCatalogRowsForList).toHaveBeenNthCalledWith(2, { + cfg: {}, + providerFilter: "openai", + staticOnly: false, + }); + expect(mocks.loadProviderIndexCatalogRowsForList).not.toHaveBeenCalled(); + }); + it("keeps scoped runtime catalog fallback separate from broad registry loading", async () => { const { planAllModelListSources } = await import("./list.source-plan.js"); @@ -98,7 +132,7 @@ describe("planAllModelListSources", () => { it("keeps broad all-model lists on the registry path with cheap catalog supplements", async () => { const { planAllModelListSources } = await import("./list.source-plan.js"); const providerIndexRow = { ...catalogRow, source: "provider-index" }; - mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([catalogRow]); + mocks.loadManifestCatalogRowsForList.mockReturnValueOnce([catalogRow]); mocks.loadProviderIndexCatalogRowsForList.mockReturnValueOnce([providerIndexRow]); const plan = await planAllModelListSources({ @@ -113,6 +147,10 @@ describe("planAllModelListSources", () => { }); expect(plan.manifestCatalogRows).toEqual([catalogRow]); expect(plan.providerIndexCatalogRows).toEqual([providerIndexRow]); + expect(mocks.loadManifestCatalogRowsForList).toHaveBeenCalledWith({ + cfg: {}, + staticOnly: false, + }); expect(mocks.hasProviderStaticCatalogForFilter).not.toHaveBeenCalled(); }); diff --git a/src/commands/models/list.source-plan.ts b/src/commands/models/list.source-plan.ts index f434fdc69a2..7eace278cfe 100644 --- a/src/commands/models/list.source-plan.ts +++ b/src/commands/models/list.source-plan.ts @@ -51,11 +51,20 @@ export async function planAllModelListSources(params: { return createRegistryModelListSourcePlan(); } - const { loadStaticManifestCatalogRowsForList } = await import("./list.manifest-catalog.js"); - const manifestCatalogRows = loadStaticManifestCatalogRowsForList({ + const { loadManifestCatalogRowsForList } = await import("./list.manifest-catalog.js"); + const staticManifestCatalogRows = loadManifestCatalogRowsForList({ cfg: params.cfg, ...(params.providerFilter ? { providerFilter: params.providerFilter } : {}), + staticOnly: Boolean(params.providerFilter), }); + const manifestCatalogRows = + params.providerFilter && staticManifestCatalogRows.length === 0 + ? loadManifestCatalogRowsForList({ + cfg: params.cfg, + providerFilter: params.providerFilter, + staticOnly: false, + }) + : staticManifestCatalogRows; if (!params.providerFilter) { const { loadProviderIndexCatalogRowsForList } = await import("./list.provider-index-catalog.js"); @@ -70,6 +79,13 @@ export async function planAllModelListSources(params: { } if (manifestCatalogRows.length > 0) { + if (staticManifestCatalogRows.length === 0) { + return createSourcePlan({ + kind: "registry", + manifestCatalogRows, + requiresInitialRegistry: true, + }); + } return createSourcePlan({ kind: "manifest", manifestCatalogRows,