mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-31 11:38:34 +00:00
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:
committed by
Peter Steinberger
parent
6e85869161
commit
16702496c6
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user