mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix: reuse plugin manifests for model pricing refresh
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -761,6 +761,7 @@ export async function startGatewayServer(
|
||||
cfgAtStart,
|
||||
channelManager,
|
||||
log,
|
||||
pluginLookUpTable,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user