From 16702496c67f99c8a7eaf476eaf8d298b4a768ce Mon Sep 17 00:00:00 2001 From: Michael Zelbel Date: Sun, 24 May 2026 15:40:34 +0200 Subject: [PATCH] fix(usage-cost): only flag catalog-default zeros, preserve operator-configured $0 Address review: distinguish unknown pricing from an intentional free price. A turn's all-zero cost is treated as unknown (counted toward missingCostEntries) only when the operator did NOT explicitly configure the model's price under models.providers -- i.e. the zero is a generated-catalog default (codex/gpt-5.x), not a deliberate $0. Operator-configured zero-cost models keep reporting a complete $0. Adds resolveConfiguredModelCost() to read config-only pricing, and regression tests for both paths (unconfigured unknown -> missing; configured free -> $0). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/infra/session-cost-usage.test.ts | 59 ++++++++++++++++++++++++---- src/infra/session-cost-usage.ts | 21 +++++++--- src/utils/usage-format.ts | 16 ++++++++ 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/infra/session-cost-usage.test.ts b/src/infra/session-cost-usage.test.ts index 36754f987de..1cbb2aa49a0 100644 --- a/src/infra/session-cost-usage.test.ts +++ b/src/infra/session-cost-usage.test.ts @@ -149,14 +149,14 @@ describe("session cost usage", () => { }); }); - it("counts token usage for an unpriced (all-zero cost) model as missing, not a confident $0", async () => { + it("counts token usage for an unpriced (unconfigured all-zero) model as missing, not a confident $0", async () => { const root = await makeSessionCostRoot("cost-unknown-pricing"); const sessionsDir = path.join(root, "agents", "main", "sessions"); await fs.mkdir(sessionsDir, { recursive: true }); // A real assistant turn that burned tokens. The transport recorded cost.total: 0, - // derived from the model's all-zero catalog pricing — exactly what codex/gpt-5.x - // models produce, since the Codex backend exposes no per-token price. + // derived from an all-zero catalog price — exactly what codex/gpt-5.x models produce, + // since the Codex backend exposes no per-token price and the operator never set one. const entry = { type: "message", timestamp: new Date().toISOString(), @@ -181,7 +181,52 @@ describe("session cost usage", () => { "utf-8", ); - // The model resolves to an all-zero cost config, i.e. its pricing is unknown. + // No operator-configured pricing for this model, so its all-zero cost is unknown, + // not an intentional "free" price. + clearGatewayModelPricingCacheState(); + await withStateDir(root, async () => { + const summary = await loadCostUsageSummary({ days: 30 }); + expect(summary.totals.totalTokens).toBe(23287); + expect(summary.totals.totalCost).toBe(0); + // Unknown pricing must be surfaced as missing rather than reported as a + // confident $0 that would blind budget/spike monitoring to real spend. + expect(summary.totals.missingCostEntries).toBe(1); + }); + }); + + it("preserves an operator-configured zero-cost model as a complete $0, not missing", async () => { + const root = await makeSessionCostRoot("cost-intentional-free"); + const sessionsDir = path.join(root, "agents", "main", "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + + // Same shape of turn, but here the operator deliberately priced the model at 0 + // (e.g. a local/self-hosted free model). That intentional $0 must be respected. + const entry = { + type: "message", + timestamp: new Date().toISOString(), + message: { + role: "assistant", + provider: "openai", + model: "gpt-5.5", + usage: { + input: 881, + output: 6, + cacheRead: 22400, + cacheWrite: 0, + totalTokens: 23287, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }, + }; + + await fs.writeFile( + path.join(sessionsDir, "sess-1.jsonl"), + transcriptText("sess-1", entry), + "utf-8", + ); + + // The operator explicitly configured this model's price as 0 — an intentional + // "free" price that must be preserved as complete $0 cost data. const config = { models: { providers: { @@ -197,13 +242,13 @@ describe("session cost usage", () => { }, } as unknown as OpenClawConfig; + clearGatewayModelPricingCacheState(); await withStateDir(root, async () => { const summary = await loadCostUsageSummary({ days: 30, config }); expect(summary.totals.totalTokens).toBe(23287); expect(summary.totals.totalCost).toBe(0); - // Unknown pricing must be surfaced as missing rather than reported as a - // confident $0 that would blind budget/spike monitoring to real spend. - expect(summary.totals.missingCostEntries).toBe(1); + // Operator-configured $0 is intentional and complete — not a missing entry. + expect(summary.totals.missingCostEntries).toBe(0); }); }); diff --git a/src/infra/session-cost-usage.ts b/src/infra/session-cost-usage.ts index f6647643d6d..3801dbb7b88 100644 --- a/src/infra/session-cost-usage.ts +++ b/src/infra/session-cost-usage.ts @@ -24,6 +24,7 @@ import { normalizeOptionalString } from "../shared/string-coerce.js"; import { countToolResults, extractToolCallNames } from "../utils/transcript-tools.js"; import { estimateUsageCost, + resolveConfiguredModelCost, resolveModelCostConfig, resolveModelCostConfigFingerprint, } from "../utils/usage-format.js"; @@ -1117,15 +1118,23 @@ async function scanTranscriptFile(params: { entry.costBreakdown = undefined; } else if ( !isModelPricingKnown(cost) && + resolveConfiguredModelCost({ + provider: entry.provider, + model: entry.model, + config: params.config, + }) === undefined && (entry.costTotal === undefined || entry.costTotal === 0) && computeUsageTokenTotals(entry.usage).totalTokens > 0 ) { - // Pricing for this model is unknown (no positive per-token rate) and there is no - // trustworthy recorded cost — the transport either recorded nothing or a - // fabricated $0 derived from an all-zero catalog entry. Surface this token-burning - // turn as a missing-cost entry instead of recording a confident $0, so budget and - // spike safeguards that read totalCost are not left blind to it. A turn that - // carries a real positive recorded cost is preserved by the guard above. + // Pricing for this model is unknown: it has no positive per-token rate, the + // operator did not explicitly configure its price (so an all-zero value is a + // catalog default, not an intentional "free" price), and there is no trustworthy + // recorded cost — the transport either recorded nothing or a fabricated $0 + // derived from an all-zero catalog entry. Surface this token-burning turn as a + // missing-cost entry instead of recording a confident $0, so budget and spike + // safeguards that read totalCost are not left blind to it. A turn with a real + // positive recorded cost, or a model the operator deliberately priced at 0, is + // preserved by the guards above. entry.costTotal = undefined; entry.costBreakdown = undefined; } else if (entry.costTotal === undefined) { diff --git a/src/utils/usage-format.ts b/src/utils/usage-format.ts index a63748fd4d9..feb4f95914f 100644 --- a/src/utils/usage-format.ts +++ b/src/utils/usage-format.ts @@ -298,6 +298,22 @@ export function resolveModelCostConfigFingerprint(config?: OpenClawConfig): stri }); } +// Returns model pricing ONLY when the operator explicitly configured it under +// `models.providers` in their OpenClaw config. Unlike resolveModelCostConfig this +// ignores the generated model catalog (models.json) and the gateway pricing cache, so +// callers can tell an intentional operator-set price (e.g. a deliberately free local +// model priced at 0) apart from a price the catalog merely defaulted to zero. +export function resolveConfiguredModelCost(params: { + provider?: string; + model?: string; + config?: OpenClawConfig; +}): ModelCostConfig | undefined { + return ( + findConfiguredProviderCost({ ...params, allowPluginNormalization: false }) ?? + findConfiguredProviderCost(params) + ); +} + export function resolveModelCostConfig(params: { provider?: string; model?: string;