fix: reuse plugin manifests for model pricing refresh

This commit is contained in:
Shakker
2026-04-27 10:19:55 +01:00
parent 3af34316f2
commit 7d9dc8cf24
6 changed files with 207 additions and 13 deletions

View File

@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
- Plugins/startup: use a `PluginLookUpTable` during Gateway startup so channel ownership, deferred channel loading, and startup plugin IDs reuse the same installed manifest registry instead of rebuilding manifest metadata on the boot path. Thanks @shakkernerd.
- Plugins/startup: pass the Gateway `PluginLookUpTable` through plugin loading so auto-enable checks and startup-scope fallback reuse the same manifest registry instead of doing another manifest pass. Thanks @shakkernerd.
- Plugins/startup: carry the Gateway `PluginLookUpTable` into deferred channel full-runtime reloads so post-listen startup does not rebuild manifest metadata after the provisional setup-runtime load. Thanks @shakkernerd.
- Gateway/models: reuse Gateway plugin manifest metadata during the initial model-pricing refresh so pricing policies and configured plugin web-search models do not rebuild plugin lookups during startup. Thanks @shakkernerd.
- Gateway/startup: extend `OPENCLAW_GATEWAY_STARTUP_TRACE=1` with per-phase event-loop delay plus plugin lookup-table timing and count metrics for installed-index, manifest, startup-plan, and owner-map work, and include the new timing fields in startup benchmark summaries. Thanks @shakkernerd.
- Plugins/channels: resolve read-only channel command defaults from one plugin index plus manifest pass instead of reloading plugin metadata while checking candidate plugin enablement. Thanks @shakkernerd.
- Plugins/capabilities: cache manifest-derived capability provider plugin IDs per config snapshot so repeated TTS, media, realtime, memory, image, video, and music provider resolution avoids redundant manifest scans. Thanks @shakkernerd.

View File

