fix: treat zero-rate usage cost as unknown

This commit is contained in:
Peter Steinberger
2026-05-25 21:13:34 +01:00
parent 9c79a0f8f4
commit 116c600f60
3 changed files with 15 additions and 39 deletions

View File

@@ -194,13 +194,14 @@ describe("session cost usage", () => {
});
});
it("preserves an operator-configured zero-cost model as a complete $0, not missing", async () => {
const root = await makeSessionCostRoot("cost-intentional-free");
it("counts token usage for a configured all-zero model as missing because pricing is still unknown", async () => {
const root = await makeSessionCostRoot("cost-configured-zero-unknown");
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.
// Same shape of turn, with a configured all-zero cost block. After config defaults,
// omitted cost and explicit all-zero cost are indistinguishable, so a zero-rate
// token-burning turn is still safer to report as missing than as complete $0 spend.
const entry = {
type: "message",
timestamp: new Date().toISOString(),
@@ -225,8 +226,8 @@ describe("session cost usage", () => {
"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.
// This mirrors normalized config where a model declaration without pricing has
// already received default zero rates.
const config = {
models: {
providers: {
@@ -247,8 +248,7 @@ describe("session cost usage", () => {
const summary = await loadCostUsageSummary({ days: 30, config });
expect(summary.totals.totalTokens).toBe(23287);
expect(summary.totals.totalCost).toBe(0);
// Operator-configured $0 is intentional and complete — not a missing entry.
expect(summary.totals.missingCostEntries).toBe(0);
expect(summary.totals.missingCostEntries).toBe(1);
});
});

View File

@@ -24,7 +24,6 @@ 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";
@@ -1122,23 +1121,16 @@ 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: 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.
// Pricing for this model is unknown: it has no positive per-token rate and no
// trustworthy recorded cost. The transport either recorded nothing or a
// fabricated $0 derived from an all-zero/default 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 carrying a real positive recorded cost is preserved by the guard
// above.
entry.costTotal = undefined;
entry.costBreakdown = undefined;
} else if (entry.costTotal === undefined) {

View File

@@ -298,22 +298,6 @@ 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;