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) <noreply@anthropic.com>
This commit is contained in:
Michael Zelbel
2026-05-24 15:40:34 +02:00
committed by Peter Steinberger
parent 6e85869161
commit 16702496c6
3 changed files with 83 additions and 13 deletions

View File

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

View File

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

View File

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