@@ -3,17 +3,38 @@ import { modelKey } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { loggingState } from "../logging/state.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
const normalizeProviderModelIdWithPluginMock = vi.hoisted(() =>
vi.fn<typeof normalizeProviderModelIdWithPlugin>(({ context }) => context.modelId),
);
const pluginManifestRegistryMocks = vi.hoisted(() => ({
manifestRegistry: undefined as PluginManifestRegistry | undefined,
loadPluginManifestRegistryForInstalledIndex: vi.fn(),
}));
vi.mock("../plugins/provider-runtime.js", () => {
return { normalizeProviderModelIdWithPlugin: normalizeProviderModelIdWithPluginMock };
});
vi.mock("../plugins/manifest-registry-installed.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../plugins/manifest-registry-installed.js")>();
return {
...actual,
loadPluginManifestRegistryForInstalledIndex: (
params: Parameters<typeof actual.loadPluginManifestRegistryForInstalledIndex>[0],
) => {
pluginManifestRegistryMocks.loadPluginManifestRegistryForInstalledIndex(params);
return (
pluginManifestRegistryMocks.manifestRegistry ??
actual.loadPluginManifestRegistryForInstalledIndex(params)
);
},
};
});
import {
__resetGatewayModelPricingCacheForTest,
collectConfiguredModelPricingRefs,
@@ -25,6 +46,8 @@ import {
describe("model-pricing-cache", () => {
beforeEach(() => {
__resetGatewayModelPricingCacheForTest();
pluginManifestRegistryMocks.manifestRegistry = undefined;
pluginManifestRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockClear();
});
afterEach(() => {
@@ -121,6 +144,48 @@ describe("model-pricing-cache", () => {
expect(refs).toContain("tavily/search-preview");
});
it("uses one installed manifest pass for pricing policies and configured web-search refs", async () => {
pluginManifestRegistryMocks.manifestRegistry = {
diagnostics: [],
plugins: [
createManifestRecord({
id: "search-plugin",
contracts: { webSearchProviders: ["search-plugin"] },
}),
],
};
const config = {
plugins: {
entries: {
"search-plugin": {
config: {
webSearch: {
model: "local-search/search-model",
},
},
},
},
},
models: {
providers: {
"local-search": {
baseUrl: "http://127.0.0.1:43210/v1",
api: "openai-completions",
models: [{ id: "search-model" }],
},
},
},
} as unknown as OpenClawConfig;
const fetchImpl = vi.fn<typeof fetch>();
await refreshGatewayModelPricingCache({ config, fetchImpl });
expect(
pluginManifestRegistryMocks.loadPluginManifestRegistryForInstalledIndex,
).toHaveBeenCalledOnce();
expect(fetchImpl).not.toHaveBeenCalled();
});
it("skips remote pricing catalogs for local-only model providers", async () => {
const config = {
agents: {
@@ -717,3 +782,19 @@ describe("model-pricing-cache", () => {
});
});
});
function createManifestRecord(overrides: Partial<PluginManifestRecord>): PluginManifestRecord {
return {
id: "plugin",
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
origin: "global",
rootDir: "/tmp/plugin",
source: "/tmp/plugin/index.js",
manifestPath: "/tmp/plugin/openclaw.plugin.json",
...overrides,
};
}

View File

@@ -12,14 +12,18 @@ import type { ModelDefinitionConfig } from "../config/types.models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { planManifestModelCatalogRows, type ModelCatalogCost } from "../model-catalog/index.js";
import { isInstalledPluginEnabled } from "../plugins/installed-plugin-index.js";
import { loadPluginManifestRegistryForInstalledIndex } from "../plugins/manifest-registry-installed.js";
import type { PluginManifestRegistry } from "../plugins/manifest-registry.js";
import type {
PluginManifestModelPricingModelIdTransform,
PluginManifestModelPricingProvider,
PluginManifestModelPricingSource,
} from "../plugins/manifest.js";
import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js";
import {
loadPluginManifestRegistryForPluginRegistry,
resolveManifestContractPluginIds,
loadPluginRegistrySnapshot,
type PluginRegistrySnapshot,
} from "../plugins/plugin-registry.js";
import { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js";
import { normalizeOptionalString, resolvePrimaryStringValue } from "../shared/string-coerce.js";
@@ -39,6 +43,11 @@ type OpenRouterPricingEntry = {
type ModelListLike = string | { primary?: string; fallbacks?: string[] } | undefined;
type ModelPricingManifestMetadata = {
allRegistry: PluginManifestRegistry;
activeRegistry: PluginManifestRegistry;
};
type OpenRouterModelPayload = {
id?: unknown;
pricing?: unknown;
@@ -361,11 +370,60 @@ function normalizeExternalPricingPolicy(
};
}
function loadManifestPricingContext(config: OpenClawConfig): {
function filterActiveManifestRegistry(params: {
registry: PluginManifestRegistry;
index: PluginRegistrySnapshot;
config: OpenClawConfig;
}): PluginManifestRegistry {
return {
diagnostics: params.registry.diagnostics,
plugins: params.registry.plugins.filter((plugin) =>
isInstalledPluginEnabled(params.index, plugin.id, params.config),
),
};
}
function resolveModelPricingManifestMetadata(params: {
config: OpenClawConfig;
pluginLookUpTable?: Pick<PluginLookUpTable, "index" | "manifestRegistry">;
manifestRegistry?: PluginManifestRegistry;
}): ModelPricingManifestMetadata {
if (params.pluginLookUpTable) {
return {
allRegistry: params.pluginLookUpTable.manifestRegistry,
activeRegistry: filterActiveManifestRegistry({
registry: params.pluginLookUpTable.manifestRegistry,
index: params.pluginLookUpTable.index,
config: params.config,
}),
};
}
if (params.manifestRegistry) {
return {
allRegistry: params.manifestRegistry,
activeRegistry: params.manifestRegistry,
};
}
const index = loadPluginRegistrySnapshot({ config: params.config });
const allRegistry = loadPluginManifestRegistryForInstalledIndex({
index,
config: params.config,
includeDisabled: true,
});
return {
allRegistry,
activeRegistry: filterActiveManifestRegistry({
registry: allRegistry,
index,
config: params.config,
}),
};
}
function loadManifestPricingContext(registry: PluginManifestRegistry): {
policies: Map<string, ExternalPricingPolicy>;
catalogPricing: Map<string, CachedModelPricing>;
} {
const registry = loadPluginManifestRegistryForPluginRegistry({ config });
const policies = new Map<string, ExternalPricingPolicy>();
for (const plugin of registry.plugins) {
for (const [provider, rawPolicy] of Object.entries(plugin.modelPricing?.providers ?? {})) {
@@ -549,11 +607,12 @@ function addConfiguredWebSearchPluginModels(params: {
config: OpenClawConfig;
aliasIndex: ReturnType<typeof buildModelAliasIndex>;
refs: Map<string, ModelRef>;
manifestRegistry: PluginManifestRegistry;
}): void {
for (const pluginId of resolveManifestContractPluginIds({
contract: "webSearchProviders",
config: params.config,
})) {
for (const pluginId of params.manifestRegistry.plugins
.filter((plugin) => (plugin.contracts?.webSearchProviders ?? []).length > 0)
.map((plugin) => plugin.id)
.toSorted((left, right) => left.localeCompare(right))) {
addResolvedModelRef({
raw: resolvePluginWebSearchConfig(params.config, pluginId)?.model as string | undefined,
aliasIndex: params.aliasIndex,
@@ -659,7 +718,12 @@ function filterExternalPricingRefs(params: {
);
}
export function collectConfiguredModelPricingRefs(config: OpenClawConfig): ModelRef[] {
export function collectConfiguredModelPricingRefs(
config: OpenClawConfig,
options: { manifestRegistry?: PluginManifestRegistry } = {},
): ModelRef[] {
const manifestRegistry =
options.manifestRegistry ?? resolveModelPricingManifestMetadata({ config }).allRegistry;
const refs = new Map<string, ModelRef>();
const aliasIndex = buildModelAliasIndex({
cfg: config,
@@ -698,7 +762,7 @@ export function collectConfiguredModelPricingRefs(config: OpenClawConfig): Model
}
}
addConfiguredWebSearchPluginModels({ config, aliasIndex, refs });
addConfiguredWebSearchPluginModels({ config, aliasIndex, refs, manifestRegistry });
for (const entry of config.tools?.media?.models ?? []) {
addProviderModelPair({ provider: entry.provider, model: entry.model, refs });
@@ -823,14 +887,23 @@ function collectSeededPricing(params: {
export async function refreshGatewayModelPricingCache(params: {
config: OpenClawConfig;
fetchImpl?: typeof fetch;
pluginLookUpTable?: Pick<PluginLookUpTable, "index" | "manifestRegistry">;
manifestRegistry?: PluginManifestRegistry;
}): Promise<void> {
if (inFlightRefresh) {
return await inFlightRefresh;
}
const fetchImpl = params.fetchImpl ?? fetch;
inFlightRefresh = (async () => {
const pricingContext = loadManifestPricingContext(params.config);
const allRefs = collectConfiguredModelPricingRefs(params.config);
const manifestMetadata = resolveModelPricingManifestMetadata({
config: params.config,
pluginLookUpTable: params.pluginLookUpTable,
manifestRegistry: params.manifestRegistry,
});
const pricingContext = loadManifestPricingContext(manifestMetadata.activeRegistry);
const allRefs = collectConfiguredModelPricingRefs(params.config, {
manifestRegistry: manifestMetadata.allRegistry,
});
const seededPricing = collectSeededPricing({
config: params.config,
refs: allRefs,
@@ -950,6 +1023,8 @@ export async function refreshGatewayModelPricingCache(params: {
export function startGatewayModelPricingRefresh(params: {
config: OpenClawConfig;
fetchImpl?: typeof fetch;
pluginLookUpTable?: Pick<PluginLookUpTable, "index" | "manifestRegistry">;
manifestRegistry?: PluginManifestRegistry;
}): () => void {
let stopped = false;
queueMicrotask(() => {

View File

@@ -10,6 +10,7 @@ const hoisted = vi.hoisted(() => {
startHeartbeatRunner: vi.fn(() => heartbeatRunner),
startChannelHealthMonitor: vi.fn(() => ({ stop: vi.fn() })),
startGatewayModelPricingRefresh: vi.fn(() => vi.fn()),
isVitestRuntimeEnv: vi.fn(() => false),
recoverPendingDeliveries: vi.fn(async () => undefined),
recoverPendingRestartContinuationDeliveries: vi.fn(async () => undefined),
deliverOutboundPayloads: vi.fn(),
@@ -20,6 +21,10 @@ vi.mock("../infra/heartbeat-runner.js", () => ({
startHeartbeatRunner: hoisted.startHeartbeatRunner,
}));
vi.mock("../infra/env.js", () => ({
isVitestRuntimeEnv: hoisted.isVitestRuntimeEnv,
}));
vi.mock("../infra/outbound/deliver.js", () => ({
deliverOutboundPayloads: hoisted.deliverOutboundPayloads,
}));
@@ -51,6 +56,7 @@ describe("server-runtime-services", () => {
hoisted.startHeartbeatRunner.mockClear();
hoisted.startChannelHealthMonitor.mockClear();
hoisted.startGatewayModelPricingRefresh.mockClear();
hoisted.isVitestRuntimeEnv.mockReset().mockReturnValue(false);
hoisted.recoverPendingDeliveries.mockClear();
hoisted.recoverPendingRestartContinuationDeliveries.mockClear();
hoisted.deliverOutboundPayloads.mockClear();
@@ -69,6 +75,7 @@ describe("server-runtime-services", () => {
});
expect(hoisted.startChannelHealthMonitor).toHaveBeenCalledTimes(1);
expect(hoisted.startGatewayModelPricingRefresh).toHaveBeenCalledWith({ config: {} });
expect(hoisted.startHeartbeatRunner).not.toHaveBeenCalled();
expect(hoisted.recoverPendingDeliveries).not.toHaveBeenCalled();
@@ -76,6 +83,30 @@ describe("server-runtime-services", () => {
expect(hoisted.heartbeatRunner.stop).not.toHaveBeenCalled();
});
it("passes startup plugin lookup metadata to the initial pricing refresh", () => {
const pluginLookUpTable = {
index: { plugins: [] },
manifestRegistry: { plugins: [], diagnostics: [] },
};
startGatewayRuntimeServices({
minimalTestGateway: false,
cfgAtStart: {} as never,
channelManager: {
getRuntimeSnapshot: vi.fn(),
isHealthMonitorEnabled: vi.fn(),
isManuallyStopped: vi.fn(),
} as never,
log: createLog(),
pluginLookUpTable: pluginLookUpTable as never,
});
expect(hoisted.startGatewayModelPricingRefresh).toHaveBeenCalledWith({
config: {},
pluginLookUpTable,
});
});
it("activates heartbeat, cron, and delivery recovery after sidecars are ready", async () => {
vi.useFakeTimers();
const cron = { start: vi.fn(async () => undefined) };

View File

@@ -1,6 +1,7 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isVitestRuntimeEnv } from "../infra/env.js";
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
import type { PluginLookUpTable } from "../plugins/plugin-lookup-table.js";
import type { ChannelHealthMonitor } from "./channel-health-monitor.js";
import { startChannelHealthMonitor } from "./channel-health-monitor.js";
import { startGatewayModelPricingRefresh } from "./model-pricing-cache.js";
@@ -93,6 +94,7 @@ export function startGatewayRuntimeServices(params: {
cfgAtStart: OpenClawConfig;
channelManager: GatewayChannelManager;
log: GatewayRuntimeServiceLogger;
pluginLookUpTable?: Pick<PluginLookUpTable, "index" | "manifestRegistry">;
}): {
heartbeatRunner: HeartbeatRunner;
channelHealthMonitor: ChannelHealthMonitor | null;
@@ -108,7 +110,10 @@ export function startGatewayRuntimeServices(params: {
channelHealthMonitor,
stopModelPricingRefresh:
!params.minimalTestGateway && !isVitestRuntimeEnv()
? startGatewayModelPricingRefresh({ config: params.cfgAtStart })
? startGatewayModelPricingRefresh({
config: params.cfgAtStart,
...(params.pluginLookUpTable ? { pluginLookUpTable: params.pluginLookUpTable } : {}),
})
: () => {},
};
}

View File

@@ -761,6 +761,7 @@ export async function startGatewayServer(
cfgAtStart,
channelManager,
log,
pluginLookUpTable,
}),
);