fix(gateway): skip plugin pricing scans when disabled

This commit is contained in:
Peter Steinberger
2026-04-28 08:23:49 +01:00
parent 2cfe8e17f5
commit 04e96c11ea
6 changed files with 371 additions and 49 deletions

View File

@@ -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.

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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<string, { alias: string; ref: ModelRef }>();
@@ -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) {

View File

@@ -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<typeof normalizeProviderModelIdWithPlugin>(({ context }) => context.modelId),
const normalizeProviderModelIdWithRuntimeMock = vi.hoisted(() =>
vi.fn<typeof normalizeProviderModelIdWithRuntime>(({ 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<typeof import("../plugins/manifest-metadata-scan.js")>();
return {
...actual,
listOpenClawPluginManifestMetadata: (
params?: Parameters<typeof actual.listOpenClawPluginManifestMetadata>[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<typeof fetch>();
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: {

View File

@@ -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<string, ExternalPricingPolicy>;
seen?: Set<string>;
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<typeof buildModelAliasIndex>;
refs: Map<string, ModelRef>;
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<typeof buildModelAliasIndex>;
refs: Map<string, ModelRef>;
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<string, ModelRef>;
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<typeof buildModelAliasIndex>;
refs: Map<string, ModelRef>;
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<string, ExternalPricingPolicy>;
seededPricing: ReadonlyMap<string, CachedModelPricing>;
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<string, ExternalPricingPolicy>;
seededPricing: ReadonlyMap<string, CachedModelPricing>;
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<string, ModelRef>();
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<string, ExternalPricingPolicy>;
catalogById: Map<string, OpenRouterPricingEntry>;
catalogByNormalizedId: Map<string, OpenRouterPricingEntry>;
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<string, ExternalPricingPolicy>;
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<string, CachedModelPricing>;
allowManifestNormalization: boolean;
allowPluginNormalization: boolean;
}): Map<string, CachedModelPricing> {
const seeded = new Map<string, CachedModelPricing>();
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<string, OpenRouterPricingEntry>();
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;