perf: use manifest catalog for agent allowlists

This commit is contained in:
Shakker
2026-05-01 20:21:16 +01:00
parent dfde770a3a
commit f8639d3429
5 changed files with 122 additions and 5 deletions

View File

@@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai
- Agents/tools: skip unavailable media generation and PDF tool factories from the live reply path when Gateway metadata and the active auth store prove no configured provider can back them, while keeping explicit config and auth-backed providers on the normal factory path. Thanks @shakkernerd.
- Agents/runtime: reuse the Gateway metadata startup plan when ensuring reply runtime plugins are loaded, so live agent turns do not broad-load plugin runtimes after the Gateway already scoped startup activation. Thanks @shakkernerd.
- Agents/runtime: validate agent model allowlists against manifest model catalog metadata during reply startup, avoiding broad provider runtime catalog loading before the agent run lane starts. Thanks @shakkernerd.
- Agents/tools: route media and generation capability lookups through the Gateway plugin metadata snapshot during reply tool registration, avoiding repeated manifest registry reloads on the live reply path. Thanks @shakkernerd.
- Agents/tools: reuse the auth profile store already loaded for the active run when deciding media and generation tool availability, avoiding repeated provider-auth runtime discovery during reply startup. Thanks @shakkernerd.
- Agents/tools: keep image, video, and music generation tool registration on manifest/auth control-plane checks instead of loading runtime provider registries during reply startup, reducing live-path tool-prep blocking while leaving provider runtime resolution for execution and list actions. Thanks @shakkernerd.

View File

