diff --git a/src/auto-reply/status.ts b/src/auto-reply/status.ts index 27e2776b684..564b69e33a4 100644 --- a/src/auto-reply/status.ts +++ b/src/auto-reply/status.ts @@ -538,11 +538,13 @@ export function buildStatusMessage(args: StatusArgs): string { cfg: contextConfig, provider: selectedProvider, model: selectedModel, + allowAsyncLoad: false, }); const activeContextTokens = resolveContextTokensForModel({ cfg: contextConfig, ...(contextLookupProvider ? { provider: contextLookupProvider } : {}), model: contextLookupModel, + allowAsyncLoad: false, }); const persistedContextTokens = typeof entry?.contextTokens === "number" && entry.contextTokens > 0 @@ -611,6 +613,7 @@ export function buildStatusMessage(args: StatusArgs): string { model: contextLookupModel, contextTokensOverride: persistedContextTokens ?? args.agent?.contextTokens, fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, + allowAsyncLoad: false, }) ?? DEFAULT_CONTEXT_TOKENS); const thinkLevel = @@ -701,6 +704,7 @@ export function buildStatusMessage(args: StatusArgs): string { provider: activeProvider, model: activeModel, config: args.config, + allowPluginNormalization: false, }) : undefined; const hasUsage = typeof inputTokens === "number" || typeof outputTokens === "number"; diff --git a/src/utils/usage-format.test.ts b/src/utils/usage-format.test.ts index a6832a5615f..1aca3c2c2b5 100644 --- a/src/utils/usage-format.test.ts +++ b/src/utils/usage-format.test.ts @@ -223,4 +223,35 @@ describe("usage-format", () => { cacheWrite: 0, }); }); + + it("can skip plugin-backed model normalization for display-only cost lookup", () => { + const config = { + models: { + providers: { + "google-vertex": { + models: [ + { + id: "gemini-3.1-flash-lite", + cost: { input: 7, output: 8, cacheRead: 0.7, cacheWrite: 0.8 }, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + resolveModelCostConfig({ + provider: "google-vertex", + model: "gemini-3.1-flash-lite", + config, + allowPluginNormalization: false, + }), + ).toEqual({ + input: 7, + output: 8, + cacheRead: 0.7, + cacheWrite: 0.8, + }); + }); }); diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index 9de01e9617e..b32717c37bc 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -1,11 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; -import { normalizeProviderId } from "../agents/provider-id.js"; +import { modelKey, normalizeModelRef, normalizeProviderId } from "../agents/model-selection.js"; import type { NormalizedUsage } from "../agents/usage.js"; import type { OpenClawConfig } from "../config/config.js"; import type { ModelProviderConfig } from "../config/types.models.js"; -import { getCachedGatewayModelPricing } from "../gateway/model-pricing-cache-state.js"; +import { getCachedGatewayModelPricing } from "../gateway/model-pricing-cache.js"; export type ModelCostConfig = { input: number; @@ -25,22 +25,13 @@ export type UsageTotals = { type ModelsJsonCostCache = { path: string; mtimeMs: number; - entries: Map; + providers: Record | undefined; + normalizedEntries: Map | null; + rawEntries: Map | null; }; let modelsJsonCostCache: ModelsJsonCostCache | null = null; -function modelCostKey(provider: string, model: string): string { - const providerId = normalizeProviderId(provider); - const modelId = model.trim(); - if (!providerId || !modelId) { - return ""; - } - return modelId.toLowerCase().startsWith(`${providerId.toLowerCase()}/`) - ? modelId - : `${providerId}/${modelId}`; -} - export function formatTokenCount(value?: number): string { if (value === undefined || !Number.isFinite(value)) { return "0"; @@ -73,18 +64,43 @@ export function formatUsd(value?: number): string | undefined { return `$${value.toFixed(4)}`; } -function toResolvedModelKey(params: { provider?: string; model?: string }): string | null { +function toResolvedModelKey(params: { + provider?: string; + model?: string; + allowPluginNormalization?: boolean; +}): string | null { const provider = params.provider?.trim(); const model = params.model?.trim(); if (!provider || !model) { return null; } - const key = modelCostKey(provider, model); - return key || null; + const normalized = normalizeModelRef(provider, model, { + allowPluginNormalization: params.allowPluginNormalization, + }); + return modelKey(normalized.provider, normalized.model); +} + +function toDirectModelKey(params: { provider?: string; model?: string }): string | null { + const provider = normalizeProviderId(params.provider?.trim() ?? ""); + const model = params.model?.trim(); + if (!provider || !model) { + return null; + } + return modelKey(provider, model); +} + +function shouldUseNormalizedCostLookup(params: { provider?: string; model?: string }): boolean { + const provider = normalizeProviderId(params.provider?.trim() ?? ""); + const model = params.model?.trim() ?? ""; + if (!provider || !model) { + return false; + } + return provider === "anthropic" || provider === "openrouter" || provider === "vercel-ai-gateway"; } function buildProviderCostIndex( providers: Record | undefined, + options?: { allowPluginNormalization?: boolean }, ): Map { const entries = new Map(); if (!providers) { @@ -93,44 +109,56 @@ function buildProviderCostIndex( for (const [providerKey, providerConfig] of Object.entries(providers)) { const normalizedProvider = normalizeProviderId(providerKey); for (const model of providerConfig?.models ?? []) { - const key = modelCostKey(normalizedProvider, model.id); - if (!key) { - continue; - } - entries.set(key, model.cost); + const normalized = normalizeModelRef(normalizedProvider, model.id, { + allowPluginNormalization: options?.allowPluginNormalization, + }); + entries.set(modelKey(normalized.provider, normalized.model), model.cost); } } return entries; } -function loadModelsJsonCostIndex(): Map { +function loadModelsJsonCostIndex(options?: { + allowPluginNormalization?: boolean; +}): Map { + const useRawEntries = options?.allowPluginNormalization === false; const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); try { const stat = fs.statSync(modelsPath); if ( - modelsJsonCostCache && - modelsJsonCostCache.path === modelsPath && - modelsJsonCostCache.mtimeMs === stat.mtimeMs + !modelsJsonCostCache || + modelsJsonCostCache.path !== modelsPath || + modelsJsonCostCache.mtimeMs !== stat.mtimeMs ) { - return modelsJsonCostCache.entries; + const parsed = JSON.parse(fs.readFileSync(modelsPath, "utf8")) as { + providers?: Record; + }; + modelsJsonCostCache = { + path: modelsPath, + mtimeMs: stat.mtimeMs, + providers: parsed.providers, + normalizedEntries: null, + rawEntries: null, + }; } - const parsed = JSON.parse(fs.readFileSync(modelsPath, "utf8")) as { - providers?: Record; - }; - const entries = buildProviderCostIndex(parsed.providers); - modelsJsonCostCache = { - path: modelsPath, - mtimeMs: stat.mtimeMs, - entries, - }; - return entries; + if (useRawEntries) { + modelsJsonCostCache.rawEntries ??= buildProviderCostIndex(modelsJsonCostCache.providers, { + allowPluginNormalization: false, + }); + return modelsJsonCostCache.rawEntries; + } + + modelsJsonCostCache.normalizedEntries ??= buildProviderCostIndex(modelsJsonCostCache.providers); + return modelsJsonCostCache.normalizedEntries; } catch { const empty = new Map(); modelsJsonCostCache = { path: modelsPath, mtimeMs: -1, - entries: empty, + providers: undefined, + normalizedEntries: empty, + rawEntries: empty, }; return empty; } @@ -140,32 +168,62 @@ function findConfiguredProviderCost(params: { provider?: string; model?: string; config?: OpenClawConfig; + allowPluginNormalization?: boolean; }): ModelCostConfig | undefined { const key = toResolvedModelKey(params); if (!key) { return undefined; } - return buildProviderCostIndex(params.config?.models?.providers).get(key); + return buildProviderCostIndex(params.config?.models?.providers, { + allowPluginNormalization: params.allowPluginNormalization, + }).get(key); } export function resolveModelCostConfig(params: { provider?: string; model?: string; config?: OpenClawConfig; + allowPluginNormalization?: boolean; }): ModelCostConfig | undefined { - const key = toResolvedModelKey(params); - if (!key) { + const rawKey = toDirectModelKey(params); + if (!rawKey) { return undefined; } - const modelsJsonCost = loadModelsJsonCostIndex().get(key); - if (modelsJsonCost) { - return modelsJsonCost; + // Favor direct configured keys first so local pricing/status lookups stay + // synchronous and do not drag plugin/provider discovery into the hot path. + const rawModelsJsonCost = loadModelsJsonCostIndex({ + allowPluginNormalization: false, + }).get(rawKey); + if (rawModelsJsonCost) { + return rawModelsJsonCost; } - const configuredCost = findConfiguredProviderCost(params); - if (configuredCost) { - return configuredCost; + const rawConfiguredCost = findConfiguredProviderCost({ + ...params, + allowPluginNormalization: false, + }); + if (rawConfiguredCost) { + return rawConfiguredCost; + } + + if (params.allowPluginNormalization === false) { + return undefined; + } + + if (shouldUseNormalizedCostLookup(params)) { + const key = toResolvedModelKey(params); + if (key && key !== rawKey) { + const modelsJsonCost = loadModelsJsonCostIndex().get(key); + if (modelsJsonCost) { + return modelsJsonCost; + } + + const configuredCost = findConfiguredProviderCost(params); + if (configuredCost) { + return configuredCost; + } + } } return getCachedGatewayModelPricing(params);