diff --git a/CHANGELOG.md b/CHANGELOG.md index d7aef465a30..d410f54bd78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index b8f096650c2..aee706ff1c3 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -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(({ 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(); + return { + ...actual, + loadPluginManifestRegistryForInstalledIndex: ( + params: Parameters[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(); + + 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 { + 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, + }; +} diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index d4b4667d69d..e83a318b2d2 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -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; + 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; catalogPricing: Map; } { - const registry = loadPluginManifestRegistryForPluginRegistry({ config }); const policies = new Map(); 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; refs: Map; + 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(); 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; + manifestRegistry?: PluginManifestRegistry; }): Promise { 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; + manifestRegistry?: PluginManifestRegistry; }): () => void { let stopped = false; queueMicrotask(() => { diff --git a/src/gateway/server-runtime-services.test.ts b/src/gateway/server-runtime-services.test.ts index 1466cfbbb91..cb69740a4b1 100644 --- a/src/gateway/server-runtime-services.test.ts +++ b/src/gateway/server-runtime-services.test.ts @@ -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) }; diff --git a/src/gateway/server-runtime-services.ts b/src/gateway/server-runtime-services.ts index 70cbe4c0773..8bb832772e6 100644 --- a/src/gateway/server-runtime-services.ts +++ b/src/gateway/server-runtime-services.ts @@ -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; }): { 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 } : {}), + }) : () => {}, }; } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index f78ff27c83b..401f0e4e1df 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -761,6 +761,7 @@ export async function startGatewayServer( cfgAtStart, channelManager, log, + pluginLookUpTable, }), );