From 04e96c11ead3af89af61a960ac7134bb5616bdd4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 08:23:49 +0100 Subject: [PATCH] fix(gateway): skip plugin pricing scans when disabled --- CHANGELOG.md | 1 + src/agents/model-ref-shared.ts | 9 +- src/agents/model-selection-normalize.ts | 7 +- src/agents/model-selection-shared.ts | 21 +- src/gateway/model-pricing-cache.test.ts | 78 +++++- src/gateway/model-pricing-cache.ts | 304 ++++++++++++++++++++---- 6 files changed, 371 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c26c106e70..d6a37ddba6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai - CLI/model probes: reject empty or whitespace-only `infer model run --prompt` values before calling local providers or the Gateway, so smoke checks do not spend provider calls on invalid turns. Fixes #73185. Thanks @iot2edge. - Gateway/media: route text-only `chat.send` image offloads through media-understanding fields so `agents.defaults.imageModel` can describe WebChat attachments instead of leaving only an opaque `media://inbound` marker. Fixes #72968. Thanks @vorajeeah. - Gateway/Windows: route no-listener restart handoffs through the Windows supervisor without leaving restart tokens in flight, so failed task scheduling can be retried and successful handoffs do not coalesce later restart requests. (#69056) Thanks @Thatgfsj. +- Gateway/model pricing: skip plugin manifest discovery during background pricing refreshes when `plugins.enabled: false`, so disabled-plugin setups do not keep rebuilding plugin metadata from the Gateway hot path. Fixes #73291. Thanks @slideshow-dingo and @fishgills. - Gateway/sessions: remove automatic oversized `sessions.json` rotation backups, deprecate `session.maintenance.rotateBytes`, and teach `openclaw doctor --fix` to remove the ignored key so hot session writes no longer copy multi-MB stores. Refs #72338. Thanks @midhunmonachan and @DougButdorf. - Channels/Telegram: fail fast when Telegram rejects the startup `getMe` token probe with 401, so invalid or stale BotFather tokens are reported as token auth failures instead of misleading `deleteWebhook` cleanup failures. Fixes #47674. Thanks @samaedan-arch. - ACPX: keep generated Codex and Claude ACP wrapper startup paths working when remote or special state filesystems reject chmod, since OpenClaw invokes the wrappers through Node instead of executing them directly. Fixes #73333. Thanks @david-garcia-garcia. diff --git a/src/agents/model-ref-shared.ts b/src/agents/model-ref-shared.ts index 365dc3a3984..c0de50888ec 100644 --- a/src/agents/model-ref-shared.ts +++ b/src/agents/model-ref-shared.ts @@ -23,7 +23,14 @@ export function modelKey(provider: string, model: string): string { : `${providerId}/${modelId}`; } -export function normalizeStaticProviderModelId(provider: string, model: string): string { +export function normalizeStaticProviderModelId( + provider: string, + model: string, + options: { allowManifestNormalization?: boolean } = {}, +): string { + if (options.allowManifestNormalization === false) { + return model; + } return ( normalizeProviderModelIdWithManifest({ provider, diff --git a/src/agents/model-selection-normalize.ts b/src/agents/model-selection-normalize.ts index c9c6cb856fe..b51b2e5a08f 100644 --- a/src/agents/model-selection-normalize.ts +++ b/src/agents/model-selection-normalize.ts @@ -38,9 +38,11 @@ export { function normalizeProviderModelId( provider: string, model: string, - options?: { allowPluginNormalization?: boolean }, + options?: { allowManifestNormalization?: boolean; allowPluginNormalization?: boolean }, ): string { - const staticModelId = normalizeStaticProviderModelId(provider, model); + const staticModelId = normalizeStaticProviderModelId(provider, model, { + allowManifestNormalization: options?.allowManifestNormalization, + }); if (options?.allowPluginNormalization === false) { return staticModelId; } @@ -56,6 +58,7 @@ function normalizeProviderModelId( } type ModelRefNormalizeOptions = { + allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; }; diff --git a/src/agents/model-selection-shared.ts b/src/agents/model-selection-shared.ts index 84d6326b5f7..8be9870d548 100644 --- a/src/agents/model-selection-shared.ts +++ b/src/agents/model-selection-shared.ts @@ -165,6 +165,7 @@ function isConcreteOpenRouterFreeModelRef(ref: ModelRef): boolean { function resolveConfiguredOpenRouterCompatFreeRef(params: { cfg: OpenClawConfig; defaultProvider: string; + allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; }): ModelRef | null { const configuredModels = params.cfg.agents?.defaults?.models ?? {}; @@ -173,6 +174,7 @@ function resolveConfiguredOpenRouterCompatFreeRef(params: { continue; } const parsed = parseModelRef(raw, params.defaultProvider, { + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); if (parsed && isConcreteOpenRouterFreeModelRef(parsed)) { @@ -190,6 +192,7 @@ function resolveConfiguredOpenRouterCompatFreeRef(params: { continue; } return normalizeModelRef("openrouter", modelId, { + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); } @@ -201,11 +204,13 @@ export function resolveConfiguredOpenRouterCompatAlias(params: { cfg?: OpenClawConfig; raw: string; defaultProvider: string; + allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; }): ModelRef | null { const normalized = normalizeLowercaseStringOrEmpty(params.raw); if (normalized === "openrouter:auto") { return normalizeModelRef("openrouter", "auto", { + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); } @@ -215,6 +220,7 @@ export function resolveConfiguredOpenRouterCompatAlias(params: { return resolveConfiguredOpenRouterCompatFreeRef({ cfg: params.cfg, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); } @@ -223,12 +229,14 @@ export function parseModelRefWithCompatAlias(params: { cfg?: OpenClawConfig; raw: string; defaultProvider: string; + allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; }): ModelRef | null { return ( resolveConfiguredOpenRouterCompatAlias(params) ?? resolveExactConfiguredProviderRef(params) ?? parseModelRef(params.raw, params.defaultProvider, { + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }) ); @@ -237,6 +245,7 @@ export function parseModelRefWithCompatAlias(params: { function resolveExactConfiguredProviderRef(params: { cfg?: OpenClawConfig; raw: string; + allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; }): ModelRef | null { const slash = params.raw.indexOf("/"); @@ -265,7 +274,9 @@ function resolveExactConfiguredProviderRef(params: { const provider = normalizeLowercaseStringOrEmpty(configuredProvider); return { provider, - model: normalizeStaticProviderModelId(provider, modelRaw.trim()), + model: normalizeStaticProviderModelId(provider, modelRaw.trim(), { + allowManifestNormalization: params.allowManifestNormalization, + }), }; } @@ -311,6 +322,7 @@ export function buildConfiguredAllowlistKeys(params: { export function buildModelAliasIndex(params: { cfg: OpenClawConfig; defaultProvider: string; + allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; }): ModelAliasIndex { const byAlias = new Map(); @@ -322,6 +334,7 @@ export function buildModelAliasIndex(params: { cfg: params.cfg, raw: keyRaw, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); if (!parsed) { @@ -430,6 +443,7 @@ export function resolveModelRefFromString(params: { raw: string; defaultProvider: string; aliasIndex?: ModelAliasIndex; + allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; }): { ref: ModelRef; alias?: string } | null { const { model } = splitTrailingAuthProfile(params.raw); @@ -447,6 +461,7 @@ export function resolveModelRefFromString(params: { cfg: params.cfg, raw: model, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); if (!parsed) { @@ -459,6 +474,7 @@ export function resolveConfiguredModelRef(params: { cfg: OpenClawConfig; defaultProvider: string; defaultModel: string; + allowManifestNormalization?: boolean; allowPluginNormalization?: boolean; }): ModelRef { const rawModel = resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model) ?? ""; @@ -467,6 +483,7 @@ export function resolveConfiguredModelRef(params: { const aliasIndex = buildModelAliasIndex({ cfg: params.cfg, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); if (!trimmed.includes("/")) { @@ -474,6 +491,7 @@ export function resolveConfiguredModelRef(params: { cfg: params.cfg, raw: trimmed, defaultProvider: params.defaultProvider, + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); if (openrouterCompatRef) { @@ -507,6 +525,7 @@ export function resolveConfiguredModelRef(params: { raw: trimmed, defaultProvider: params.defaultProvider, aliasIndex, + allowManifestNormalization: params.allowManifestNormalization, allowPluginNormalization: params.allowPluginNormalization, }); if (resolved) { diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index aee706ff1c3..c8c2ea43b2c 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -1,22 +1,23 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { modelKey } from "../agents/model-selection.js"; +import type { normalizeProviderModelIdWithRuntime } from "../agents/provider-model-normalization.runtime.js"; import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; import { loggingState } from "../logging/state.js"; import type { PluginManifestRecord, PluginManifestRegistry } from "../plugins/manifest-registry.js"; -import type { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js"; import { withFetchPreconnect } from "../test-utils/fetch-mock.js"; -const normalizeProviderModelIdWithPluginMock = vi.hoisted(() => - vi.fn(({ context }) => context.modelId), +const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() => + vi.fn(({ context }) => context.modelId), ); const pluginManifestRegistryMocks = vi.hoisted(() => ({ manifestRegistry: undefined as PluginManifestRegistry | undefined, loadPluginManifestRegistryForInstalledIndex: vi.fn(), + listOpenClawPluginManifestMetadata: vi.fn(), })); -vi.mock("../plugins/provider-runtime.js", () => { - return { normalizeProviderModelIdWithPlugin: normalizeProviderModelIdWithPluginMock }; +vi.mock("../agents/provider-model-normalization.runtime.js", () => { + return { normalizeProviderModelIdWithRuntime: normalizeProviderModelIdWithRuntimeMock }; }); vi.mock("../plugins/manifest-registry-installed.js", async (importOriginal) => { @@ -35,6 +36,19 @@ vi.mock("../plugins/manifest-registry-installed.js", async (importOriginal) => { }; }); +vi.mock("../plugins/manifest-metadata-scan.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listOpenClawPluginManifestMetadata: ( + params?: Parameters[0], + ) => { + pluginManifestRegistryMocks.listOpenClawPluginManifestMetadata(params); + return actual.listOpenClawPluginManifestMetadata(params); + }, + }; +}); + import { __resetGatewayModelPricingCacheForTest, collectConfiguredModelPricingRefs, @@ -48,6 +62,8 @@ describe("model-pricing-cache", () => { __resetGatewayModelPricingCacheForTest(); pluginManifestRegistryMocks.manifestRegistry = undefined; pluginManifestRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockClear(); + pluginManifestRegistryMocks.listOpenClawPluginManifestMetadata.mockClear(); + normalizeProviderModelIdWithRuntimeMock.mockClear(); }); afterEach(() => { @@ -186,6 +202,58 @@ describe("model-pricing-cache", () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it("does not load plugin manifests for pricing when plugins are globally disabled", async () => { + const config = { + plugins: { + enabled: false, + entries: { + "search-plugin": { + config: { + webSearch: { + model: "local-search/search-model", + }, + }, + }, + }, + }, + agents: { + defaults: { + model: { primary: "custom/gpt-local" }, + }, + }, + models: { + providers: { + custom: { + baseUrl: "https://models.example/v1", + api: "openai-completions", + models: [ + { + id: "gpt-local", + cost: { input: 0.12, output: 0.48 }, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + const fetchImpl = vi.fn(); + + await refreshGatewayModelPricingCache({ config, fetchImpl }); + + expect( + pluginManifestRegistryMocks.loadPluginManifestRegistryForInstalledIndex, + ).not.toHaveBeenCalled(); + expect(pluginManifestRegistryMocks.listOpenClawPluginManifestMetadata).not.toHaveBeenCalled(); + expect(normalizeProviderModelIdWithRuntimeMock).not.toHaveBeenCalled(); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(getCachedGatewayModelPricing({ provider: "custom", model: "gpt-local" })).toEqual({ + input: 0.12, + output: 0.48, + cacheRead: 0, + cacheWrite: 0, + }); + }); + it("skips remote pricing catalogs for local-only model providers", async () => { const config = { agents: { diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index e83a318b2d2..4451a39f035 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -25,7 +25,6 @@ import { loadPluginRegistrySnapshot, type PluginRegistrySnapshot, } from "../plugins/plugin-registry.js"; -import { normalizeProviderModelIdWithPlugin } from "../plugins/provider-runtime.js"; import { normalizeOptionalString, resolvePrimaryStringValue } from "../shared/string-coerce.js"; import { clearGatewayModelPricingCacheState, @@ -67,6 +66,11 @@ type ExternalPricingSourcePolicy = { export { getCachedGatewayModelPricing }; +type PricingModelNormalizationOptions = { + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; +}; + const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; const LITELLM_PRICING_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; @@ -86,6 +90,16 @@ function clearRefreshTimer(): void { refreshTimer = null; } +function getPricingModelNormalizationOptions( + config: OpenClawConfig, +): PricingModelNormalizationOptions { + const allowPluginBackedNormalization = config.plugins?.enabled !== false; + return { + allowManifestNormalization: allowPluginBackedNormalization, + allowPluginNormalization: allowPluginBackedNormalization, + }; +} + function listLikeFallbacks(value: ModelListLike): string[] { if (!value || typeof value !== "object") { return []; @@ -404,6 +418,13 @@ function resolveModelPricingManifestMetadata(params: { activeRegistry: params.manifestRegistry, }; } + if (params.config.plugins?.enabled === false) { + const emptyRegistry: PluginManifestRegistry = { plugins: [], diagnostics: [] }; + return { + allRegistry: emptyRegistry, + activeRegistry: emptyRegistry, + }; + } const index = loadPluginRegistrySnapshot({ config: params.config }); const allRegistry = loadPluginManifestRegistryForInstalledIndex({ index, @@ -472,7 +493,13 @@ function applyModelIdTransforms( return [...variants]; } -function canonicalizeOpenRouterLookupId(id: string): string { +function canonicalizeOpenRouterLookupId( + id: string, + options: PricingModelNormalizationOptions = { + allowManifestNormalization: true, + allowPluginNormalization: true, + }, +): string { const trimmed = id.trim(); if (!trimmed) { return ""; @@ -481,19 +508,18 @@ function canonicalizeOpenRouterLookupId(id: string): string { if (slash === -1) { return trimmed; } - const provider = normalizeModelRef(trimmed.slice(0, slash), "placeholder").provider; + const provider = normalizeModelRef(trimmed.slice(0, slash), "placeholder", { + allowManifestNormalization: options.allowManifestNormalization, + allowPluginNormalization: options.allowPluginNormalization, + }).provider; const model = trimmed.slice(slash + 1).trim(); if (!model) { return provider; } - const normalizedModel = - normalizeProviderModelIdWithPlugin({ - provider, - context: { - provider, - modelId: model, - }, - }) ?? model; + const normalizedModel = normalizeModelRef(provider, model, { + allowManifestNormalization: options.allowManifestNormalization, + allowPluginNormalization: options.allowPluginNormalization, + }).model; return modelKey(provider, normalizedModel); } @@ -502,6 +528,8 @@ function buildExternalCatalogCandidates(params: { source: "openRouter" | "liteLLM"; policies: ReadonlyMap; seen?: Set; + allowManifestNormalization?: boolean; + allowPluginNormalization?: boolean; }): string[] { const { ref, source, policies } = params; const refKey = modelKey(ref.provider, ref.model); @@ -529,17 +557,29 @@ function buildExternalCatalogCandidates(params: { for (const model of applyModelIdTransforms(ref.model, transforms)) { const candidate = modelKey(provider, model); - candidates.add(source === "openRouter" ? canonicalizeOpenRouterLookupId(candidate) : candidate); + candidates.add( + source === "openRouter" + ? canonicalizeOpenRouterLookupId(candidate, { + allowManifestNormalization: params.allowManifestNormalization ?? true, + allowPluginNormalization: params.allowPluginNormalization ?? true, + }) + : candidate, + ); } if (sourcePolicy?.passthroughProviderModel && ref.model.includes("/")) { - const nestedRef = parseModelRef(ref.model, DEFAULT_PROVIDER); + const nestedRef = parseModelRef(ref.model, DEFAULT_PROVIDER, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }); if (nestedRef) { for (const candidate of buildExternalCatalogCandidates({ ref: nestedRef, source, policies, seen: nextSeen, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, })) { candidates.add(candidate); } @@ -553,6 +593,8 @@ function addResolvedModelRef(params: { raw: string | undefined; aliasIndex: ReturnType; refs: Map; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): void { const raw = params.raw?.trim(); if (!raw) { @@ -562,11 +604,16 @@ function addResolvedModelRef(params: { raw, defaultProvider: DEFAULT_PROVIDER, aliasIndex: params.aliasIndex, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, }); if (!resolved) { return; } - const normalized = normalizeModelRef(resolved.ref.provider, resolved.ref.model); + const normalized = normalizeModelRef(resolved.ref.provider, resolved.ref.model, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }); params.refs.set(modelKey(normalized.provider, normalized.model), normalized); } @@ -574,17 +621,23 @@ function addModelListLike(params: { value: ModelListLike; aliasIndex: ReturnType; refs: Map; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): void { addResolvedModelRef({ raw: resolvePrimaryStringValue(params.value), aliasIndex: params.aliasIndex, refs: params.refs, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, }); for (const fallback of listLikeFallbacks(params.value)) { addResolvedModelRef({ raw: fallback, aliasIndex: params.aliasIndex, refs: params.refs, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, }); } } @@ -593,13 +646,18 @@ function addProviderModelPair(params: { provider: string | undefined; model: string | undefined; refs: Map; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): void { const provider = params.provider?.trim(); const model = params.model?.trim(); if (!provider || !model) { return; } - const normalized = normalizeModelRef(provider, model); + const normalized = normalizeModelRef(provider, model, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }); params.refs.set(modelKey(normalized.provider, normalized.model), normalized); } @@ -608,6 +666,8 @@ function addConfiguredWebSearchPluginModels(params: { aliasIndex: ReturnType; refs: Map; manifestRegistry: PluginManifestRegistry; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): void { for (const pluginId of params.manifestRegistry.plugins .filter((plugin) => (plugin.contracts?.webSearchProviders ?? []).length > 0) @@ -617,6 +677,8 @@ function addConfiguredWebSearchPluginModels(params: { raw: resolvePluginWebSearchConfig(params.config, pluginId)?.model as string | undefined, aliasIndex: params.aliasIndex, refs: params.refs, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, }); } } @@ -660,10 +722,17 @@ function isPrivateOrLoopbackBaseUrl(baseUrl: string | undefined): boolean { function findConfiguredProviderModel( config: OpenClawConfig, ref: ModelRef, + options: PricingModelNormalizationOptions = { + allowManifestNormalization: true, + allowPluginNormalization: true, + }, ): ModelDefinitionConfig | undefined { const providerConfig = config.models?.providers?.[ref.provider]; return providerConfig?.models?.find((model) => { - const normalized = normalizeModelRef(ref.provider, model.id); + const normalized = normalizeModelRef(ref.provider, model.id, { + allowManifestNormalization: options.allowManifestNormalization, + allowPluginNormalization: options.allowPluginNormalization, + }); return modelKey(normalized.provider, normalized.model) === modelKey(ref.provider, ref.model); }); } @@ -671,13 +740,24 @@ function findConfiguredProviderModel( function getConfiguredModelPricing( config: OpenClawConfig, ref: ModelRef, + options: PricingModelNormalizationOptions = { + allowManifestNormalization: true, + allowPluginNormalization: true, + }, ): CachedModelPricing | undefined { - return toCachedModelPricing(findConfiguredProviderModel(config, ref)?.cost); + return toCachedModelPricing(findConfiguredProviderModel(config, ref, options)?.cost); } -function hasPrivateOrLoopbackConfiguredEndpoint(config: OpenClawConfig, ref: ModelRef): boolean { +function hasPrivateOrLoopbackConfiguredEndpoint( + config: OpenClawConfig, + ref: ModelRef, + options: PricingModelNormalizationOptions = { + allowManifestNormalization: true, + allowPluginNormalization: true, + }, +): boolean { const providerConfig = config.models?.providers?.[ref.provider]; - const model = findConfiguredProviderModel(config, ref); + const model = findConfiguredProviderModel(config, ref, options); return ( isPrivateOrLoopbackBaseUrl(model?.baseUrl) || isPrivateOrLoopbackBaseUrl(providerConfig?.baseUrl) @@ -689,11 +769,18 @@ function shouldFetchExternalPricingForRef(params: { ref: ModelRef; policies: ReadonlyMap; seededPricing: ReadonlyMap; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): boolean { if (params.seededPricing.has(modelKey(params.ref.provider, params.ref.model))) { return false; } - if (hasPrivateOrLoopbackConfiguredEndpoint(params.config, params.ref)) { + if ( + hasPrivateOrLoopbackConfiguredEndpoint(params.config, params.ref, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }) + ) { return false; } if (params.policies.get(params.ref.provider)?.external === false) { @@ -707,6 +794,8 @@ function filterExternalPricingRefs(params: { refs: ModelRef[]; policies: ReadonlyMap; seededPricing: ReadonlyMap; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): ModelRef[] { return params.refs.filter((ref) => shouldFetchExternalPricingForRef({ @@ -714,6 +803,8 @@ function filterExternalPricingRefs(params: { ref, policies: params.policies, seededPricing: params.seededPricing, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, }), ); } @@ -724,29 +815,104 @@ export function collectConfiguredModelPricingRefs( ): ModelRef[] { const manifestRegistry = options.manifestRegistry ?? resolveModelPricingManifestMetadata({ config }).allRegistry; + const normalizationOptions = getPricingModelNormalizationOptions(config); const refs = new Map(); const aliasIndex = buildModelAliasIndex({ cfg: config, defaultProvider: DEFAULT_PROVIDER, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, }); - addModelListLike({ value: config.agents?.defaults?.model, aliasIndex, refs }); - addModelListLike({ value: config.agents?.defaults?.imageModel, aliasIndex, refs }); - addModelListLike({ value: config.agents?.defaults?.pdfModel, aliasIndex, refs }); - addResolvedModelRef({ raw: config.agents?.defaults?.compaction?.model, aliasIndex, refs }); - addResolvedModelRef({ raw: config.agents?.defaults?.heartbeat?.model, aliasIndex, refs }); - addModelListLike({ value: config.tools?.subagents?.model, aliasIndex, refs }); - addResolvedModelRef({ raw: config.messages?.tts?.summaryModel, aliasIndex, refs }); - addResolvedModelRef({ raw: config.hooks?.gmail?.model, aliasIndex, refs }); + addModelListLike({ + value: config.agents?.defaults?.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addModelListLike({ + value: config.agents?.defaults?.imageModel, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addModelListLike({ + value: config.agents?.defaults?.pdfModel, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addResolvedModelRef({ + raw: config.agents?.defaults?.compaction?.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addResolvedModelRef({ + raw: config.agents?.defaults?.heartbeat?.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addModelListLike({ + value: config.tools?.subagents?.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addResolvedModelRef({ + raw: config.messages?.tts?.summaryModel, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addResolvedModelRef({ + raw: config.hooks?.gmail?.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); for (const agent of config.agents?.list ?? []) { - addModelListLike({ value: agent.model, aliasIndex, refs }); - addModelListLike({ value: agent.subagents?.model, aliasIndex, refs }); - addResolvedModelRef({ raw: agent.heartbeat?.model, aliasIndex, refs }); + addModelListLike({ + value: agent.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addModelListLike({ + value: agent.subagents?.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); + addResolvedModelRef({ + raw: agent.heartbeat?.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); } for (const mapping of config.hooks?.mappings ?? []) { - addResolvedModelRef({ raw: mapping.model, aliasIndex, refs }); + addResolvedModelRef({ + raw: mapping.model, + aliasIndex, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); } for (const channelMap of Object.values(config.channels?.modelByChannel ?? {})) { @@ -758,23 +924,56 @@ export function collectConfiguredModelPricingRefs( raw: typeof raw === "string" ? raw : undefined, aliasIndex, refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, }); } } - addConfiguredWebSearchPluginModels({ config, aliasIndex, refs, manifestRegistry }); + addConfiguredWebSearchPluginModels({ + config, + aliasIndex, + refs, + manifestRegistry, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); for (const entry of config.tools?.media?.models ?? []) { - addProviderModelPair({ provider: entry.provider, model: entry.model, refs }); + addProviderModelPair({ + provider: entry.provider, + model: entry.model, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); } for (const entry of config.tools?.media?.image?.models ?? []) { - addProviderModelPair({ provider: entry.provider, model: entry.model, refs }); + addProviderModelPair({ + provider: entry.provider, + model: entry.model, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); } for (const entry of config.tools?.media?.audio?.models ?? []) { - addProviderModelPair({ provider: entry.provider, model: entry.model, refs }); + addProviderModelPair({ + provider: entry.provider, + model: entry.model, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); } for (const entry of config.tools?.media?.video?.models ?? []) { - addProviderModelPair({ provider: entry.provider, model: entry.model, refs }); + addProviderModelPair({ + provider: entry.provider, + model: entry.model, + refs, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, + }); } return Array.from(refs.values()); @@ -810,11 +1009,15 @@ function resolveCatalogPricingForRef(params: { policies: ReadonlyMap; catalogById: Map; catalogByNormalizedId: Map; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): CachedModelPricing | undefined { const candidates = buildExternalCatalogCandidates({ ref: params.ref, source: "openRouter", policies: params.policies, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, }); for (const candidate of candidates) { const exact = params.catalogById.get(candidate); @@ -823,7 +1026,10 @@ function resolveCatalogPricingForRef(params: { } } for (const candidate of candidates) { - const normalized = canonicalizeOpenRouterLookupId(candidate); + const normalized = canonicalizeOpenRouterLookupId(candidate, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }); if (!normalized) { continue; } @@ -839,11 +1045,15 @@ function resolveLiteLLMPricingForRef(params: { ref: ModelRef; policies: ReadonlyMap; catalog: LiteLLMPricingCatalog; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): CachedModelPricing | undefined { for (const candidate of buildExternalCatalogCandidates({ ref: params.ref, source: "liteLLM", policies: params.policies, + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, })) { const pricing = params.catalog.get(candidate); if (pricing) { @@ -867,11 +1077,16 @@ function collectSeededPricing(params: { config: OpenClawConfig; refs: readonly ModelRef[]; catalogPricing: ReadonlyMap; + allowManifestNormalization: boolean; + allowPluginNormalization: boolean; }): Map { const seeded = new Map(); for (const ref of params.refs) { const key = modelKey(ref.provider, ref.model); - const configuredPricing = getConfiguredModelPricing(params.config, ref); + const configuredPricing = getConfiguredModelPricing(params.config, ref, { + allowManifestNormalization: params.allowManifestNormalization, + allowPluginNormalization: params.allowPluginNormalization, + }); if (configuredPricing) { seeded.set(key, configuredPricing); continue; @@ -900,6 +1115,7 @@ export async function refreshGatewayModelPricingCache(params: { pluginLookUpTable: params.pluginLookUpTable, manifestRegistry: params.manifestRegistry, }); + const normalizationOptions = getPricingModelNormalizationOptions(params.config); const pricingContext = loadManifestPricingContext(manifestMetadata.activeRegistry); const allRefs = collectConfiguredModelPricingRefs(params.config, { manifestRegistry: manifestMetadata.allRegistry, @@ -908,12 +1124,16 @@ export async function refreshGatewayModelPricingCache(params: { config: params.config, refs: allRefs, catalogPricing: pricingContext.catalogPricing, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, }); const refs = filterExternalPricingRefs({ config: params.config, refs: allRefs, policies: pricingContext.policies, seededPricing, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, }); if (refs.length === 0) { replaceGatewayModelPricingCache(seededPricing); @@ -940,7 +1160,7 @@ export async function refreshGatewayModelPricingCache(params: { const catalogByNormalizedId = new Map(); for (const entry of catalogById.values()) { - const normalizedId = canonicalizeOpenRouterLookupId(entry.id); + const normalizedId = canonicalizeOpenRouterLookupId(entry.id, normalizationOptions); if (!normalizedId || catalogByNormalizedId.has(normalizedId)) { continue; } @@ -955,6 +1175,8 @@ export async function refreshGatewayModelPricingCache(params: { policies: pricingContext.policies, catalogById, catalogByNormalizedId, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, }); // 2. Try LiteLLM (may contain tiered pricing) @@ -962,6 +1184,8 @@ export async function refreshGatewayModelPricingCache(params: { ref, policies: pricingContext.policies, catalog: litellmCatalog, + allowManifestNormalization: normalizationOptions.allowManifestNormalization, + allowPluginNormalization: normalizationOptions.allowPluginNormalization, }); // Merge strategy: OpenRouter provides the base flat pricing;