@@ -36,6 +36,7 @@ const state = vi.hoisted(() => ({
clearSessionAuthProfileOverrideMock: vi.fn(),
isThinkingLevelSupportedMock: vi.fn((_args: unknown) => true),
resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"),
loadManifestModelCatalogMock: vi.fn(() => []),
authProfileStoreMock: { profiles: {} } as { profiles: Record<string, unknown> },
sessionEntryMock: undefined as unknown,
sessionStoreMock: undefined as unknown,
@@ -290,7 +291,7 @@ vi.mock("./lanes.js", () => ({
}));
vi.mock("./model-catalog.js", () => ({
loadModelCatalog: async () => [],
loadManifestModelCatalog: (...args: unknown[]) => state.loadManifestModelCatalogMock(...args),
}));
vi.mock("./model-selection.js", () => ({
@@ -480,6 +481,7 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
state.runtimeConfigMock = undefined;
state.isThinkingLevelSupportedMock.mockReturnValue(true);
state.resolveThinkingDefaultMock.mockReturnValue("low");
state.loadManifestModelCatalogMock.mockReturnValue([]);
state.acpRunTurnMock.mockImplementation(async (params: unknown) => {
const onEvent = (params as { onEvent?: (event: unknown) => void }).onEvent;
onEvent?.({ type: "text_delta", stream: "output", text: "done" });

View File

@@ -51,7 +51,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import { resolveFastModeState } from "./fast-mode.js";
import { AGENT_LANE_SUBAGENT } from "./lanes.js";
import { LiveSessionModelSwitchError } from "./live-model-switch.js";
import { loadModelCatalog } from "./model-catalog.js";
import { loadManifestModelCatalog } from "./model-catalog.js";
import { runWithModelFallback } from "./model-fallback.js";
import {
buildAllowedModelSet,
@@ -729,12 +729,12 @@ async function agentCommandInternal(
}
const needsModelCatalog = Boolean(hasAllowlist);
let allowedModelKeys = new Set<string>();
let allowedModelCatalog: Awaited<ReturnType<typeof loadModelCatalog>> = [];
let modelCatalog: Awaited<ReturnType<typeof loadModelCatalog>> | null = null;
let allowedModelCatalog: ReturnType<typeof loadManifestModelCatalog> = [];
let modelCatalog: ReturnType<typeof loadManifestModelCatalog> | null = null;
let allowAnyModel = !hasAllowlist;
if (needsModelCatalog) {
modelCatalog = await loadModelCatalog({ config: cfg });
modelCatalog = loadManifestModelCatalog({ config: cfg, workspaceDir });
const allowed = buildAllowedModelSet({
cfg,
catalog: modelCatalog,

View File

@@ -7,11 +7,14 @@ type PiSdkModule = typeof import("./pi-model-discovery.js");
let __setModelCatalogImportForTest: typeof import("./model-catalog.js").__setModelCatalogImportForTest;
let findModelCatalogEntry: typeof import("./model-catalog.js").findModelCatalogEntry;
let findModelInCatalog: typeof import("./model-catalog.js").findModelInCatalog;
let loadManifestModelCatalog: typeof import("./model-catalog.js").loadManifestModelCatalog;
let loadModelCatalog: typeof import("./model-catalog.js").loadModelCatalog;
let modelSupportsInput: typeof import("./model-catalog.js").modelSupportsInput;
let resetModelCatalogCacheForTest: typeof import("./model-catalog.js").resetModelCatalogCacheForTest;
let augmentCatalogMock: ReturnType<typeof vi.fn>;
let ensureOpenClawModelsJsonMock: ReturnType<typeof vi.fn>;
let currentPluginMetadataSnapshotMock: ReturnType<typeof vi.fn>;
let loadPluginMetadataSnapshotMock: ReturnType<typeof vi.fn>;
vi.mock("./model-suppression.runtime.js", () => ({
shouldSuppressBuiltInModel: (params: { provider?: string; id?: string }) =>
@@ -77,11 +80,21 @@ describe("loadModelCatalog", () => {
vi.doMock("../plugins/provider-runtime.runtime.js", () => ({
augmentModelCatalogWithProviderPlugins: vi.fn().mockResolvedValue([]),
}));
currentPluginMetadataSnapshotMock = vi.fn();
loadPluginMetadataSnapshotMock = vi.fn();
vi.doMock("../plugins/current-plugin-metadata-snapshot.js", () => ({
getCurrentPluginMetadataSnapshot: (...args: unknown[]) =>
currentPluginMetadataSnapshotMock(...args),
}));
vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: (...args: unknown[]) => loadPluginMetadataSnapshotMock(...args),
}));
({
__setModelCatalogImportForTest,
findModelCatalogEntry,
findModelInCatalog,
loadManifestModelCatalog,
loadModelCatalog,
modelSupportsInput,
resetModelCatalogCacheForTest,
@@ -93,6 +106,9 @@ describe("loadModelCatalog", () => {
beforeEach(() => {
resetModelCatalogCacheForTest();
ensureOpenClawModelsJsonMock.mockClear();
augmentCatalogMock.mockClear();
currentPluginMetadataSnapshotMock.mockReset();
loadPluginMetadataSnapshotMock.mockReset();
});
afterEach(() => {
@@ -105,6 +121,8 @@ describe("loadModelCatalog", () => {
vi.doUnmock("./models-config.js");
vi.doUnmock("./agent-paths.js");
vi.doUnmock("../plugins/provider-runtime.runtime.js");
vi.doUnmock("../plugins/current-plugin-metadata-snapshot.js");
vi.doUnmock("../plugins/plugin-metadata-snapshot.js");
});
it("retries after import failure without poisoning the cache", async () => {
@@ -367,6 +385,59 @@ describe("loadModelCatalog", () => {
);
});
it("loads manifest catalog rows from the current metadata snapshot without provider runtime", () => {
const snapshot = {
policyHash: "policy",
index: {
policyHash: "policy",
plugins: [
{
pluginId: "external-provider",
enabled: true,
origin: "global",
},
],
},
plugins: [
{
id: "external-provider",
origin: "global",
modelCatalog: {
providers: {
external: {
models: [
{
id: "external-fast",
name: "External Fast",
input: ["text", "image"],
reasoning: true,
contextWindow: 32000,
},
],
},
},
},
},
],
};
currentPluginMetadataSnapshotMock.mockReturnValue(snapshot);
const result = loadManifestModelCatalog({ config: {} as OpenClawConfig });
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
expect(augmentCatalogMock).not.toHaveBeenCalled();
expect(result).toEqual([
{
provider: "external",
id: "external-fast",
name: "External Fast",
input: ["text", "image"],
reasoning: true,
contextWindow: 32000,
},
]);
});
it("dedupes supplemental models against registry entries", async () => {
mockSingleOpenAiCatalogModel();
augmentCatalogMock.mockResolvedValueOnce([

View File

@@ -2,6 +2,10 @@ import { join } from "node:path";
import { getRuntimeConfig } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js";
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
import { isManifestPluginAvailableForControlPlane } from "../plugins/manifest-contract-eligibility.js";
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.runtime.js";
import {
normalizeLowercaseStringOrEmpty,
@@ -105,6 +109,45 @@ function appendCatalogEntriesIfAbsent(
}
}
export function loadManifestModelCatalog(params: {
config: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
}): ModelCatalogEntry[] {
const snapshot =
getCurrentPluginMetadataSnapshot({
config: params.config,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
}) ??
loadPluginMetadataSnapshot({
config: params.config,
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
env: params.env ?? process.env,
});
const eligiblePlugins = snapshot.plugins.filter(
(plugin) =>
plugin.modelCatalog &&
isManifestPluginAvailableForControlPlane({
snapshot,
plugin,
config: params.config,
}),
);
const plan = planManifestModelCatalogRows({
registry: { plugins: eligiblePlugins },
});
return plan.rows.map((row) => ({
id: row.id,
name: row.name,
provider: row.provider,
...(row.contextWindow ? { contextWindow: row.contextWindow } : {}),
...(row.contextTokens && !row.contextWindow ? { contextWindow: row.contextTokens } : {}),
...(typeof row.reasoning === "boolean" ? { reasoning: row.reasoning } : {}),
...(row.input?.length ? { input: [...row.input] } : {}),
...(row.compat ? { compat: row.compat } : {}),
}));
}
export async function loadModelCatalog(params?: {
config?: OpenClawConfig;
useCache?: boolean;