fix(plugins): reuse workspace metadata for session model refs (#76655)

Fixes the session-list plugin metadata hot path by reusing the active workspace-scoped plugin metadata snapshot for manifest model-id normalization and setup CLI backend fallback. Also keeps model-pricing collection on its provided manifest registry and fixes the CI-only hoisted test mock failure.

Validated with targeted plugin/gateway/model-selection tests, CI-shaped gateway startup shard, Testbox `pnpm check:changed`, and green PR CI.

Thanks @rolandrscheel.
This commit is contained in:
rolandrscheel
2026-05-03 16:58:14 +02:00
committed by GitHub
parent 935f078a89
commit aeac10f5ce
12 changed files with 620 additions and 219 deletions

View File

@@ -89,6 +89,7 @@ Docs: https://docs.openclaw.ai
- Plugins/install: require OpenClaw-owned install provenance before granting official npm plugin scanner trust, so direct npm package names no longer bypass launch-code scanning while catalog, onboarding, and doctor installs stay trusted. Thanks @fede-kamel and @vincentkoc.
- Network proxy: preserve target TLS hostname validation for Node HTTPS requests routed through the managed HTTP proxy, so Discord-style CONNECT traffic no longer validates certificates against the local proxy host. Fixes #74809. (#76442) Thanks @jesse-merhi and @abnershang.
- Gateway/sessions: keep async `sessions.list` title and preview hydration bounded to transcript head/tail reads so Control UI polling cannot full-scan large session transcripts every refresh. Thanks @vincentkoc.
- Gateway/sessions: cache manifest model-id normalization and bundled setup CLI fallback metadata against the active plugin metadata snapshot, so Control UI `sessions.list` polling avoids repeated plugin manifest scans while still refreshing after plugin reloads. Thanks @rolandrscheel.
- Gateway/performance: cache per-run verbose-level session reads, skip a redundant `lsof` scan in `gateway --force` when no listener was killed, and make the Gateway startup benchmark print usage for `--help`.
- Gateway/sessions: keep agent runtime metadata on lightweight `sessions.list` rows so model-only session patches do not make Control UI lose runtime identity. Thanks @vincentkoc.
- Gateway/sessions: keep bulk `sessions.list` rows lightweight by skipping per-row transcript usage fallback, display model inference, and plugin projection, avoiding event-loop stalls in large session stores. Thanks @Marvinthebored and @vincentkoc.

View File

@@ -1,4 +1,5 @@
import { normalizeProviderModelIdWithManifest } from "../plugins/manifest-model-id-normalization.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { normalizeProviderId } from "./provider-id.js";
@@ -26,7 +27,10 @@ export function modelKey(provider: string, model: string): string {
export function normalizeStaticProviderModelId(
provider: string,
model: string,
options: { allowManifestNormalization?: boolean } = {},
options: {
allowManifestNormalization?: boolean;
manifestPlugins?: readonly Pick<PluginManifestRecord, "modelIdNormalization">[];
} = {},
): string {
if (options.allowManifestNormalization === false) {
return model;
@@ -34,6 +38,7 @@ export function normalizeStaticProviderModelId(
return (
normalizeProviderModelIdWithManifest({
provider,
plugins: options.manifestPlugins,
context: {
provider,
modelId: model,

View File

@@ -13,7 +13,7 @@ export function isCliProvider(provider: string, cfg?: OpenClawConfig): boolean {
if (cliBackends.some((backend) => normalizeProviderId(backend.id) === normalized)) {
return true;
}
if (resolvePluginSetupCliBackendRuntime({ backend: normalized })) {
if (resolvePluginSetupCliBackendRuntime({ backend: normalized, config: cfg })) {
return true;
}
return false;

View File

@@ -1,3 +1,4 @@
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { modelKey as sharedModelKey, normalizeStaticProviderModelId } from "./model-ref-shared.js";
import {
@@ -38,10 +39,15 @@ export {
function normalizeProviderModelId(
provider: string,
model: string,
options?: { allowManifestNormalization?: boolean; allowPluginNormalization?: boolean },
options?: {
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
manifestPlugins?: readonly Pick<PluginManifestRecord, "modelIdNormalization">[];
},
): string {
const staticModelId = normalizeStaticProviderModelId(provider, model, {
allowManifestNormalization: options?.allowManifestNormalization,
manifestPlugins: options?.manifestPlugins,
});
if (options?.allowPluginNormalization === false) {
return staticModelId;
@@ -60,6 +66,7 @@ function normalizeProviderModelId(
type ModelRefNormalizeOptions = {
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
manifestPlugins?: readonly Pick<PluginManifestRecord, "modelIdNormalization">[];
};
export function normalizeModelRef(

View File

@@ -1,6 +1,7 @@
import { resolveAgentModelPrimaryValue } from "../config/model-input.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -35,6 +36,10 @@ export type ModelAliasIndex = {
byKey: Map<string, string[]>;
};
type ManifestNormalizationContext = {
manifestPlugins?: readonly Pick<PluginManifestRecord, "modelIdNormalization">[];
};
function sanitizeModelWarningValue(value: string): string {
const stripped = value ? stripAnsi(value) : "";
let controlBoundary = -1;
@@ -179,12 +184,14 @@ function isConcreteOpenRouterFreeModelRef(ref: ModelRef): boolean {
return ref.provider === "openrouter" && ref.model.includes("/") && ref.model.endsWith(":free");
}
function resolveConfiguredOpenRouterCompatFreeRef(params: {
cfg: OpenClawConfig;
defaultProvider: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
}): ModelRef | null {
function resolveConfiguredOpenRouterCompatFreeRef(
params: {
cfg: OpenClawConfig;
defaultProvider: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
} & ManifestNormalizationContext,
): ModelRef | null {
const configuredModels = params.cfg.agents?.defaults?.models ?? {};
for (const raw of Object.keys(configuredModels)) {
if (!raw.includes("/")) {
@@ -193,6 +200,7 @@ function resolveConfiguredOpenRouterCompatFreeRef(params: {
const parsed = parseModelRef(raw, params.defaultProvider, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
if (parsed && isConcreteOpenRouterFreeModelRef(parsed)) {
return parsed;
@@ -211,24 +219,28 @@ function resolveConfiguredOpenRouterCompatFreeRef(params: {
return normalizeModelRef("openrouter", modelId, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
}
return null;
}
export function resolveConfiguredOpenRouterCompatAlias(params: {
cfg?: OpenClawConfig;
raw: string;
defaultProvider: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
}): ModelRef | null {
export function resolveConfiguredOpenRouterCompatAlias(
params: {
cfg?: OpenClawConfig;
raw: string;
defaultProvider: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
} & ManifestNormalizationContext,
): ModelRef | null {
const normalized = normalizeLowercaseStringOrEmpty(params.raw);
if (normalized === "openrouter:auto") {
return normalizeModelRef("openrouter", "auto", {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
}
if (normalized !== OPENROUTER_COMPAT_FREE_ALIAS || !params.cfg) {
@@ -239,32 +251,38 @@ export function resolveConfiguredOpenRouterCompatAlias(params: {
defaultProvider: params.defaultProvider,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
}
function parseModelRefWithCompatAlias(params: {
cfg?: OpenClawConfig;
raw: string;
defaultProvider: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
}): ModelRef | null {
function parseModelRefWithCompatAlias(
params: {
cfg?: OpenClawConfig;
raw: string;
defaultProvider: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
} & ManifestNormalizationContext,
): ModelRef | null {
return (
resolveConfiguredOpenRouterCompatAlias(params) ??
resolveExactConfiguredProviderRef(params) ??
parseModelRef(params.raw, params.defaultProvider, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
})
);
}
function resolveExactConfiguredProviderRef(params: {
cfg?: OpenClawConfig;
raw: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
}): ModelRef | null {
function resolveExactConfiguredProviderRef(
params: {
cfg?: OpenClawConfig;
raw: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
} & ManifestNormalizationContext,
): ModelRef | null {
const slash = params.raw.indexOf("/");
if (slash <= 0 || !params.cfg?.models?.providers) {
return null;
@@ -293,6 +311,7 @@ function resolveExactConfiguredProviderRef(params: {
provider,
model: normalizeStaticProviderModelId(provider, modelRaw.trim(), {
allowManifestNormalization: params.allowManifestNormalization,
manifestPlugins: params.manifestPlugins,
}),
};
}
@@ -336,12 +355,14 @@ export function buildConfiguredAllowlistKeys(params: {
return keys.size > 0 ? keys : null;
}
export function buildModelAliasIndex(params: {
cfg: OpenClawConfig;
defaultProvider: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
}): ModelAliasIndex {
export function buildModelAliasIndex(
params: {
cfg: OpenClawConfig;
defaultProvider: string;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
} & ManifestNormalizationContext,
): ModelAliasIndex {
const byAlias = new Map<string, { alias: string; ref: ModelRef }>();
const byKey = new Map<string, string[]>();
@@ -353,6 +374,7 @@ export function buildModelAliasIndex(params: {
defaultProvider: params.defaultProvider,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
if (!parsed) {
continue;
@@ -459,14 +481,16 @@ function buildSyntheticAllowedCatalogEntry(params: {
};
}
export function resolveModelRefFromString(params: {
cfg?: OpenClawConfig;
raw: string;
defaultProvider: string;
aliasIndex?: ModelAliasIndex;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
}): { ref: ModelRef; alias?: string } | null {
export function resolveModelRefFromString(
params: {
cfg?: OpenClawConfig;
raw: string;
defaultProvider: string;
aliasIndex?: ModelAliasIndex;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
} & ManifestNormalizationContext,
): { ref: ModelRef; alias?: string } | null {
const { model } = splitTrailingAuthProfile(params.raw);
if (!model) {
return null;
@@ -482,6 +506,7 @@ export function resolveModelRefFromString(params: {
defaultProvider: params.defaultProvider,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
if (!parsed) {
return null;

View File

@@ -78,6 +78,7 @@ export { getCachedGatewayModelPricing };
type PricingModelNormalizationOptions = {
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
};
const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
@@ -99,13 +100,15 @@ function clearRefreshTimer(): void {
refreshTimer = null;
}
function getPricingModelNormalizationOptions(
config: OpenClawConfig,
): PricingModelNormalizationOptions {
const allowPluginBackedNormalization = config.plugins?.enabled !== false;
function getPricingModelNormalizationOptions(params: {
config: OpenClawConfig;
manifestRegistry?: PluginManifestRegistry;
}): PricingModelNormalizationOptions {
const allowPluginBackedNormalization = params.config.plugins?.enabled !== false;
return {
allowManifestNormalization: allowPluginBackedNormalization,
allowPluginNormalization: allowPluginBackedNormalization,
...(params.manifestRegistry ? { manifestPlugins: params.manifestRegistry.plugins } : {}),
};
}
@@ -371,13 +374,14 @@ async function fetchLiteLLMPricingCatalog(
function normalizeExternalPricingSource(
value: PluginManifestModelPricingSource | false | undefined,
options: PricingModelNormalizationOptions,
): ExternalPricingSourcePolicy | undefined {
if (!value) {
return undefined;
}
return {
...(value.provider
? { provider: normalizeModelRef(value.provider, "placeholder").provider }
? { provider: normalizeModelRef(value.provider, "placeholder", options).provider }
: {}),
...(value.passthroughProviderModel ? { passthroughProviderModel: true } : {}),
modelIdTransforms: value.modelIdTransforms ?? [],
@@ -386,17 +390,18 @@ function normalizeExternalPricingSource(
function normalizeExternalPricingPolicy(
value: PluginManifestModelPricingProvider | undefined,
options: PricingModelNormalizationOptions,
): ExternalPricingPolicy | undefined {
if (!value) {
return undefined;
}
return {
external: value.external !== false,
...(normalizeExternalPricingSource(value.openRouter) !== undefined
? { openRouter: normalizeExternalPricingSource(value.openRouter) }
...(normalizeExternalPricingSource(value.openRouter, options) !== undefined
? { openRouter: normalizeExternalPricingSource(value.openRouter, options) }
: {}),
...(normalizeExternalPricingSource(value.liteLLM) !== undefined
? { liteLLM: normalizeExternalPricingSource(value.liteLLM) }
...(normalizeExternalPricingSource(value.liteLLM, options) !== undefined
? { liteLLM: normalizeExternalPricingSource(value.liteLLM, options) }
: {}),
};
}
@@ -461,14 +466,17 @@ function resolveModelPricingManifestMetadata(params: {
};
}
function loadManifestPricingContext(registry: PluginManifestRegistry): {
function loadManifestPricingContext(
registry: PluginManifestRegistry,
normalizationOptions: PricingModelNormalizationOptions,
): {
policies: Map<string, ExternalPricingPolicy>;
catalogPricing: Map<string, CachedModelPricing>;
} {
const policies = new Map<string, ExternalPricingPolicy>();
for (const plugin of registry.plugins) {
for (const [provider, rawPolicy] of Object.entries(plugin.modelPricing?.providers ?? {})) {
const policy = normalizeExternalPricingPolicy(rawPolicy);
const policy = normalizeExternalPricingPolicy(rawPolicy, normalizationOptions);
if (policy) {
policies.set(provider, policy);
}
@@ -531,6 +539,7 @@ function canonicalizeOpenRouterLookupId(
const provider = normalizeModelRef(trimmed.slice(0, slash), "placeholder", {
allowManifestNormalization: options.allowManifestNormalization,
allowPluginNormalization: options.allowPluginNormalization,
manifestPlugins: options.manifestPlugins,
}).provider;
const model = trimmed.slice(slash + 1).trim();
if (!model) {
@@ -539,6 +548,7 @@ function canonicalizeOpenRouterLookupId(
const normalizedModel = normalizeModelRef(provider, model, {
allowManifestNormalization: options.allowManifestNormalization,
allowPluginNormalization: options.allowPluginNormalization,
manifestPlugins: options.manifestPlugins,
}).model;
return modelKey(provider, normalizedModel);
}
@@ -550,6 +560,7 @@ function buildExternalCatalogCandidates(params: {
seen?: Set<string>;
allowManifestNormalization?: boolean;
allowPluginNormalization?: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): string[] {
const { ref, source, policies } = params;
const refKey = modelKey(ref.provider, ref.model);
@@ -582,6 +593,7 @@ function buildExternalCatalogCandidates(params: {
? canonicalizeOpenRouterLookupId(candidate, {
allowManifestNormalization: params.allowManifestNormalization ?? true,
allowPluginNormalization: params.allowPluginNormalization ?? true,
manifestPlugins: params.manifestPlugins,
})
: candidate,
);
@@ -591,6 +603,7 @@ function buildExternalCatalogCandidates(params: {
const nestedRef = parseModelRef(ref.model, DEFAULT_PROVIDER, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
if (nestedRef) {
for (const candidate of buildExternalCatalogCandidates({
@@ -600,6 +613,7 @@ function buildExternalCatalogCandidates(params: {
seen: nextSeen,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
})) {
candidates.add(candidate);
}
@@ -615,6 +629,7 @@ function addResolvedModelRef(params: {
refs: Map<string, ModelRef>;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): void {
const raw = params.raw?.trim();
if (!raw) {
@@ -626,6 +641,7 @@ function addResolvedModelRef(params: {
aliasIndex: params.aliasIndex,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
if (!resolved) {
return;
@@ -633,6 +649,7 @@ function addResolvedModelRef(params: {
const normalized = normalizeModelRef(resolved.ref.provider, resolved.ref.model, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
params.refs.set(modelKey(normalized.provider, normalized.model), normalized);
}
@@ -643,6 +660,7 @@ function addModelListLike(params: {
refs: Map<string, ModelRef>;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): void {
addResolvedModelRef({
raw: resolvePrimaryStringValue(params.value),
@@ -650,6 +668,7 @@ function addModelListLike(params: {
refs: params.refs,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
for (const fallback of listLikeFallbacks(params.value)) {
addResolvedModelRef({
@@ -658,6 +677,7 @@ function addModelListLike(params: {
refs: params.refs,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
}
}
@@ -668,6 +688,7 @@ function addProviderModelPair(params: {
refs: Map<string, ModelRef>;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): void {
const provider = params.provider?.trim();
const model = params.model?.trim();
@@ -677,6 +698,7 @@ function addProviderModelPair(params: {
const normalized = normalizeModelRef(provider, model, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
params.refs.set(modelKey(normalized.provider, normalized.model), normalized);
}
@@ -688,6 +710,7 @@ function addConfiguredWebSearchPluginModels(params: {
manifestRegistry: PluginManifestRegistry;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): void {
for (const pluginId of params.manifestRegistry.plugins
.filter((plugin) => (plugin.contracts?.webSearchProviders ?? []).length > 0)
@@ -699,6 +722,7 @@ function addConfiguredWebSearchPluginModels(params: {
refs: params.refs,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
}
}
@@ -752,6 +776,7 @@ function findConfiguredProviderModel(
const normalized = normalizeModelRef(ref.provider, model.id, {
allowManifestNormalization: options.allowManifestNormalization,
allowPluginNormalization: options.allowPluginNormalization,
manifestPlugins: options.manifestPlugins,
});
return modelKey(normalized.provider, normalized.model) === modelKey(ref.provider, ref.model);
});
@@ -791,6 +816,7 @@ function shouldFetchExternalPricingForRef(params: {
seededPricing: ReadonlyMap<string, CachedModelPricing>;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): boolean {
if (params.seededPricing.has(modelKey(params.ref.provider, params.ref.model))) {
return false;
@@ -799,6 +825,7 @@ function shouldFetchExternalPricingForRef(params: {
hasPrivateOrLoopbackConfiguredEndpoint(params.config, params.ref, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
})
) {
return false;
@@ -816,6 +843,7 @@ function filterExternalPricingRefs(params: {
seededPricing: ReadonlyMap<string, CachedModelPricing>;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): ModelRef[] {
return params.refs.filter((ref) =>
shouldFetchExternalPricingForRef({
@@ -825,6 +853,7 @@ function filterExternalPricingRefs(params: {
seededPricing: params.seededPricing,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
}),
);
}
@@ -835,70 +864,71 @@ export function collectConfiguredModelPricingRefs(
): ModelRef[] {
const manifestRegistry =
options.manifestRegistry ?? resolveModelPricingManifestMetadata({ config }).allRegistry;
const normalizationOptions = getPricingModelNormalizationOptions(config);
const normalizationOptions = getPricingModelNormalizationOptions({
config,
manifestRegistry,
});
const refs = new Map<string, ModelRef>();
const normalizationParams = {
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...(normalizationOptions.manifestPlugins
? { manifestPlugins: normalizationOptions.manifestPlugins }
: {}),
};
const aliasIndex = buildModelAliasIndex({
cfg: config,
defaultProvider: DEFAULT_PROVIDER,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addModelListLike({
value: config.agents?.defaults?.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addModelListLike({
value: config.agents?.defaults?.imageModel,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addModelListLike({
value: config.agents?.defaults?.pdfModel,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addResolvedModelRef({
raw: config.agents?.defaults?.compaction?.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addResolvedModelRef({
raw: config.agents?.defaults?.heartbeat?.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addModelListLike({
value: config.tools?.subagents?.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addResolvedModelRef({
raw: config.messages?.tts?.summaryModel,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addResolvedModelRef({
raw: config.hooks?.gmail?.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
for (const agent of config.agents?.list ?? []) {
@@ -906,22 +936,19 @@ export function collectConfiguredModelPricingRefs(
value: agent.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addModelListLike({
value: agent.subagents?.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
addResolvedModelRef({
raw: agent.heartbeat?.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
}
@@ -930,8 +957,7 @@ export function collectConfiguredModelPricingRefs(
raw: mapping.model,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
}
@@ -944,8 +970,7 @@ export function collectConfiguredModelPricingRefs(
raw: typeof raw === "string" ? raw : undefined,
aliasIndex,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
}
}
@@ -955,8 +980,7 @@ export function collectConfiguredModelPricingRefs(
aliasIndex,
refs,
manifestRegistry,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
for (const entry of config.tools?.media?.models ?? []) {
@@ -964,8 +988,7 @@ export function collectConfiguredModelPricingRefs(
provider: entry.provider,
model: entry.model,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
}
for (const entry of config.tools?.media?.image?.models ?? []) {
@@ -973,8 +996,7 @@ export function collectConfiguredModelPricingRefs(
provider: entry.provider,
model: entry.model,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
}
for (const entry of config.tools?.media?.audio?.models ?? []) {
@@ -982,8 +1004,7 @@ export function collectConfiguredModelPricingRefs(
provider: entry.provider,
model: entry.model,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
}
for (const entry of config.tools?.media?.video?.models ?? []) {
@@ -991,8 +1012,7 @@ export function collectConfiguredModelPricingRefs(
provider: entry.provider,
model: entry.model,
refs,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
}
@@ -1032,6 +1052,7 @@ function resolveCatalogPricingForRef(params: {
catalogByNormalizedId: Map<string, OpenRouterPricingEntry>;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): CachedModelPricing | undefined {
const candidates = buildExternalCatalogCandidates({
ref: params.ref,
@@ -1039,6 +1060,7 @@ function resolveCatalogPricingForRef(params: {
policies: params.policies,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
for (const candidate of candidates) {
const exact = params.catalogById.get(candidate);
@@ -1050,6 +1072,7 @@ function resolveCatalogPricingForRef(params: {
const normalized = canonicalizeOpenRouterLookupId(candidate, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
if (!normalized) {
continue;
@@ -1068,6 +1091,7 @@ function resolveLiteLLMPricingForRef(params: {
catalog: LiteLLMPricingCatalog;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): CachedModelPricing | undefined {
for (const candidate of buildExternalCatalogCandidates({
ref: params.ref,
@@ -1075,6 +1099,7 @@ function resolveLiteLLMPricingForRef(params: {
policies: params.policies,
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
})) {
const pricing = params.catalog.get(candidate);
if (pricing) {
@@ -1109,6 +1134,7 @@ function collectSeededPricing(params: {
catalogPricing: ReadonlyMap<string, CachedModelPricing>;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
manifestPlugins?: PluginManifestRegistry["plugins"];
}): Map<string, CachedModelPricing> {
const seeded = new Map<string, CachedModelPricing>();
for (const ref of params.refs) {
@@ -1116,6 +1142,7 @@ function collectSeededPricing(params: {
const configuredPricing = getConfiguredModelPricing(params.config, ref, {
allowManifestNormalization: params.allowManifestNormalization,
allowPluginNormalization: params.allowPluginNormalization,
manifestPlugins: params.manifestPlugins,
});
if (configuredPricing) {
seeded.set(key, configuredPricing);
@@ -1152,8 +1179,21 @@ export async function refreshGatewayModelPricingCache(
pluginLookUpTable: params.pluginLookUpTable,
manifestRegistry: params.manifestRegistry,
});
const normalizationOptions = getPricingModelNormalizationOptions(params.config);
const pricingContext = loadManifestPricingContext(manifestMetadata.activeRegistry);
const normalizationOptions = getPricingModelNormalizationOptions({
config: params.config,
manifestRegistry: manifestMetadata.allRegistry,
});
const normalizationParams = {
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...(normalizationOptions.manifestPlugins
? { manifestPlugins: normalizationOptions.manifestPlugins }
: {}),
};
const pricingContext = loadManifestPricingContext(
manifestMetadata.activeRegistry,
normalizationOptions,
);
const allRefs = collectConfiguredModelPricingRefs(params.config, {
manifestRegistry: manifestMetadata.allRegistry,
});
@@ -1161,16 +1201,14 @@ export async function refreshGatewayModelPricingCache(
config: params.config,
refs: allRefs,
catalogPricing: pricingContext.catalogPricing,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
const refs = filterExternalPricingRefs({
config: params.config,
refs: allRefs,
policies: pricingContext.policies,
seededPricing,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
if (refs.length === 0) {
if (params.signal?.aborted) {
@@ -1219,8 +1257,7 @@ export async function refreshGatewayModelPricingCache(
policies: pricingContext.policies,
catalogById,
catalogByNormalizedId,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
// 2. Try LiteLLM (may contain tiered pricing)
@@ -1228,8 +1265,7 @@ export async function refreshGatewayModelPricingCache(
ref,
policies: pricingContext.policies,
catalog: litellmCatalog,
allowManifestNormalization: normalizationOptions.allowManifestNormalization,
allowPluginNormalization: normalizationOptions.allowPluginNormalization,
...normalizationParams,
});
// Merge strategy: OpenRouter provides the base flat pricing;

View File

@@ -84,6 +84,7 @@ describe("gateway startup web fetch config", () => {
await writeConfig({
gateway: {
mode: "local",
bind: "loopback",
auth: { mode: "none" },
},
plugins: {

View File

@@ -1,6 +1,5 @@
import path from "node:path";
import { vi } from "vitest";
import { createGatewayConfigModuleMock } from "./test-helpers.config-runtime.js";
import {
getTestPluginRegistry,
resetTestPluginRegistry,
@@ -201,6 +200,7 @@ vi.mock("../config/sessions.js", async () => {
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
const { createGatewayConfigModuleMock } = await import("./test-helpers.config-runtime.js");
return createGatewayConfigModuleMock(actual);
});
@@ -208,6 +208,7 @@ vi.mock("../config/io.js", async () => {
const actual = await vi.importActual<typeof import("../config/io.js")>("../config/io.js");
const configActual =
await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
const { createGatewayConfigModuleMock } = await import("./test-helpers.config-runtime.js");
const configMock = createGatewayConfigModuleMock(configActual);
const createConfigIO = vi.fn(() => ({
...actual.createConfigIO(),

View File

@@ -1,8 +1,18 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
clearCurrentPluginMetadataSnapshot,
resolvePluginMetadataControlPlaneFingerprint,
setCurrentPluginMetadataSnapshot,
} from "./current-plugin-metadata-snapshot.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import { normalizeProviderModelIdWithManifest } from "./manifest-model-id-normalization.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js";
const ORIGINAL_ENV = {
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
@@ -49,10 +59,17 @@ function writeInstallIndex(params: { stateDir: string; pluginDir: string }): voi
function writeNormalizerManifest(params: { pluginDir: string; prefix: string }): void {
fs.mkdirSync(params.pluginDir, { recursive: true });
fs.writeFileSync(
path.join(params.pluginDir, "index.ts"),
"throw new Error('runtime entry should not load while reading manifests');\n",
"utf-8",
);
fs.writeFileSync(
path.join(params.pluginDir, "openclaw.plugin.json"),
JSON.stringify({
id: "normalizer",
configSchema: { type: "object" },
providers: ["demo"],
modelIdNormalization: {
providers: {
demo: {
@@ -65,6 +82,68 @@ function writeNormalizerManifest(params: { pluginDir: string; prefix: string }):
);
}
function createCurrentSnapshot(params: {
manifestHash: string;
prefix: string;
workspaceDir?: string;
}): PluginMetadataSnapshot {
const policyHash = resolveInstalledPluginIndexPolicyHash({});
const index: InstalledPluginIndex = {
version: 1,
hostContractVersion: "test-host",
compatRegistryVersion: "test-compat",
migrationVersion: 1,
policyHash,
generatedAtMs: 0,
installRecords: {},
plugins: [
{
pluginId: "normalizer",
manifestPath: `/tmp/normalizer-${params.manifestHash}/openclaw.plugin.json`,
manifestHash: params.manifestHash,
source: `/tmp/normalizer-${params.manifestHash}/index.ts`,
rootDir: `/tmp/normalizer-${params.manifestHash}`,
origin: "global",
enabled: true,
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
},
],
diagnostics: [],
};
return {
policyHash,
configFingerprint: resolvePluginMetadataControlPlaneFingerprint(
{},
{
env: process.env,
index,
policyHash,
workspaceDir: params.workspaceDir,
},
),
workspaceDir: params.workspaceDir,
index,
plugins: [
{
id: "normalizer",
modelIdNormalization: {
providers: {
demo: {
prefixWhenBare: params.prefix,
},
},
},
},
],
} as unknown as PluginMetadataSnapshot;
}
function normalizeDemoModel(modelId = "demo-model"): string | undefined {
return normalizeProviderModelIdWithManifest({
provider: "demo",
@@ -73,13 +152,73 @@ function normalizeDemoModel(modelId = "demo-model"): string | undefined {
}
describe("manifest model id normalization", () => {
beforeEach(() => {
resetPluginRuntimeStateForTest();
});
afterEach(() => {
clearCurrentPluginMetadataSnapshot();
resetPluginRuntimeStateForTest();
restoreEnv();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("refreshes cached policies when the current metadata snapshot changes", () => {
setCurrentPluginMetadataSnapshot(
createCurrentSnapshot({
manifestHash: "alpha",
prefix: "alpha",
}),
{ config: {}, env: process.env },
);
expect(normalizeDemoModel()).toBe("alpha/demo-model");
expect(normalizeDemoModel("second-model")).toBe("alpha/second-model");
setCurrentPluginMetadataSnapshot(
createCurrentSnapshot({
manifestHash: "bravo",
prefix: "bravo",
}),
{ config: {}, env: process.env },
);
expect(normalizeDemoModel()).toBe("bravo/demo-model");
});
it("uses workspace-scoped current metadata through the active plugin runtime", () => {
setActivePluginRegistry(
createEmptyPluginRegistry(),
"workspace-a",
"gateway-bindable",
"/workspace/a",
);
setCurrentPluginMetadataSnapshot(
createCurrentSnapshot({
manifestHash: "alpha",
prefix: "alpha",
workspaceDir: "/workspace/a",
}),
{ config: {}, env: process.env },
);
expect(normalizeDemoModel()).toBe("alpha/demo-model");
expect(normalizeDemoModel("second-model")).toBe("alpha/second-model");
setCurrentPluginMetadataSnapshot(
createCurrentSnapshot({
manifestHash: "bravo",
prefix: "bravo",
workspaceDir: "/workspace/a",
}),
{ config: {}, env: process.env },
);
expect(normalizeDemoModel()).toBe("bravo/demo-model");
});
it("reflects manifest edits and state-dir changes on the next lookup", () => {
const stateDirA = makeTempDir();
const pluginDirA = path.join(stateDirA, "extensions", "normalizer");
@@ -93,8 +232,8 @@ describe("manifest model id normalization", () => {
expect(normalizeDemoModel()).toBe("alpha/demo-model");
writeNormalizerManifest({ pluginDir: pluginDirA, prefix: "bravo" });
expect(normalizeDemoModel()).toBe("bravo/demo-model");
writeNormalizerManifest({ pluginDir: pluginDirA, prefix: "bravo-local" });
expect(normalizeDemoModel()).toBe("bravo-local/demo-model");
const stateDirB = makeTempDir();
const pluginDirB = path.join(stateDirB, "extensions", "normalizer");

View File

@@ -1,123 +1,90 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { listOpenClawPluginManifestMetadata } from "./manifest-metadata-scan.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import type { PluginManifestRecord } from "./manifest-registry.js";
import type { PluginManifestModelIdNormalizationProvider } from "./manifest.js";
import {
loadPluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
type ManifestModelIdNormalizationLookupParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
plugins?: readonly Pick<PluginManifestRecord, "modelIdNormalization">[];
};
function normalizeTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => normalizeTrimmedString(entry))
.filter((entry): entry is string => entry !== undefined);
}
function normalizePrefixRules(
value: unknown,
): PluginManifestModelIdNormalizationProvider["prefixWhenBareAfterAliasStartsWith"] {
if (!Array.isArray(value)) {
return undefined;
}
const rules: NonNullable<
PluginManifestModelIdNormalizationProvider["prefixWhenBareAfterAliasStartsWith"]
> = [];
for (const rawRule of value) {
if (!isRecord(rawRule)) {
continue;
}
const modelPrefix = normalizeTrimmedString(rawRule.modelPrefix);
const prefix = normalizeTrimmedString(rawRule.prefix);
if (modelPrefix && prefix) {
rules.push({ modelPrefix, prefix });
}
}
return rules.length > 0 ? rules : undefined;
}
function normalizeModelIdNormalizationPolicy(
value: unknown,
): PluginManifestModelIdNormalizationProvider | undefined {
if (!isRecord(value)) {
return undefined;
}
const aliases: Record<string, string> = {};
if (isRecord(value.aliases)) {
for (const [aliasRaw, canonicalRaw] of Object.entries(value.aliases)) {
const alias = normalizeLowercaseStringOrEmpty(aliasRaw);
const canonical = normalizeTrimmedString(canonicalRaw);
if (alias && canonical) {
aliases[alias] = canonical;
}
}
}
const stripPrefixes = normalizeStringList(value.stripPrefixes);
const prefixWhenBare = normalizeTrimmedString(value.prefixWhenBare);
const prefixWhenBareAfterAliasStartsWith = normalizePrefixRules(
value.prefixWhenBareAfterAliasStartsWith,
);
const policy = {
...(Object.keys(aliases).length > 0 ? { aliases } : {}),
...(stripPrefixes.length > 0 ? { stripPrefixes } : {}),
...(prefixWhenBare ? { prefixWhenBare } : {}),
...(prefixWhenBareAfterAliasStartsWith ? { prefixWhenBareAfterAliasStartsWith } : {}),
} satisfies PluginManifestModelIdNormalizationProvider;
return Object.keys(policy).length > 0 ? policy : undefined;
}
function readManifestModelIdNormalizationPolicies(
manifest: Record<string, unknown>,
): Array<[string, PluginManifestModelIdNormalizationProvider]> {
const modelIdNormalization = manifest.modelIdNormalization;
if (!isRecord(modelIdNormalization) || !isRecord(modelIdNormalization.providers)) {
return [];
}
const entries: Array<[string, PluginManifestModelIdNormalizationProvider]> = [];
for (const [providerRaw, rawPolicy] of Object.entries(modelIdNormalization.providers)) {
const provider = normalizeLowercaseStringOrEmpty(providerRaw);
const policy = normalizeModelIdNormalizationPolicy(rawPolicy);
if (provider && policy) {
entries.push([provider, policy]);
}
}
return entries;
}
function collectManifestModelIdNormalizationPolicies(): Map<
string,
PluginManifestModelIdNormalizationProvider
> {
function collectManifestModelIdNormalizationPolicies(
plugins: readonly Pick<PluginManifestRecord, "modelIdNormalization">[],
): Map<string, PluginManifestModelIdNormalizationProvider> {
const policies = new Map<string, PluginManifestModelIdNormalizationProvider>();
for (const { manifest } of listOpenClawPluginManifestMetadata()) {
for (const [provider, policy] of readManifestModelIdNormalizationPolicies(manifest)) {
policies.set(provider, policy);
for (const plugin of plugins) {
for (const [provider, policy] of Object.entries(plugin.modelIdNormalization?.providers ?? {})) {
policies.set(normalizeLowercaseStringOrEmpty(provider), policy);
}
}
return policies;
}
function loadManifestModelIdNormalizationPolicies(): Map<
string,
PluginManifestModelIdNormalizationProvider
> {
return collectManifestModelIdNormalizationPolicies();
type ManifestModelIdNormalizationPolicyCache = {
configFingerprint: string;
policies: Map<string, PluginManifestModelIdNormalizationProvider>;
};
let cachedPolicies: ManifestModelIdNormalizationPolicyCache | undefined;
function resolveMetadataSnapshotForPolicies(
params: ManifestModelIdNormalizationLookupParams = {},
): {
snapshot: PluginMetadataSnapshot;
cacheable: boolean;
} {
const env = params.env ?? process.env;
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
const current = getCurrentPluginMetadataSnapshot({
config: params.config,
env,
workspaceDir,
});
if (current) {
return { snapshot: current, cacheable: true };
}
return {
snapshot: loadPluginMetadataSnapshot({
config: params.config ?? {},
env,
workspaceDir,
}),
cacheable: false,
};
}
function loadManifestModelIdNormalizationPolicies(
params: ManifestModelIdNormalizationLookupParams = {},
): Map<string, PluginManifestModelIdNormalizationProvider> {
if (params.plugins) {
return collectManifestModelIdNormalizationPolicies(params.plugins);
}
const { snapshot, cacheable } = resolveMetadataSnapshotForPolicies(params);
const configFingerprint = snapshot.configFingerprint;
if (cacheable && configFingerprint && cachedPolicies?.configFingerprint === configFingerprint) {
return cachedPolicies.policies;
}
const policies = collectManifestModelIdNormalizationPolicies(snapshot.plugins);
if (cacheable && configFingerprint) {
cachedPolicies = { configFingerprint, policies };
}
return policies;
}
function resolveManifestModelIdNormalizationPolicy(
provider: string,
params: ManifestModelIdNormalizationLookupParams = {},
): PluginManifestModelIdNormalizationProvider | undefined {
const providerId = normalizeLowercaseStringOrEmpty(provider);
return loadManifestModelIdNormalizationPolicies().get(providerId);
return loadManifestModelIdNormalizationPolicies(params).get(providerId);
}
function hasProviderPrefix(modelId: string): boolean {
@@ -130,12 +97,16 @@ function formatPrefixedModelId(prefix: string, modelId: string): string {
export function normalizeProviderModelIdWithManifest(params: {
provider: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
plugins?: readonly Pick<PluginManifestRecord, "modelIdNormalization">[];
context: {
provider: string;
modelId: string;
};
}): string | undefined {
const policy = resolveManifestModelIdNormalizationPolicy(params.provider);
const policy = resolveManifestModelIdNormalizationPolicy(params.provider, params);
if (!policy) {
return undefined;
}

View File

@@ -1,4 +1,14 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearCurrentPluginMetadataSnapshot,
resolvePluginMetadataControlPlaneFingerprint,
setCurrentPluginMetadataSnapshot,
} from "./current-plugin-metadata-snapshot.js";
import { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js";
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.js";
import { createEmptyPluginRegistry } from "./registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "./runtime.js";
const loadPluginRegistrySnapshotMock = vi.hoisted(() => vi.fn());
const loadPluginManifestRegistryForInstalledIndexMock = vi.hoisted(() => vi.fn());
@@ -8,7 +18,8 @@ vi.mock("./plugin-registry.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./plugin-registry.js")>()),
loadPluginRegistrySnapshot: loadPluginRegistrySnapshotMock,
}));
vi.mock("./manifest-registry-installed.js", () => ({
vi.mock("./manifest-registry-installed.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./manifest-registry-installed.js")>()),
loadPluginManifestRegistryForInstalledIndex: loadPluginManifestRegistryForInstalledIndexMock,
}));
vi.mock("./plugin-metadata-snapshot.js", () => ({
@@ -16,11 +27,70 @@ vi.mock("./plugin-metadata-snapshot.js", () => ({
}));
afterEach(() => {
clearCurrentPluginMetadataSnapshot();
resetPluginRuntimeStateForTest();
loadPluginRegistrySnapshotMock.mockReset();
loadPluginManifestRegistryForInstalledIndexMock.mockReset();
loadPluginMetadataSnapshotMock.mockReset();
});
function createCurrentSnapshot(params: {
manifestHash: string;
cliBackends: string[];
workspaceDir?: string;
}): PluginMetadataSnapshot {
const policyHash = resolveInstalledPluginIndexPolicyHash({});
const index: InstalledPluginIndex = {
version: 1,
hostContractVersion: "test-host",
compatRegistryVersion: "test-compat",
migrationVersion: 1,
policyHash,
generatedAtMs: 0,
installRecords: {},
plugins: [
{
pluginId: "openai",
manifestPath: `/tmp/openai-${params.manifestHash}/openclaw.plugin.json`,
manifestHash: params.manifestHash,
source: `/tmp/openai-${params.manifestHash}/index.ts`,
rootDir: `/tmp/openai-${params.manifestHash}`,
origin: "bundled",
enabled: true,
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
},
],
diagnostics: [],
};
return {
policyHash,
configFingerprint: resolvePluginMetadataControlPlaneFingerprint(
{},
{
env: process.env,
index,
policyHash,
workspaceDir: params.workspaceDir,
},
),
workspaceDir: params.workspaceDir,
index,
plugins: [
{
id: "openai",
origin: "bundled",
cliBackends: params.cliBackends,
},
],
} as unknown as PluginMetadataSnapshot;
}
describe("setup-registry runtime fallback", () => {
it("uses bundled registry cliBackends when the setup-registry runtime is unavailable", async () => {
loadPluginMetadataSnapshotMock.mockReturnValue({
@@ -71,6 +141,90 @@ describe("setup-registry runtime fallback", () => {
});
});
it("refreshes bundled registry cliBackends when the current metadata snapshot changes", async () => {
const { __testing, resolvePluginSetupCliBackendRuntime } =
await import("./setup-registry.runtime.js");
__testing.resetRuntimeState();
__testing.setRuntimeModuleForTest(null);
setCurrentPluginMetadataSnapshot(
createCurrentSnapshot({
manifestHash: "alpha",
cliBackends: ["Codex-CLI"],
}),
{ config: {}, env: process.env },
);
expect(resolvePluginSetupCliBackendRuntime({ backend: "codex-cli" })).toEqual({
pluginId: "openai",
backend: { id: "Codex-CLI" },
});
expect(resolvePluginSetupCliBackendRuntime({ backend: "next-cli" })).toBeUndefined();
setCurrentPluginMetadataSnapshot(
createCurrentSnapshot({
manifestHash: "bravo",
cliBackends: ["Next-CLI"],
}),
{ config: {}, env: process.env },
);
expect(resolvePluginSetupCliBackendRuntime({ backend: "codex-cli" })).toBeUndefined();
expect(resolvePluginSetupCliBackendRuntime({ backend: "next-cli" })).toEqual({
pluginId: "openai",
backend: { id: "Next-CLI" },
});
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
});
it("uses workspace-scoped current metadata through the active plugin runtime", async () => {
const { __testing, resolvePluginSetupCliBackendRuntime } =
await import("./setup-registry.runtime.js");
__testing.resetRuntimeState();
__testing.setRuntimeModuleForTest(null);
setActivePluginRegistry(
createEmptyPluginRegistry(),
"workspace-a",
"gateway-bindable",
"/workspace/a",
);
setCurrentPluginMetadataSnapshot(
createCurrentSnapshot({
manifestHash: "alpha",
cliBackends: ["Codex-CLI"],
workspaceDir: "/workspace/a",
}),
{ config: {}, env: process.env },
);
expect(resolvePluginSetupCliBackendRuntime({ backend: "codex-cli", config: {} })).toEqual({
pluginId: "openai",
backend: { id: "Codex-CLI" },
});
expect(
resolvePluginSetupCliBackendRuntime({ backend: "next-cli", config: {} }),
).toBeUndefined();
setCurrentPluginMetadataSnapshot(
createCurrentSnapshot({
manifestHash: "bravo",
cliBackends: ["Next-CLI"],
workspaceDir: "/workspace/a",
}),
{ config: {}, env: process.env },
);
expect(
resolvePluginSetupCliBackendRuntime({ backend: "codex-cli", config: {} }),
).toBeUndefined();
expect(resolvePluginSetupCliBackendRuntime({ backend: "next-cli", config: {} })).toEqual({
pluginId: "openai",
backend: { id: "Next-CLI" },
});
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
});
it("preserves fail-closed setup lookup when the runtime module explicitly declines to resolve", async () => {
loadPluginMetadataSnapshotMock.mockReturnValue({
index: {

View File

@@ -1,7 +1,13 @@
import { createRequire } from "node:module";
import { normalizeProviderId } from "../agents/provider-id.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
import { isInstalledPluginEnabled } from "./installed-plugin-index.js";
import { loadManifestMetadataSnapshot } from "./manifest-contract-eligibility.js";
import {
loadPluginMetadataSnapshot,
type PluginMetadataSnapshot,
} from "./plugin-metadata-snapshot.js";
import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js";
type SetupRegistryRuntimeModule = Pick<
typeof import("./setup-registry.js"),
@@ -15,23 +21,73 @@ type SetupCliBackendRuntimeEntry = {
};
};
type SetupCliBackendRuntimeLookupParams = {
backend: string;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
};
const require = createRequire(import.meta.url);
const SETUP_REGISTRY_RUNTIME_CANDIDATES = ["./setup-registry.js", "./setup-registry.ts"] as const;
type BundledSetupCliBackendCache = {
configFingerprint: string;
entries: SetupCliBackendRuntimeEntry[];
};
let setupRegistryRuntimeModule: SetupRegistryRuntimeModule | null | undefined;
let cachedBundledSetupCliBackends: BundledSetupCliBackendCache | undefined;
export const __testing = {
resetRuntimeState(): void {
setupRegistryRuntimeModule = undefined;
cachedBundledSetupCliBackends = undefined;
},
setRuntimeModuleForTest(module: SetupRegistryRuntimeModule | null | undefined): void {
setupRegistryRuntimeModule = module;
},
};
function resolveBundledSetupCliBackends(): SetupCliBackendRuntimeEntry[] {
const snapshot = loadManifestMetadataSnapshot({ config: {}, env: process.env });
return snapshot.plugins.flatMap((plugin) => {
function resolveMetadataSnapshotForSetupCliBackends(
params: Omit<SetupCliBackendRuntimeLookupParams, "backend"> = {},
): {
snapshot: PluginMetadataSnapshot;
cacheable: boolean;
} {
const env = params.env ?? process.env;
const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState();
const current = getCurrentPluginMetadataSnapshot({
config: params.config,
env,
workspaceDir,
});
if (current) {
return { snapshot: current, cacheable: true };
}
return {
snapshot: loadPluginMetadataSnapshot({
config: params.config ?? {},
env,
workspaceDir,
}),
cacheable: false,
};
}
function resolveBundledSetupCliBackends(
params: Omit<SetupCliBackendRuntimeLookupParams, "backend"> = {},
): SetupCliBackendRuntimeEntry[] {
const { snapshot, cacheable } = resolveMetadataSnapshotForSetupCliBackends(params);
const configFingerprint = snapshot.configFingerprint;
if (
cacheable &&
configFingerprint &&
cachedBundledSetupCliBackends?.configFingerprint === configFingerprint
) {
return cachedBundledSetupCliBackends.entries;
}
const entries = snapshot.plugins.flatMap((plugin) => {
if (plugin.origin !== "bundled" || !isInstalledPluginEnabled(snapshot.index, plugin.id)) {
return [];
}
@@ -43,6 +99,10 @@ function resolveBundledSetupCliBackends(): SetupCliBackendRuntimeEntry[] {
}) satisfies SetupCliBackendRuntimeEntry,
);
});
if (cacheable && configFingerprint) {
cachedBundledSetupCliBackends = { configFingerprint, entries };
}
return entries;
}
function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null {
@@ -57,16 +117,17 @@ function loadSetupRegistryRuntime(): SetupRegistryRuntimeModule | null {
// Try source/runtime candidates in order.
}
}
setupRegistryRuntimeModule = null;
return null;
}
export function resolvePluginSetupCliBackendRuntime(params: { backend: string }) {
export function resolvePluginSetupCliBackendRuntime(params: SetupCliBackendRuntimeLookupParams) {
const normalized = normalizeProviderId(params.backend);
const runtime = loadSetupRegistryRuntime();
if (runtime !== null) {
return runtime.resolvePluginSetupCliBackend(params);
}
return resolveBundledSetupCliBackends().find(
return resolveBundledSetupCliBackends(params).find(
(entry) => normalizeProviderId(entry.backend.id) === normalized,
);
}