diff --git a/CHANGELOG.md b/CHANGELOG.md index 48d652a9bf6..8ef2c3a34fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/agents/model-ref-shared.ts b/src/agents/model-ref-shared.ts index 0f609518622..501436ce8ad 100644 --- a/src/agents/model-ref-shared.ts +++ b/src/agents/model-ref-shared.ts @@ -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[]; + } = {}, ): string { if (options.allowManifestNormalization === false) { return model; @@ -34,6 +38,7 @@ export function normalizeStaticProviderModelId( return ( normalizeProviderModelIdWithManifest({ provider, + plugins: options.manifestPlugins, context: { provider, modelId: model, diff --git a/src/agents/model-selection-cli.ts b/src/agents/model-selection-cli.ts index f8a052db8ba..cbcbe898b88 100644 --- a/src/agents/model-selection-cli.ts +++ b/src/agents/model-selection-cli.ts @@ -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; diff --git a/src/agents/model-selection-normalize.ts b/src/agents/model-selection-normalize.ts index b51b2e5a08f..203803b929e 100644 --- a/src/agents/model-selection-normalize.ts +++ b/src/agents/model-selection-normalize.ts @@ -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[]; + }, ): 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[]; }; export function normalizeModelRef( diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 25ce466fecf..b745670b37a 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -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; }; +type ManifestNormalizationContext = { + manifestPlugins?: readonly Pick[]; +}; + 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(); const byKey = new Map(); @@ -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; diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index 47f84b12165..88d26907faf 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -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; catalogPricing: Map; } { const policies = new Map(); 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; 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; 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; 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; 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; 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; 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(); + 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; 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; allowManifestNormalization: boolean; allowPluginNormalization: boolean; + manifestPlugins?: PluginManifestRegistry["plugins"]; }): Map { const seeded = new Map(); 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; diff --git a/src/gateway/server-startup-web-fetch-bind.test.ts b/src/gateway/server-startup-web-fetch-bind.test.ts index 3163603a729..6fb78b22d40 100644 --- a/src/gateway/server-startup-web-fetch-bind.test.ts +++ b/src/gateway/server-startup-web-fetch-bind.test.ts @@ -84,6 +84,7 @@ describe("gateway startup web fetch config", () => { await writeConfig({ gateway: { mode: "local", + bind: "loopback", auth: { mode: "none" }, }, plugins: { diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 641adc90b65..855c9db8048 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -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("../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("../config/io.js"); const configActual = await vi.importActual("../config/config.js"); + const { createGatewayConfigModuleMock } = await import("./test-helpers.config-runtime.js"); const configMock = createGatewayConfigModuleMock(configActual); const createConfigIO = vi.fn(() => ({ ...actual.createConfigIO(), diff --git a/src/plugins/manifest-model-id-normalization.test.ts b/src/plugins/manifest-model-id-normalization.test.ts index 9d84838f0ca..2f53899c633 100644 --- a/src/plugins/manifest-model-id-normalization.test.ts +++ b/src/plugins/manifest-model-id-normalization.test.ts @@ -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"); diff --git a/src/plugins/manifest-model-id-normalization.ts b/src/plugins/manifest-model-id-normalization.ts index 9cfdbf30329..33ef1de2302 100644 --- a/src/plugins/manifest-model-id-normalization.ts +++ b/src/plugins/manifest-model-id-normalization.ts @@ -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 { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} +type ManifestModelIdNormalizationLookupParams = { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + plugins?: readonly Pick[]; +}; -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 = {}; - 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, -): 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[], +): Map { const policies = new Map(); - 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; +}; + +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 { + 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[]; context: { provider: string; modelId: string; }; }): string | undefined { - const policy = resolveManifestModelIdNormalizationPolicy(params.provider); + const policy = resolveManifestModelIdNormalizationPolicy(params.provider, params); if (!policy) { return undefined; } diff --git a/src/plugins/setup-registry.runtime.test.ts b/src/plugins/setup-registry.runtime.test.ts index 8b95179055a..d4163daaede 100644 --- a/src/plugins/setup-registry.runtime.test.ts +++ b/src/plugins/setup-registry.runtime.test.ts @@ -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()), loadPluginRegistrySnapshot: loadPluginRegistrySnapshotMock, })); -vi.mock("./manifest-registry-installed.js", () => ({ +vi.mock("./manifest-registry-installed.js", async (importOriginal) => ({ + ...(await importOriginal()), 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: { diff --git a/src/plugins/setup-registry.runtime.ts b/src/plugins/setup-registry.runtime.ts index 2e32c216b43..d166f6c1250 100644 --- a/src/plugins/setup-registry.runtime.ts +++ b/src/plugins/setup-registry.runtime.ts @@ -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 = {}, +): { + 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 = {}, +): 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, ); }