refactor: support refreshable manifest list rows

This commit is contained in:
Shakker
2026-04-27 19:09:55 +01:00
parent a0608af2ee
commit 8ac10cf164
5 changed files with 106 additions and 15 deletions

View File

@@ -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

View File

@@ -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"]);
});
});

View File

@@ -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,
});
}

View File

@@ -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();
});

View File

@@ -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,