fix(cost): snapshot estimatedCostUsd instead of accumulating (#69347)

The bug: three persist sites accumulated cost instead of snapshotting
it like tokens. This caused cost to be inflated 1x-72x on multi-persist
sessions because the same cumulative usage was added repeatedly.

Root cause: persistSessionUsageUpdate, updateSessionStoreAfterAgentRun,
and the cron isolated-agent run path all used:
  estimatedCostUsd = existingCost + runCost

But runCost was already computed from cumulative run usage, so this
added the same cost repeatedly on redundant persists.

Fix: snapshot cost directly like tokens already do:
  estimatedCostUsd = runCost

Files affected:
- src/auto-reply/reply/session-usage.ts
- src/agents/command/session-store.ts
- src/cron/isolated-agent/run.ts

Tests added:
- session-store.test.ts: verify cost is snapshotted, not accumulated
- session.test.ts: updated existing test to verify snapshot behavior

Fixes #69347
This commit is contained in:
Dexter (Miaigi)
2026-04-20 18:18:56 +01:00
committed by Peter Steinberger
parent 5bc9d9cc5c
commit 47bb5ddece
5 changed files with 173 additions and 32 deletions

View File

@@ -149,10 +149,11 @@ export async function persistSessionUsageUpdate(params: {
patch.cacheRead = cacheUsage?.cacheRead ?? 0;
patch.cacheWrite = cacheUsage?.cacheWrite ?? 0;
}
// Snapshot cost like tokens (runEstimatedCostUsd is already computed from
// cumulative run usage, so assign directly instead of accumulating).
// Fixes #69347: cost was inflated 1x-72x by accumulating on every persist.
if (runEstimatedCostUsd !== undefined) {
patch.estimatedCostUsd = existingEstimatedCostUsd + runEstimatedCostUsd;
} else if (entry.estimatedCostUsd !== undefined) {
patch.estimatedCostUsd = entry.estimatedCostUsd;
patch.estimatedCostUsd = runEstimatedCostUsd;
}
// Missing a last-call snapshot (and promptTokens fallback) means
// context utilization is stale/unknown.

View File

@@ -2424,7 +2424,7 @@ describe("persistSessionUsageUpdate", () => {
expect(stored[sessionKey].totalTokensFresh).toBe(true);
});
it("accumulates estimatedCostUsd across persisted usage updates", async () => {
it("snapshots estimatedCostUsd instead of accumulating (fixes #69347)", async () => {
const storePath = await createStorePath("openclaw-usage-cost-");
const sessionKey = "main";
await seedSessionStore({
@@ -2433,33 +2433,36 @@ describe("persistSessionUsageUpdate", () => {
entry: {
sessionId: "s1",
updatedAt: Date.now(),
estimatedCostUsd: 0.0015,
},
});
const cfg: OpenClawConfig = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [
{
id: "gpt-5.4",
name: "GPT 5.4",
reasoning: true,
input: ["text"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.5 },
contextWindow: 200_000,
maxTokens: 8_192,
},
],
},
},
},
};
// First persist: 2000 input + 500 output + 1000 cacheRead + 200 cacheWrite tokens
// Cost = (2000*1.25 + 500*10 + 1000*0.125 + 200*0.5) / 1e6 = $0.007725
await persistSessionUsageUpdate({
storePath,
sessionKey,
cfg: {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
models: [
{
id: "gpt-5.4",
name: "GPT 5.4",
reasoning: true,
input: ["text"],
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.5 },
contextWindow: 200_000,
maxTokens: 8_192,
},
],
},
},
},
} satisfies OpenClawConfig,
cfg,
usage: { input: 2_000, output: 500, cacheRead: 1_000, cacheWrite: 200 },
lastCallUsage: { input: 800, output: 200, cacheRead: 300, cacheWrite: 50 },
providerUsed: "openai",
@@ -2467,8 +2470,26 @@ describe("persistSessionUsageUpdate", () => {
contextTokensUsed: 200_000,
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].estimatedCostUsd).toBeCloseTo(0.009225, 8);
const stored1 = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored1[sessionKey].estimatedCostUsd).toBeCloseTo(0.007725, 8);
// Second persist with SAME cumulative usage (e.g., heartbeat or redundant persist)
// Before fix: cost would accumulate to $0.0155 (2x)
// After fix: cost stays $0.00775 (snapshotted)
await persistSessionUsageUpdate({
storePath,
sessionKey,
cfg,
usage: { input: 2_000, output: 500, cacheRead: 1_000, cacheWrite: 200 },
lastCallUsage: { input: 800, output: 200, cacheRead: 300, cacheWrite: 50 },
providerUsed: "openai",
modelUsed: "gpt-5.4",
contextTokensUsed: 200_000,
});
const stored2 = JSON.parse(await fs.readFile(storePath, "utf-8"));
// Cost should still be $0.007725, NOT $0.01545
expect(stored2[sessionKey].estimatedCostUsd).toBeCloseTo(0.007725, 8);
});
it("persists zero estimatedCostUsd for free priced models", async () => {