diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts new file mode 100644 index 00000000000..8ce128d4938 --- /dev/null +++ b/src/gateway/model-pricing-cache.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { modelKey } from "../agents/model-selection.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { + __resetGatewayModelPricingCacheForTest, + collectConfiguredModelPricingRefs, + getCachedGatewayModelPricing, + refreshGatewayModelPricingCache, +} from "./model-pricing-cache.js"; + +describe("model-pricing-cache", () => { + beforeEach(() => { + __resetGatewayModelPricingCacheForTest(); + }); + + afterEach(() => { + __resetGatewayModelPricingCacheForTest(); + }); + + it("collects configured model refs across defaults, aliases, overrides, and media tools", () => { + const config = { + agents: { + defaults: { + model: { primary: "gpt", fallbacks: ["anthropic/claude-sonnet-4-6"] }, + imageModel: { primary: "google/gemini-3-pro" }, + compaction: { model: "opus" }, + heartbeat: { model: "xai/grok-4" }, + models: { + "openai/gpt-5.4": { alias: "gpt" }, + "anthropic/claude-opus-4-6": { alias: "opus" }, + }, + }, + list: [ + { + id: "router", + model: { primary: "openrouter/anthropic/claude-opus-4-6" }, + subagents: { model: { primary: "openrouter/auto" } }, + heartbeat: { model: "anthropic/claude-opus-4-6" }, + }, + ], + }, + channels: { + modelByChannel: { + slack: { + C123: "gpt", + }, + }, + }, + hooks: { + gmail: { model: "anthropic/claude-opus-4-6" }, + mappings: [{ model: "zai/glm-5" }], + }, + tools: { + subagents: { model: { primary: "anthropic/claude-haiku-4-5" } }, + media: { + models: [{ provider: "google", model: "gemini-2.5-pro" }], + image: { + models: [{ provider: "xai", model: "grok-4" }], + }, + }, + }, + messages: { + tts: { + summaryModel: "openai/gpt-5.4", + }, + }, + } as unknown as OpenClawConfig; + + const refs = collectConfiguredModelPricingRefs(config).map((ref) => + modelKey(ref.provider, ref.model), + ); + + expect(refs).toEqual( + expect.arrayContaining([ + "openai/gpt-5.4", + "anthropic/claude-sonnet-4-6", + "google/gemini-3-pro-preview", + "anthropic/claude-opus-4-6", + "xai/grok-4", + "openrouter/anthropic/claude-opus-4-6", + "openrouter/auto", + "zai/glm-5", + "anthropic/claude-haiku-4-5", + "google/gemini-2.5-pro", + ]), + ); + expect(new Set(refs).size).toBe(refs.length); + }); + + it("loads openrouter pricing and maps provider aliases, wrappers, and anthropic dotted ids", async () => { + const config = { + agents: { + defaults: { + model: { primary: "anthropic/claude-opus-4-6" }, + }, + list: [ + { + id: "router", + model: { primary: "openrouter/anthropic/claude-sonnet-4-6" }, + }, + ], + }, + hooks: { + mappings: [{ model: "xai/grok-4" }], + }, + tools: { + subagents: { model: { primary: "zai/glm-5" } }, + }, + } as unknown as OpenClawConfig; + + const fetchImpl: typeof fetch = async () => + new Response( + JSON.stringify({ + data: [ + { + id: "anthropic/claude-opus-4.6", + pricing: { + prompt: "0.000005", + completion: "0.000025", + input_cache_read: "0.0000005", + input_cache_write: "0.00000625", + }, + }, + { + id: "anthropic/claude-sonnet-4.6", + pricing: { + prompt: "0.000003", + completion: "0.000015", + input_cache_read: "0.0000003", + }, + }, + { + id: "x-ai/grok-4", + pricing: { + prompt: "0.000002", + completion: "0.00001", + }, + }, + { + id: "z-ai/glm-5", + pricing: { + prompt: "0.000001", + completion: "0.000004", + }, + }, + ], + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); + + await refreshGatewayModelPricingCache({ config, fetchImpl }); + + expect( + getCachedGatewayModelPricing({ provider: "anthropic", model: "claude-opus-4-6" }), + ).toEqual({ + input: 5, + output: 25, + cacheRead: 0.5, + cacheWrite: 6.25, + }); + expect( + getCachedGatewayModelPricing({ + provider: "openrouter", + model: "anthropic/claude-sonnet-4-6", + }), + ).toEqual({ + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0, + }); + expect(getCachedGatewayModelPricing({ provider: "xai", model: "grok-4" })).toEqual({ + input: 2, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }); + expect(getCachedGatewayModelPricing({ provider: "zai", model: "glm-5" })).toEqual({ + input: 1, + output: 4, + cacheRead: 0, + cacheWrite: 0, + }); + }); +}); diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts new file mode 100644 index 00000000000..8a2e250f53f --- /dev/null +++ b/src/gateway/model-pricing-cache.ts @@ -0,0 +1,469 @@ +import { DEFAULT_PROVIDER } from "../agents/defaults.js"; +import { + buildModelAliasIndex, + modelKey, + normalizeModelRef, + parseModelRef, + resolveModelRefFromString, + type ModelRef, +} from "../agents/model-selection.js"; +import { normalizeGoogleModelId } from "../agents/models-config.providers.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; + +export type CachedModelPricing = { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +}; + +type OpenRouterPricingEntry = { + id: string; + pricing: CachedModelPricing; +}; + +type ModelListLike = string | { primary?: string; fallbacks?: string[] } | undefined; + +type OpenRouterModelPayload = { + id?: unknown; + pricing?: unknown; +}; + +const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"; +const CACHE_TTL_MS = 24 * 60 * 60_000; +const FETCH_TIMEOUT_MS = 15_000; +const PROVIDER_ALIAS_TO_OPENROUTER: Record = { + "google-gemini-cli": "google", + kimi: "moonshotai", + "kimi-coding": "moonshotai", + moonshot: "moonshotai", + moonshotai: "moonshotai", + "openai-codex": "openai", + qwen: "qwen", + "qwen-portal": "qwen", + xai: "x-ai", + zai: "z-ai", +}; +const WRAPPER_PROVIDERS = new Set([ + "cloudflare-ai-gateway", + "kilocode", + "openrouter", + "vercel-ai-gateway", +]); + +const log = createSubsystemLogger("gateway").child("model-pricing"); + +let cachedPricing = new Map(); +let cachedAt = 0; +let refreshTimer: ReturnType | null = null; +let inFlightRefresh: Promise | null = null; + +function clearRefreshTimer(): void { + if (!refreshTimer) { + return; + } + clearTimeout(refreshTimer); + refreshTimer = null; +} + +function listLikePrimary(value: ModelListLike): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + const trimmed = value?.primary?.trim(); + return trimmed || undefined; +} + +function listLikeFallbacks(value: ModelListLike): string[] { + if (!value || typeof value !== "object") { + return []; + } + return Array.isArray(value.fallbacks) + ? value.fallbacks + .filter((entry): entry is string => typeof entry === "string") + .map((entry) => entry.trim()) + .filter(Boolean) + : []; +} + +function parseNumberString(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +function toPricePerMillion(value: number | null): number { + if (value === null || value < 0 || !Number.isFinite(value)) { + return 0; + } + return value * 1_000_000; +} + +function parseOpenRouterPricing(value: unknown): CachedModelPricing | null { + if (!value || typeof value !== "object") { + return null; + } + const pricing = value as Record; + const prompt = parseNumberString(pricing.prompt); + const completion = parseNumberString(pricing.completion); + if (prompt === null || completion === null) { + return null; + } + return { + input: toPricePerMillion(prompt), + output: toPricePerMillion(completion), + cacheRead: toPricePerMillion(parseNumberString(pricing.input_cache_read)), + cacheWrite: toPricePerMillion(parseNumberString(pricing.input_cache_write)), + }; +} + +function canonicalizeOpenRouterProvider(provider: string): string { + const normalized = normalizeModelRef(provider, "placeholder").provider; + return PROVIDER_ALIAS_TO_OPENROUTER[normalized] ?? normalized; +} + +function canonicalizeOpenRouterLookupId(id: string): string { + const trimmed = id.trim(); + if (!trimmed) { + return ""; + } + const slash = trimmed.indexOf("/"); + if (slash === -1) { + return trimmed; + } + const provider = canonicalizeOpenRouterProvider(trimmed.slice(0, slash)); + let model = trimmed.slice(slash + 1).trim(); + if (!model) { + return provider; + } + if (provider === "anthropic") { + model = model + .replace(/^claude-(\d+)\.(\d+)-/u, "claude-$1-$2-") + .replace(/^claude-([a-z]+)-(\d+)\.(\d+)$/u, "claude-$1-$2-$3"); + } + if (provider === "google") { + model = normalizeGoogleModelId(model); + } + return `${provider}/${model}`; +} + +function buildOpenRouterExactCandidates(ref: ModelRef): string[] { + const candidates = new Set(); + const canonicalProvider = canonicalizeOpenRouterProvider(ref.provider); + const canonicalFullId = canonicalizeOpenRouterLookupId(modelKey(canonicalProvider, ref.model)); + if (canonicalFullId) { + candidates.add(canonicalFullId); + } + + if (canonicalProvider === "anthropic") { + const slash = canonicalFullId.indexOf("/"); + const model = slash === -1 ? canonicalFullId : canonicalFullId.slice(slash + 1); + const dotted = model + .replace(/^claude-(\d+)-(\d+)-/u, "claude-$1.$2-") + .replace(/^claude-([a-z]+)-(\d+)-(\d+)$/u, "claude-$1-$2.$3"); + candidates.add(`${canonicalProvider}/${dotted}`); + } + + if (WRAPPER_PROVIDERS.has(ref.provider) && ref.model.includes("/")) { + const nestedRef = parseModelRef(ref.model, DEFAULT_PROVIDER); + if (nestedRef) { + for (const candidate of buildOpenRouterExactCandidates(nestedRef)) { + candidates.add(candidate); + } + } + } + + return Array.from(candidates).filter(Boolean); +} + +function addResolvedModelRef(params: { + raw: string | undefined; + aliasIndex: ReturnType; + refs: Map; +}): void { + const raw = params.raw?.trim(); + if (!raw) { + return; + } + const resolved = resolveModelRefFromString({ + raw, + defaultProvider: DEFAULT_PROVIDER, + aliasIndex: params.aliasIndex, + }); + if (!resolved) { + return; + } + const normalized = normalizeModelRef(resolved.ref.provider, resolved.ref.model); + params.refs.set(modelKey(normalized.provider, normalized.model), normalized); +} + +function addModelListLike(params: { + value: ModelListLike; + aliasIndex: ReturnType; + refs: Map; +}): void { + addResolvedModelRef({ + raw: listLikePrimary(params.value), + aliasIndex: params.aliasIndex, + refs: params.refs, + }); + for (const fallback of listLikeFallbacks(params.value)) { + addResolvedModelRef({ + raw: fallback, + aliasIndex: params.aliasIndex, + refs: params.refs, + }); + } +} + +function addProviderModelPair(params: { + provider: string | undefined; + model: string | undefined; + refs: Map; +}): void { + const provider = params.provider?.trim(); + const model = params.model?.trim(); + if (!provider || !model) { + return; + } + const normalized = normalizeModelRef(provider, model); + params.refs.set(modelKey(normalized.provider, normalized.model), normalized); +} + +export function collectConfiguredModelPricingRefs(config: OpenClawConfig): ModelRef[] { + const refs = new Map(); + const aliasIndex = buildModelAliasIndex({ + cfg: config, + defaultProvider: DEFAULT_PROVIDER, + }); + + 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 }); + + 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 }); + } + + for (const mapping of config.hooks?.mappings ?? []) { + addResolvedModelRef({ raw: mapping.model, aliasIndex, refs }); + } + + for (const channelMap of Object.values(config.channels?.modelByChannel ?? {})) { + if (!channelMap || typeof channelMap !== "object") { + continue; + } + for (const raw of Object.values(channelMap)) { + addResolvedModelRef({ + raw: typeof raw === "string" ? raw : undefined, + aliasIndex, + refs, + }); + } + } + + addResolvedModelRef({ raw: config.tools?.web?.search?.gemini?.model, aliasIndex, refs }); + addResolvedModelRef({ raw: config.tools?.web?.search?.grok?.model, aliasIndex, refs }); + addResolvedModelRef({ raw: config.tools?.web?.search?.kimi?.model, aliasIndex, refs }); + addResolvedModelRef({ raw: config.tools?.web?.search?.perplexity?.model, aliasIndex, refs }); + + for (const entry of config.tools?.media?.models ?? []) { + addProviderModelPair({ provider: entry.provider, model: entry.model, refs }); + } + for (const entry of config.tools?.media?.image?.models ?? []) { + addProviderModelPair({ provider: entry.provider, model: entry.model, refs }); + } + for (const entry of config.tools?.media?.audio?.models ?? []) { + addProviderModelPair({ provider: entry.provider, model: entry.model, refs }); + } + for (const entry of config.tools?.media?.video?.models ?? []) { + addProviderModelPair({ provider: entry.provider, model: entry.model, refs }); + } + + return Array.from(refs.values()); +} + +async function fetchOpenRouterPricingCatalog( + fetchImpl: typeof fetch, +): Promise> { + const response = await fetchImpl(OPENROUTER_MODELS_URL, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`OpenRouter /models failed: HTTP ${response.status}`); + } + const payload = (await response.json()) as { data?: unknown }; + const entries = Array.isArray(payload.data) ? payload.data : []; + const catalog = new Map(); + for (const entry of entries) { + const obj = entry as OpenRouterModelPayload; + const id = typeof obj.id === "string" ? obj.id.trim() : ""; + const pricing = parseOpenRouterPricing(obj.pricing); + if (!id || !pricing) { + continue; + } + catalog.set(id, { id, pricing }); + } + return catalog; +} + +function resolveCatalogPricingForRef(params: { + ref: ModelRef; + catalogById: Map; + catalogByNormalizedId: Map; +}): CachedModelPricing | undefined { + for (const candidate of buildOpenRouterExactCandidates(params.ref)) { + const exact = params.catalogById.get(candidate); + if (exact) { + return exact.pricing; + } + } + for (const candidate of buildOpenRouterExactCandidates(params.ref)) { + const normalized = canonicalizeOpenRouterLookupId(candidate); + if (!normalized) { + continue; + } + const match = params.catalogByNormalizedId.get(normalized); + if (match) { + return match.pricing; + } + } + return undefined; +} + +function scheduleRefresh(params: { config: OpenClawConfig; fetchImpl: typeof fetch }): void { + clearRefreshTimer(); + refreshTimer = setTimeout(() => { + refreshTimer = null; + void refreshGatewayModelPricingCache(params).catch((error: unknown) => { + log.warn(`pricing refresh failed: ${String(error)}`); + }); + }, CACHE_TTL_MS); +} + +export async function refreshGatewayModelPricingCache(params: { + config: OpenClawConfig; + fetchImpl?: typeof fetch; +}): Promise { + if (inFlightRefresh) { + return await inFlightRefresh; + } + const fetchImpl = params.fetchImpl ?? fetch; + inFlightRefresh = (async () => { + const refs = collectConfiguredModelPricingRefs(params.config); + if (refs.length === 0) { + cachedPricing = new Map(); + cachedAt = Date.now(); + clearRefreshTimer(); + return; + } + + const catalogById = await fetchOpenRouterPricingCatalog(fetchImpl); + const catalogByNormalizedId = new Map(); + for (const entry of catalogById.values()) { + const normalizedId = canonicalizeOpenRouterLookupId(entry.id); + if (!normalizedId || catalogByNormalizedId.has(normalizedId)) { + continue; + } + catalogByNormalizedId.set(normalizedId, entry); + } + + const nextPricing = new Map(); + for (const ref of refs) { + const pricing = resolveCatalogPricingForRef({ + ref, + catalogById, + catalogByNormalizedId, + }); + if (!pricing) { + continue; + } + nextPricing.set(modelKey(ref.provider, ref.model), pricing); + } + + cachedPricing = nextPricing; + cachedAt = Date.now(); + scheduleRefresh({ config: params.config, fetchImpl }); + })(); + + try { + await inFlightRefresh; + } finally { + inFlightRefresh = null; + } +} + +export function startGatewayModelPricingRefresh(params: { + config: OpenClawConfig; + fetchImpl?: typeof fetch; +}): () => void { + void refreshGatewayModelPricingCache(params).catch((error: unknown) => { + log.warn(`pricing bootstrap failed: ${String(error)}`); + }); + return () => { + clearRefreshTimer(); + }; +} + +export function getCachedGatewayModelPricing(params: { + provider?: string; + model?: string; +}): CachedModelPricing | undefined { + const provider = params.provider?.trim(); + const model = params.model?.trim(); + if (!provider || !model) { + return undefined; + } + const normalized = normalizeModelRef(provider, model); + return cachedPricing.get(modelKey(normalized.provider, normalized.model)); +} + +export function getGatewayModelPricingCacheMeta(): { + cachedAt: number; + ttlMs: number; + size: number; +} { + return { + cachedAt, + ttlMs: CACHE_TTL_MS, + size: cachedPricing.size, + }; +} + +export function __resetGatewayModelPricingCacheForTest(): void { + cachedPricing = new Map(); + cachedAt = 0; + clearRefreshTimer(); + inFlightRefresh = null; +} + +export function __setGatewayModelPricingForTest( + entries: Array<{ provider: string; model: string; pricing: CachedModelPricing }>, +): void { + cachedPricing = new Map( + entries.map((entry) => { + const normalized = normalizeModelRef(entry.provider, entry.model); + return [modelKey(normalized.provider, normalized.model), entry.pricing] as const; + }), + ); + cachedAt = Date.now(); +} diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 98b8122a456..79e131f9c5c 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -74,6 +74,7 @@ import { type GatewayUpdateAvailableEventPayload, } from "./events.js"; import { ExecApprovalManager } from "./exec-approval-manager.js"; +import { startGatewayModelPricingRefresh } from "./model-pricing-cache.js"; import { NodeRegistry } from "./node-registry.js"; import type { startBrowserControlServerIfEnabled } from "./server-browser.js"; import { createChannelManager } from "./server-channels.js"; @@ -856,6 +857,11 @@ export async function startGatewayServer( void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`)); } + const stopModelPricingRefresh = + !minimalTestGateway && process.env.VITEST !== "1" + ? startGatewayModelPricingRefresh({ config: cfgAtStart }) + : () => {}; + // Recover pending outbound deliveries from previous crash/restart. if (!minimalTestGateway) { void (async () => { @@ -1160,6 +1166,7 @@ export async function startGatewayServer( skillsChangeUnsub(); authRateLimiter?.dispose(); browserAuthRateLimiter.dispose(); + stopModelPricingRefresh(); channelHealthMonitor?.stop(); clearSecretsRuntimeSnapshot(); await close(opts); diff --git a/src/utils/usage-format.test.ts b/src/utils/usage-format.test.ts index c6926b99111..d70fd1c3b28 100644 --- a/src/utils/usage-format.test.ts +++ b/src/utils/usage-format.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { + __resetGatewayModelPricingCacheForTest, + __setGatewayModelPricingForTest, +} from "../gateway/model-pricing-cache.js"; +import { + __resetUsageFormatCachesForTest, estimateUsageCost, formatTokenCount, formatUsd, @@ -8,6 +16,27 @@ import { } from "./usage-format.js"; describe("usage-format", () => { + const originalAgentDir = process.env.OPENCLAW_AGENT_DIR; + let agentDir: string; + + beforeEach(async () => { + agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-format-")); + process.env.OPENCLAW_AGENT_DIR = agentDir; + __resetUsageFormatCachesForTest(); + __resetGatewayModelPricingCacheForTest(); + }); + + afterEach(async () => { + if (originalAgentDir === undefined) { + delete process.env.OPENCLAW_AGENT_DIR; + } else { + process.env.OPENCLAW_AGENT_DIR = originalAgentDir; + } + __resetUsageFormatCachesForTest(); + __resetGatewayModelPricingCacheForTest(); + await fs.rm(agentDir, { recursive: true, force: true }); + }); + it("formats token counts", () => { expect(formatTokenCount(999)).toBe("999"); expect(formatTokenCount(1234)).toBe("1.2k"); @@ -76,7 +105,66 @@ describe("usage-format", () => { ).toBeUndefined(); }); - it("uses configured pricing when present", () => { + it("prefers models.json pricing over openclaw config and cached pricing", async () => { + const config = { + models: { + providers: { + openai: { + models: [ + { + id: "gpt-5.4", + cost: { input: 20, output: 21, cacheRead: 22, cacheWrite: 23 }, + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + await fs.writeFile( + path.join(agentDir, "models.json"), + JSON.stringify( + { + providers: { + openai: { + models: [ + { + id: "gpt-5.4", + cost: { input: 10, output: 11, cacheRead: 12, cacheWrite: 13 }, + }, + ], + }, + }, + }, + null, + 2, + ), + "utf8", + ); + + __setGatewayModelPricingForTest([ + { + provider: "openai", + model: "gpt-5.4", + pricing: { input: 30, output: 31, cacheRead: 32, cacheWrite: 33 }, + }, + ]); + + expect( + resolveModelCostConfig({ + provider: "openai", + model: "gpt-5.4", + config, + }), + ).toEqual({ + input: 10, + output: 11, + cacheRead: 12, + cacheWrite: 13, + }); + }); + + it("falls back to openclaw config pricing when models.json is absent", () => { const config = { models: { providers: { @@ -92,6 +180,14 @@ describe("usage-format", () => { }, } as unknown as OpenClawConfig; + __setGatewayModelPricingForTest([ + { + provider: "anthropic", + model: "claude-sonnet-4-6", + pricing: { input: 3, output: 4, cacheRead: 0.3, cacheWrite: 0.4 }, + }, + ]); + expect( resolveModelCostConfig({ provider: "anthropic", @@ -105,4 +201,26 @@ describe("usage-format", () => { cacheWrite: 1.9, }); }); + + it("falls back to cached gateway pricing when no configured cost exists", () => { + __setGatewayModelPricingForTest([ + { + provider: "openai-codex", + model: "gpt-5.4", + pricing: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 }, + }, + ]); + + expect( + resolveModelCostConfig({ + provider: "openai-codex", + model: "gpt-5.4", + }), + ).toEqual({ + input: 2.5, + output: 15, + cacheRead: 0.25, + cacheWrite: 0, + }); + }); }); diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index 1086163bf20..96956cfb4a3 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -1,5 +1,11 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveOpenClawAgentDir } from "../agents/agent-paths.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.js"; export type ModelCostConfig = { input: number; @@ -16,6 +22,14 @@ export type UsageTotals = { total?: number; }; +type ModelsJsonCostCache = { + path: string; + mtimeMs: number; + entries: Map; +}; + +let modelsJsonCostCache: ModelsJsonCostCache | null = null; + export function formatTokenCount(value?: number): string { if (value === undefined || !Number.isFinite(value)) { return "0"; @@ -48,19 +62,99 @@ export function formatUsd(value?: number): string | undefined { return `$${value.toFixed(4)}`; } +function toResolvedModelKey(params: { provider?: string; model?: string }): string | null { + const provider = params.provider?.trim(); + const model = params.model?.trim(); + if (!provider || !model) { + return null; + } + const normalized = normalizeModelRef(provider, model); + return modelKey(normalized.provider, normalized.model); +} + +function buildProviderCostIndex( + providers: Record | undefined, +): Map { + const entries = new Map(); + if (!providers) { + return entries; + } + for (const [providerKey, providerConfig] of Object.entries(providers)) { + const normalizedProvider = normalizeProviderId(providerKey); + for (const model of providerConfig?.models ?? []) { + const normalized = normalizeModelRef(normalizedProvider, model.id); + entries.set(modelKey(normalized.provider, normalized.model), model.cost); + } + } + return entries; +} + +function loadModelsJsonCostIndex(): Map { + const modelsPath = path.join(resolveOpenClawAgentDir(), "models.json"); + try { + const stat = fs.statSync(modelsPath); + if ( + modelsJsonCostCache && + modelsJsonCostCache.path === modelsPath && + modelsJsonCostCache.mtimeMs === stat.mtimeMs + ) { + return modelsJsonCostCache.entries; + } + + 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; + } catch { + const empty = new Map(); + modelsJsonCostCache = { + path: modelsPath, + mtimeMs: -1, + entries: empty, + }; + return empty; + } +} + +function findConfiguredProviderCost(params: { + provider?: string; + model?: string; + config?: OpenClawConfig; +}): ModelCostConfig | undefined { + const key = toResolvedModelKey(params); + if (!key) { + return undefined; + } + return buildProviderCostIndex(params.config?.models?.providers).get(key); +} + export function resolveModelCostConfig(params: { provider?: string; model?: string; config?: OpenClawConfig; }): ModelCostConfig | undefined { - const provider = params.provider?.trim(); - const model = params.model?.trim(); - if (!provider || !model) { + const key = toResolvedModelKey(params); + if (!key) { return undefined; } - const providers = params.config?.models?.providers ?? {}; - const entry = providers[provider]?.models?.find((item) => item.id === model); - return entry?.cost; + + const modelsJsonCost = loadModelsJsonCostIndex().get(key); + if (modelsJsonCost) { + return modelsJsonCost; + } + + const configuredCost = findConfiguredProviderCost(params); + if (configuredCost) { + return configuredCost; + } + + return getCachedGatewayModelPricing(params); } const toNumber = (value: number | undefined): number => @@ -89,3 +183,7 @@ export function estimateUsageCost(params: { } return total / 1_000_000; } + +export function __resetUsageFormatCachesForTest(): void { + modelsJsonCostCache = null; +}