perf(gateway): cache manifest model catalog rows

This commit is contained in:
Peter Steinberger
2026-05-27 18:12:22 +01:00
parent a4c2e7f5cf
commit d84cbfa50e
10 changed files with 98 additions and 72 deletions

View File

@@ -111,6 +111,46 @@ function modelIdNormalizationSnapshot() {
};
}
function manifestModelCatalogSnapshot(model: {
id: string;
name?: string;
input?: Array<"text" | "image">;
reasoning?: boolean;
contextWindow?: number;
}) {
return {
policyHash: "policy",
index: {
policyHash: "policy",
plugins: [
{
pluginId: "external-provider",
enabled: true,
origin: "global",
},
],
},
plugins: [
{
id: "external-provider",
origin: "global",
modelCatalog: {
providers: {
external: {
models: [
{
name: model.id,
...model,
},
],
},
},
},
},
],
};
}
function configuredModel(id: string) {
return {
id,
@@ -896,40 +936,13 @@ 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,
},
],
},
},
},
},
],
};
const snapshot = manifestModelCatalogSnapshot({
id: "external-fast",
name: "External Fast",
input: ["text", "image"],
reasoning: true,
contextWindow: 32000,
});
currentPluginMetadataSnapshotMock.mockReturnValue(snapshot);
const result = loadManifestModelCatalog({ config: {} as OpenClawConfig });
@@ -948,6 +961,41 @@ describe("loadModelCatalog", () => {
]);
});
it("reuses planned manifest catalog rows for the same config and metadata snapshot", () => {
const config = {} as OpenClawConfig;
const snapshot = manifestModelCatalogSnapshot({ id: "external-fast" });
currentPluginMetadataSnapshotMock.mockReturnValue(snapshot);
const first = loadManifestModelCatalog({ config });
const second = loadManifestModelCatalog({ config });
expect(second).toBe(first);
expect(first).toEqual([
{
provider: "external",
id: "external-fast",
name: "external-fast",
input: ["text"],
reasoning: false,
},
]);
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
});
it("refreshes manifest catalog rows when the metadata snapshot changes", () => {
const config = {} as OpenClawConfig;
currentPluginMetadataSnapshotMock
.mockReturnValueOnce(manifestModelCatalogSnapshot({ id: "external-fast" }))
.mockReturnValue(manifestModelCatalogSnapshot({ id: "external-slow" }));
const first = loadManifestModelCatalog({ config });
const second = loadManifestModelCatalog({ config });
expect(second).not.toBe(first);
expect(first[0]?.id).toBe("external-fast");
expect(second[0]?.id).toBe("external-slow");
});
it("lets read-only manifest catalog reuse the current workspace-scoped snapshot", () => {
loadManifestModelCatalog({
config: {} as OpenClawConfig,

View File

@@ -69,6 +69,11 @@ let hasLoggedModelCatalogError = false;
let hasLoggedReadOnlyStaticCatalogError = false;
const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js");
let importPiSdk = defaultImportPiSdk;
type ManifestModelCatalogCacheEntry = {
snapshot: PluginMetadataSnapshot;
rows: ModelCatalogEntry[];
};
let manifestModelCatalogCache = new WeakMap<OpenClawConfig, ManifestModelCatalogCacheEntry>();
const modelSuppressionLoader = createLazyImportLoader(
() => import("./model-suppression.runtime.js"),
);
@@ -83,6 +88,7 @@ function loadModelSuppression() {
export function resetModelCatalogCache() {
modelCatalogPromise = null;
manifestModelCatalogCache = new WeakMap();
hasLoggedModelCatalogError = false;
hasLoggedReadOnlyStaticCatalogError = false;
}
@@ -203,6 +209,10 @@ export function loadManifestModelCatalog(params: {
if (!resolvedSnapshot) {
return [];
}
const cached = manifestModelCatalogCache.get(params.config);
if (cached?.snapshot === resolvedSnapshot) {
return cached.rows;
}
const eligiblePlugins = resolvedSnapshot.plugins.filter(
(plugin) =>
plugin.modelCatalog &&
@@ -215,7 +225,7 @@ export function loadManifestModelCatalog(params: {
const plan = planManifestModelCatalogRows({
registry: { plugins: eligiblePlugins },
});
return plan.rows.map((row) => {
const rows = plan.rows.map((row) => {
const entry: ModelCatalogEntry = {
id: row.id,
name: row.name,
@@ -239,6 +249,8 @@ export function loadManifestModelCatalog(params: {
}
return entry;
});
manifestModelCatalogCache.set(params.config, { snapshot: resolvedSnapshot, rows });
return rows;
}
function sortModelCatalogEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] {

View File

@@ -50,25 +50,6 @@ describe("resolveGatewayPluginConfig", () => {
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledTimes(2);
});
it("refreshes the cached config when env object changes", async () => {
const { resolveGatewayPluginConfig } = await import("./runtime-plugin-config.js");
const config = { channels: { telegram: { botToken: "token" } } } as OpenClawConfig;
const snapshot = { manifestRegistry: { plugins: [], diagnostics: [] } };
mocks.getCurrentPluginMetadataSnapshot.mockReturnValue(snapshot);
mocks.applyPluginAutoEnable
.mockReturnValueOnce({ config: { ...config, first: true }, changes: [] })
.mockReturnValueOnce({ config: { ...config, second: true }, changes: [] });
expect(resolveGatewayPluginConfig({ config, env: { A: "1" } })).toMatchObject({
first: true,
});
expect(resolveGatewayPluginConfig({ config, env: { A: "2" } })).toMatchObject({
second: true,
});
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledTimes(2);
});
it("does not cache without a current metadata snapshot", async () => {
const { resolveGatewayPluginConfig } = await import("./runtime-plugin-config.js");
const config = {} as OpenClawConfig;

View File

@@ -4,41 +4,33 @@ import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-meta
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
type CachedGatewayPluginConfig = {
env: NodeJS.ProcessEnv;
snapshot: PluginMetadataSnapshot;
config: OpenClawConfig;
};
const gatewayPluginConfigCache = new WeakMap<OpenClawConfig, CachedGatewayPluginConfig>();
export function resolveGatewayPluginConfig(params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): OpenClawConfig {
const env = params.env ?? process.env;
export function resolveGatewayPluginConfig(params: { config: OpenClawConfig }): OpenClawConfig {
const currentSnapshot = getCurrentPluginMetadataSnapshot({
config: params.config,
env,
allowWorkspaceScopedSnapshot: true,
});
if (!currentSnapshot) {
return applyPluginAutoEnable({
config: params.config,
env,
}).config;
}
const cached = gatewayPluginConfigCache.get(params.config);
if (cached?.snapshot === currentSnapshot && cached.env === env) {
if (cached?.snapshot === currentSnapshot) {
return cached.config;
}
const config = applyPluginAutoEnable({
config: params.config,
env,
manifestRegistry: currentSnapshot.manifestRegistry,
discovery: currentSnapshot.discovery,
}).config;
gatewayPluginConfigCache.set(params.config, { env, snapshot: currentSnapshot, config });
gatewayPluginConfigCache.set(params.config, { snapshot: currentSnapshot, config });
return config;
}

View File

@@ -117,7 +117,6 @@ describe("channelsHandlers channels.start", () => {
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
config: {},
env: process.env,
});
expect(startChannel).toHaveBeenCalledWith("whatsapp", "default-account");
expect(respond).toHaveBeenCalledWith(

View File

@@ -143,7 +143,6 @@ describe("channelsHandlers channels.status", () => {
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
config: {},
env: process.env,
});
const snapshotArgs = requireRecord(requireFirstCallArg(mocks.buildChannelAccountSnapshot));
expect(snapshotArgs.cfg).toBe(autoEnabledConfig);

View File

@@ -304,7 +304,6 @@ export const channelsHandlers: GatewayRequestHandlers = {
const runtimeConfig = context.getRuntimeConfig();
const cfg = resolveGatewayPluginConfig({
config: runtimeConfig,
env: process.env,
});
const runtime = context.getRuntimeSnapshot();
const plugins = listChannelPlugins();
@@ -583,7 +582,6 @@ export const channelsHandlers: GatewayRequestHandlers = {
const runtimeConfig = context.getRuntimeConfig();
const cfg = resolveGatewayPluginConfig({
config: runtimeConfig,
env: process.env,
});
const payload = await startChannelAccount({
channelId,

View File

@@ -652,7 +652,6 @@ describe("gateway send mirroring", () => {
expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({
config: {},
env: process.env,
});
expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledWith({
cfg: autoEnabledConfig,

View File

@@ -134,7 +134,6 @@ async function resolveRequestedChannel(params: {
const runtimeConfig = params.context.getRuntimeConfig();
const cfg = resolveGatewayPluginConfig({
config: runtimeConfig,
env: process.env,
});
let channel = normalizedChannel;
if (!channel) {

View File

@@ -841,7 +841,6 @@ export async function startGatewayServer(
const runtimeConfig = getRuntimeConfig();
return resolveGatewayPluginConfig({
config: runtimeConfig,
env: process.env,
});
},
channelLogs,