From d84cbfa50ea53a73a6b4626dca1c4c1e57db4e31 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 27 May 2026 18:12:22 +0100 Subject: [PATCH] perf(gateway): cache manifest model catalog rows --- src/agents/model-catalog.test.ts | 116 +++++++++++++----- src/agents/model-catalog.ts | 14 ++- src/gateway/runtime-plugin-config.test.ts | 19 --- src/gateway/runtime-plugin-config.ts | 14 +-- .../server-methods/channels.start.test.ts | 1 - .../server-methods/channels.status.test.ts | 1 - src/gateway/server-methods/channels.ts | 2 - src/gateway/server-methods/send.test.ts | 1 - src/gateway/server-methods/send.ts | 1 - src/gateway/server.impl.ts | 1 - 10 files changed, 98 insertions(+), 72 deletions(-) diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 95d1ddd3a7a..af58098b3e5 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -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, diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index e225e0f29ec..fbd56950930 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -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(); 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[] { diff --git a/src/gateway/runtime-plugin-config.test.ts b/src/gateway/runtime-plugin-config.test.ts index ebdd3bc0e5f..c046d5813d1 100644 --- a/src/gateway/runtime-plugin-config.test.ts +++ b/src/gateway/runtime-plugin-config.test.ts @@ -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; diff --git a/src/gateway/runtime-plugin-config.ts b/src/gateway/runtime-plugin-config.ts index f9c8da614dc..9adecd4ff44 100644 --- a/src/gateway/runtime-plugin-config.ts +++ b/src/gateway/runtime-plugin-config.ts @@ -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(); -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; } diff --git a/src/gateway/server-methods/channels.start.test.ts b/src/gateway/server-methods/channels.start.test.ts index c30b18bf5da..125a8f77839 100644 --- a/src/gateway/server-methods/channels.start.test.ts +++ b/src/gateway/server-methods/channels.start.test.ts @@ -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( diff --git a/src/gateway/server-methods/channels.status.test.ts b/src/gateway/server-methods/channels.status.test.ts index 770290f8628..3acf9533073 100644 --- a/src/gateway/server-methods/channels.status.test.ts +++ b/src/gateway/server-methods/channels.status.test.ts @@ -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); diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index b0f9c790c79..6dfdafe9461 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -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, diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index f32126cfb07..17f63e318fb 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -652,7 +652,6 @@ describe("gateway send mirroring", () => { expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({ config: {}, - env: process.env, }); expect(mocks.resolveMessageChannelSelection).toHaveBeenCalledWith({ cfg: autoEnabledConfig, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 911161ad848..8e7fc5d5dad 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -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) { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 4f7960565e9..03fafb91c1f 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -841,7 +841,6 @@ export async function startGatewayServer( const runtimeConfig = getRuntimeConfig(); return resolveGatewayPluginConfig({ config: runtimeConfig, - env: process.env, }); }, channelLogs,