Gateway: cache OpenRouter pricing for configured models

This commit is contained in:
Tyler Yust 2026-03-12 17:57:31 -07:00
parent de22f822e0
commit 61c9cc812c
5 changed files with 888 additions and 8 deletions

View File

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

View File

@ -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<string, string> = {
"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<string, CachedModelPricing>();
let cachedAt = 0;
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
let inFlightRefresh: Promise<void> | 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<string, unknown>;
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<string>();
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<typeof buildModelAliasIndex>;
refs: Map<string, ModelRef>;
}): 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<typeof buildModelAliasIndex>;
refs: Map<string, ModelRef>;
}): 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<string, ModelRef>;
}): 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<string, ModelRef>();
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<Map<string, OpenRouterPricingEntry>> {
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<string, OpenRouterPricingEntry>();
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<string, OpenRouterPricingEntry>;
catalogByNormalizedId: Map<string, OpenRouterPricingEntry>;
}): 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<void> {
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<string, OpenRouterPricingEntry>();
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<string, CachedModelPricing>();
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();
}

View File

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

View File

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

View File

@ -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<string, ModelCostConfig>;
};
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<string, ModelProviderConfig> | undefined,
): Map<string, ModelCostConfig> {
const entries = new Map<string, ModelCostConfig>();
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<string, ModelCostConfig> {
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<string, ModelProviderConfig>;
};
const entries = buildProviderCostIndex(parsed.providers);
modelsJsonCostCache = {
path: modelsPath,
mtimeMs: stat.mtimeMs,
entries,
};
return entries;
} catch {
const empty = new Map<string, ModelCostConfig>();
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;
}