diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index aef7c8773d2..01967a249b5 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -21,6 +21,7 @@ const loadModelCatalog = vi.fn(async () => []); const loadProviderCatalogModelsForList = vi.fn<() => Promise>>>( async () => [], ); +const loadStaticManifestCatalogRowsForList = vi.fn(() => []); const hasProviderStaticCatalogForFilter = vi.fn().mockResolvedValue(false); const shouldSuppressBuiltInModel = vi.fn().mockReturnValue(false); const modelRegistryState = { @@ -113,6 +114,10 @@ vi.mock("./models/list.provider-catalog.js", async (importOriginal) => { }; }); +vi.mock("./models/list.manifest-catalog.js", () => ({ + loadStaticManifestCatalogRowsForList, +})); + vi.mock("../agents/model-suppression.js", () => ({ shouldSuppressBuiltInModel, })); @@ -162,6 +167,8 @@ beforeEach(() => { loadModelCatalog.mockResolvedValue([]); loadProviderCatalogModelsForList.mockReset(); loadProviderCatalogModelsForList.mockResolvedValue([]); + loadStaticManifestCatalogRowsForList.mockReset(); + loadStaticManifestCatalogRowsForList.mockReturnValue([]); hasProviderStaticCatalogForFilter.mockReset(); hasProviderStaticCatalogForFilter.mockResolvedValue(false); shouldSuppressBuiltInModel.mockReset(); 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 11250fa4d3e..7947789d642 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -62,6 +62,7 @@ const mocks = vi.hoisted(() => { loadModelRegistry: vi.fn(), loadModelCatalog: vi.fn(), loadProviderCatalogModelsForList: vi.fn(), + loadStaticManifestCatalogRowsForList: vi.fn(), hasProviderStaticCatalogForFilter: vi.fn(), resolveConfiguredEntries: vi.fn(), printModelTable: vi.fn(), @@ -89,6 +90,7 @@ function resetMocks() { }); mocks.loadModelCatalog.mockResolvedValue([]); mocks.loadProviderCatalogModelsForList.mockResolvedValue([]); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValue([]); mocks.hasProviderStaticCatalogForFilter.mockResolvedValue(false); mocks.resolveConfiguredEntries.mockReturnValue({ entries: [ @@ -147,6 +149,10 @@ function installModelsListCommandForwardCompatMocks() { hasProviderStaticCatalogForFilter: mocks.hasProviderStaticCatalogForFilter, })); + vi.doMock("./list.manifest-catalog.js", () => ({ + loadStaticManifestCatalogRowsForList: mocks.loadStaticManifestCatalogRowsForList, + })); + vi.doMock("./list.registry-load.js", () => ({ loadListModelRegistry: async ( cfg: unknown, @@ -469,6 +475,38 @@ describe("modelsListCommand forward-compat", () => { ]); }); + it("uses manifest catalog rows before provider runtime catalog rows", async () => { + mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); + mocks.loadStaticManifestCatalogRowsForList.mockReturnValueOnce([ + { + provider: "moonshot", + id: "kimi-k2.6", + ref: "moonshot/kimi-k2.6", + mergeKey: "moonshot::kimi-k2.6", + name: "Kimi K2.6", + source: "manifest", + 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({ + key: "moonshot/kimi-k2.6", + available: false, + }), + ]); + }); + it("falls back to registry-backed rows when the fast-path catalog is empty", async () => { mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] }); mocks.hasProviderStaticCatalogForFilter.mockResolvedValueOnce(true); diff --git a/src/commands/models/list.list-command.ts b/src/commands/models/list.list-command.ts index ba28db382fe..30e383005c7 100644 --- a/src/commands/models/list.list-command.ts +++ b/src/commands/models/list.list-command.ts @@ -1,5 +1,6 @@ import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; import { parseModelRef } from "../../agents/model-selection.js"; +import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; import type { RuntimeEnv } from "../../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveConfiguredEntries } from "./list.configured.js"; @@ -61,13 +62,20 @@ export async function modelsListCommand( let availabilityErrorMessage: string | undefined; const { entries } = resolveConfiguredEntries(cfg); const configuredByKey = new Map(entries.map((entry) => [entry.key, entry])); + let manifestCatalogRows: readonly NormalizedModelCatalogRow[] = []; + if (opts.all && providerFilter) { + const { loadStaticManifestCatalogRowsForList } = await import("./list.manifest-catalog.js"); + manifestCatalogRows = loadStaticManifestCatalogRowsForList({ cfg, providerFilter }); + } + const useManifestCatalogFastPath = manifestCatalogRows.length > 0; const useProviderCatalogFastPath = - opts.all && providerFilter + !useManifestCatalogFastPath && opts.all && providerFilter ? await hasProviderStaticCatalogForFilter({ cfg, providerFilter }) : false; const shouldLoadRegistry = modelRowSourcesRequireRegistry({ all: opts.all, providerFilter, + useManifestCatalogFastPath, useProviderCatalogFastPath, }); const loadRegistryState = async () => { @@ -112,6 +120,8 @@ export async function modelsListCommand( rows, context: rowContext, modelRegistry, + manifestCatalogRows, + useManifestCatalogFastPath, useProviderCatalogFastPath, }); if (initialAppend.requiresRegistryFallback) { @@ -128,6 +138,8 @@ export async function modelsListCommand( rows, context: rowContext, modelRegistry, + manifestCatalogRows: [], + useManifestCatalogFastPath: false, useProviderCatalogFastPath: false, }); } diff --git a/src/commands/models/list.manifest-catalog.ts b/src/commands/models/list.manifest-catalog.ts new file mode 100644 index 00000000000..ed8068fbe75 --- /dev/null +++ b/src/commands/models/list.manifest-catalog.ts @@ -0,0 +1,27 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { planManifestModelCatalogRows } from "../../model-catalog/index.js"; +import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; +import { loadPluginManifestRegistry } from "../../plugins/manifest-registry.js"; + +export function loadStaticManifestCatalogRowsForList(params: { + cfg: OpenClawConfig; + providerFilter: string; + env?: NodeJS.ProcessEnv; +}): readonly NormalizedModelCatalogRow[] { + const registry = loadPluginManifestRegistry({ + config: params.cfg, + env: params.env, + cache: true, + }); + const plan = planManifestModelCatalogRows({ + registry, + providerFilter: params.providerFilter, + }); + const staticProviders = new Set( + plan.entries.filter((entry) => entry.discovery === "static").map((entry) => entry.provider), + ); + if (staticProviders.size === 0) { + return []; + } + return plan.rows.filter((row) => staticProviders.has(row.provider)); +} diff --git a/src/commands/models/list.model-row.ts b/src/commands/models/list.model-row.ts index f02086d8847..a0a9b557574 100644 --- a/src/commands/models/list.model-row.ts +++ b/src/commands/models/list.model-row.ts @@ -8,7 +8,7 @@ export type ListRowModel = { id: string; name: string; provider: string; - input: Array<"text" | "image">; + input: Array<"text" | "image" | "document">; baseUrl?: string; contextWindow?: number | null; contextTokens?: number | null; diff --git a/src/commands/models/list.row-sources.ts b/src/commands/models/list.row-sources.ts index 85132d3e063..fbaaadf625e 100644 --- a/src/commands/models/list.row-sources.ts +++ b/src/commands/models/list.row-sources.ts @@ -1,9 +1,11 @@ import type { ModelRegistry } from "@mariozechner/pi-coding-agent"; +import type { NormalizedModelCatalogRow } from "../../model-catalog/index.js"; import { appendCatalogSupplementRows, appendConfiguredProviderRows, appendConfiguredRows, appendDiscoveredRows, + appendManifestCatalogRows, appendProviderCatalogRows, type RowBuilderContext, } from "./list.rows.js"; @@ -13,6 +15,8 @@ type AllModelRowSources = { rows: ModelRow[]; context: RowBuilderContext; modelRegistry?: ModelRegistry; + manifestCatalogRows?: readonly NormalizedModelCatalogRow[]; + useManifestCatalogFastPath: boolean; useProviderCatalogFastPath: boolean; }; @@ -23,12 +27,16 @@ type AppendAllModelRowSourcesResult = { export function modelRowSourcesRequireRegistry(params: { all?: boolean; providerFilter?: string; + useManifestCatalogFastPath: boolean; useProviderCatalogFastPath: boolean; }): boolean { if (!params.all) { return false; } - if (params.providerFilter && params.useProviderCatalogFastPath) { + if ( + params.providerFilter && + (params.useManifestCatalogFastPath || params.useProviderCatalogFastPath) + ) { return false; } return true; @@ -37,19 +45,33 @@ export function modelRowSourcesRequireRegistry(params: { export async function appendAllModelRowSources( params: AllModelRowSources, ): Promise { - if (params.context.filter.provider && params.useProviderCatalogFastPath) { + if ( + params.context.filter.provider && + (params.useManifestCatalogFastPath || params.useProviderCatalogFastPath) + ) { let seenKeys = new Set(); appendConfiguredProviderRows({ rows: params.rows, context: params.context, seenKeys, }); - const catalogRows = await appendProviderCatalogRows({ - rows: params.rows, - context: params.context, - seenKeys, - staticOnly: true, - }); + let catalogRows = 0; + if (params.useManifestCatalogFastPath) { + catalogRows = appendManifestCatalogRows({ + rows: params.rows, + context: params.context, + seenKeys, + manifestRows: params.manifestCatalogRows ?? [], + }); + } + if (catalogRows === 0 && params.useProviderCatalogFastPath) { + catalogRows = await appendProviderCatalogRows({ + rows: params.rows, + context: params.context, + seenKeys, + staticOnly: true, + }); + } if (catalogRows === 0) { if (!params.modelRegistry) { return { requiresRegistryFallback: true }; diff --git a/src/commands/models/list.rows.ts b/src/commands/models/list.rows.ts index 902763065e8..af38385c1c5 100644 --- a/src/commands/models/list.rows.ts +++ b/src/commands/models/list.rows.ts @@ -6,6 +6,7 @@ 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 { @@ -134,6 +135,17 @@ function toConfiguredProviderListModel(params: { }; } +function toManifestCatalogListModel(row: NormalizedModelCatalogRow): ListRowModel { + return { + provider: row.provider, + id: row.id, + name: row.name, + baseUrl: row.baseUrl, + input: [...row.input], + contextWindow: row.contextWindow ?? DEFAULT_CONTEXT_TOKENS, + }; +} + function shouldListConfiguredProviderModel(params: { providerConfig: Partial; model: Partial; @@ -213,6 +225,31 @@ export function appendConfiguredProviderRows(params: { } } +export function appendManifestCatalogRows(params: { + rows: ModelRow[]; + context: RowBuilderContext; + seenKeys: Set; + manifestRows: readonly NormalizedModelCatalogRow[]; +}): number { + let appended = 0; + for (const manifestRow of params.manifestRows) { + const key = modelKey(manifestRow.provider, manifestRow.id); + if ( + appendVisibleRow({ + rows: params.rows, + model: toManifestCatalogListModel(manifestRow), + key, + context: params.context, + seenKeys: params.seenKeys, + allowProviderAvailabilityFallback: true, + }) + ) { + appended += 1; + } + } + return appended; +} + export async function appendCatalogSupplementRows(params: { rows: ModelRow[]; modelRegistry: ModelRegistry;