Persist accumulated session cost

This commit is contained in:
Tyler Yust 2026-03-12 16:21:09 -07:00
parent 02d5c07e62
commit cfef9d5d45
12 changed files with 342 additions and 69 deletions

View File

@ -280,6 +280,13 @@ export async function runReplyAgent(params: {
abortedLastRun: false,
modelProvider: undefined,
model: undefined,
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
totalTokensFresh: false,
estimatedCostUsd: undefined,
cacheRead: undefined,
cacheWrite: undefined,
contextTokens: undefined,
systemPromptReport: undefined,
fallbackNoticeSelectedModel: undefined,
@ -468,6 +475,7 @@ export async function runReplyAgent(params: {
await persistRunSessionUsage({
storePath,
sessionKey,
cfg,
usage,
lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage,
promptTokens,

View File

@ -254,6 +254,7 @@ export function createFollowupRunner(params: {
await persistRunSessionUsage({
storePath,
sessionKey,
cfg,
usage,
lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage,
promptTokens,

View File

@ -4,12 +4,15 @@ import {
hasNonzeroUsage,
type NormalizedUsage,
} from "../../agents/usage.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import {
type SessionSystemPromptReport,
type SessionEntry,
updateSessionStoreEntry,
} from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
function applyCliSessionIdToSessionPatch(
params: {
@ -32,9 +35,31 @@ function applyCliSessionIdToSessionPatch(
return patch;
}
function resolveNonNegativeNumber(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function estimateSessionRunCostUsd(params: {
cfg: OpenClawConfig;
usage?: NormalizedUsage;
providerUsed?: string;
modelUsed?: string;
}): number | undefined {
if (!hasNonzeroUsage(params.usage)) {
return undefined;
}
const cost = resolveModelCostConfig({
provider: params.providerUsed,
model: params.modelUsed,
config: params.cfg,
});
return resolveNonNegativeNumber(estimateUsageCost({ usage: params.usage, cost }));
}
export async function persistSessionUsageUpdate(params: {
storePath?: string;
sessionKey?: string;
cfg?: OpenClawConfig;
usage?: NormalizedUsage;
/**
* Usage from the last individual API call (not accumulated). When provided,
@ -57,6 +82,7 @@ export async function persistSessionUsageUpdate(params: {
}
const label = params.logLabel ? `${params.logLabel} ` : "";
const cfg = params.cfg ?? loadConfig();
const hasUsage = hasNonzeroUsage(params.usage);
const hasPromptTokens =
typeof params.promptTokens === "number" &&
@ -83,6 +109,13 @@ export async function persistSessionUsageUpdate(params: {
promptTokens: params.promptTokens,
})
: undefined;
const runEstimatedCostUsd = estimateSessionRunCostUsd({
cfg,
usage: params.usage,
providerUsed: params.providerUsed ?? entry.modelProvider,
modelUsed: params.modelUsed ?? entry.model,
});
const existingEstimatedCostUsd = resolveNonNegativeNumber(entry.estimatedCostUsd) ?? 0;
const patch: Partial<SessionEntry> = {
modelProvider: params.providerUsed ?? entry.modelProvider,
model: params.modelUsed ?? entry.model,
@ -99,6 +132,11 @@ export async function persistSessionUsageUpdate(params: {
patch.cacheRead = cacheUsage?.cacheRead ?? 0;
patch.cacheWrite = cacheUsage?.cacheWrite ?? 0;
}
if (runEstimatedCostUsd !== undefined) {
patch.estimatedCostUsd = existingEstimatedCostUsd + runEstimatedCostUsd;
} else if (entry.estimatedCostUsd !== undefined) {
patch.estimatedCostUsd = entry.estimatedCostUsd;
}
// Missing a last-call snapshot (and promptTokens fallback) means
// context utilization is stale/unknown.
patch.totalTokens = totalTokens;

View File

@ -1753,6 +1753,91 @@ describe("persistSessionUsageUpdate", () => {
expect(stored[sessionKey].totalTokens).toBe(250_000);
expect(stored[sessionKey].totalTokensFresh).toBe(true);
});
it("accumulates estimatedCostUsd across persisted usage updates", async () => {
const storePath = await createStorePath("openclaw-usage-cost-");
const sessionKey = "main";
await seedSessionStore({
storePath,
sessionKey,
entry: {
sessionId: "s1",
updatedAt: Date.now(),
estimatedCostUsd: 0.0015,
},
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
cfg: {
models: {
providers: {
openai: {
models: [
{
id: "gpt-5.4",
label: "GPT 5.4",
baseUrl: "https://api.openai.com/v1",
cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0.5 },
},
],
},
},
},
} as OpenClawConfig,
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 stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].estimatedCostUsd).toBeCloseTo(0.009225, 8);
});
it("persists zero estimatedCostUsd for free priced models", async () => {
const storePath = await createStorePath("openclaw-usage-free-cost-");
const sessionKey = "main";
await seedSessionStore({
storePath,
sessionKey,
entry: {
sessionId: "s1",
updatedAt: Date.now(),
},
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
cfg: {
models: {
providers: {
"openai-codex": {
models: [
{
id: "gpt-5.3-codex-spark",
label: "GPT 5.3 Codex Spark",
baseUrl: "https://api.openai.com/v1",
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
},
},
},
} as OpenClawConfig,
usage: { input: 5_107, output: 1_827, cacheRead: 1_536, cacheWrite: 0 },
lastCallUsage: { input: 5_107, output: 1_827, cacheRead: 1_536, cacheWrite: 0 },
providerUsed: "openai-codex",
modelUsed: "gpt-5.3-codex-spark",
contextTokensUsed: 200_000,
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].estimatedCostUsd).toBe(0);
});
});
describe("initSessionState stale threadId fallback", () => {

View File

@ -538,6 +538,7 @@ export async function initSessionState(params: {
sessionEntry.totalTokens = undefined;
sessionEntry.inputTokens = undefined;
sessionEntry.outputTokens = undefined;
sessionEntry.estimatedCostUsd = undefined;
sessionEntry.contextTokens = undefined;
}
// Preserve per-session overrides while resetting compaction state on /new.

View File

@ -10,11 +10,16 @@ import {
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
type RunResult = Awaited<
ReturnType<(typeof import("../../agents/pi-embedded.js"))["runEmbeddedPiAgent"]>
>;
function resolveNonNegativeNumber(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
export async function updateSessionStoreAfterAgentRun(params: {
cfg: OpenClawConfig;
contextTokensOverride?: number;
@ -87,6 +92,16 @@ export async function updateSessionStoreAfterAgentRun(params: {
contextTokens,
promptTokens,
});
const runEstimatedCostUsd = resolveNonNegativeNumber(
estimateUsageCost({
usage,
cost: resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
}),
}),
);
next.inputTokens = input;
next.outputTokens = output;
if (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) {
@ -98,6 +113,10 @@ export async function updateSessionStoreAfterAgentRun(params: {
}
next.cacheRead = usage.cacheRead ?? 0;
next.cacheWrite = usage.cacheWrite ?? 0;
if (runEstimatedCostUsd !== undefined) {
next.estimatedCostUsd =
(resolveNonNegativeNumber(entry.estimatedCostUsd) ?? 0) + runEstimatedCostUsd;
}
}
if (compactionsThisRun > 0) {
next.compactionCount = (entry.compactionCount ?? 0) + compactionsThisRun;

View File

@ -140,6 +140,7 @@ export type SessionEntry = {
* totalTokens as stale/unknown for context-utilization displays.
*/
totalTokensFresh?: boolean;
estimatedCostUsd?: number;
cacheRead?: number;
cacheWrite?: number;
modelProvider?: string;

View File

@ -54,6 +54,7 @@ import {
getHookType,
isExternalHookSession,
} from "../../security/external-content.js";
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
import { resolveCronDeliveryPlan } from "../delivery.js";
import type { CronJob, CronRunOutcome, CronRunTelemetry } from "../types.js";
import {
@ -75,6 +76,10 @@ import { resolveCronSession } from "./session.js";
import { resolveCronSkillsSnapshot } from "./skills-snapshot.js";
import { isLikelyInterimCronMessage } from "./subagent-followup.js";
function resolveNonNegativeNumber(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
export type RunCronAgentTurnResult = {
/** Last non-empty agent text output (not truncated). */
outputText?: string;
@ -732,6 +737,16 @@ export async function runCronIsolatedAgentTurn(params: {
contextTokens,
promptTokens,
});
const runEstimatedCostUsd = resolveNonNegativeNumber(
estimateUsageCost({
usage,
cost: resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
}),
}),
);
cronSession.sessionEntry.inputTokens = input;
cronSession.sessionEntry.outputTokens = output;
const telemetryUsage: NonNullable<CronRunTelemetry["usage"]> = {
@ -748,6 +763,11 @@ export async function runCronIsolatedAgentTurn(params: {
}
cronSession.sessionEntry.cacheRead = usage.cacheRead ?? 0;
cronSession.sessionEntry.cacheWrite = usage.cacheWrite ?? 0;
if (runEstimatedCostUsd !== undefined) {
cronSession.sessionEntry.estimatedCostUsd =
(resolveNonNegativeNumber(cronSession.sessionEntry.estimatedCostUsd) ?? 0) +
runEstimatedCostUsd;
}
telemetry = {
model: modelUsed,

View File

@ -699,7 +699,7 @@ describe("readLatestSessionUsageFromTranscript", () => {
});
});
test("backfills missing model and cost fields from earlier assistant usage snapshots", () => {
test("aggregates assistant usage across the full transcript and keeps the latest context snapshot", () => {
const sessionId = "usage-aggregate";
writeTranscript(tmpDir, sessionId, [
{ type: "session", version: 1, id: sessionId },
@ -721,22 +721,72 @@ describe("readLatestSessionUsageFromTranscript", () => {
role: "assistant",
usage: {
input: 2_400,
output: 250,
cacheRead: 900,
cost: { total: 0.006 },
},
},
},
]);
expect(readLatestSessionUsageFromTranscript(sessionId, storePath)).toEqual({
const snapshot = readLatestSessionUsageFromTranscript(sessionId, storePath);
expect(snapshot).toMatchObject({
modelProvider: "anthropic",
model: "claude-sonnet-4-6",
inputTokens: 2400,
outputTokens: 400,
cacheRead: 900,
inputTokens: 4200,
outputTokens: 650,
cacheRead: 1500,
totalTokens: 3300,
totalTokensFresh: true,
costUsd: 0.0055,
});
expect(snapshot?.costUsd).toBeCloseTo(0.0115, 8);
});
test("reads earlier assistant usage outside the old tail window", () => {
const sessionId = "usage-full-transcript";
const filler = "x".repeat(20_000);
writeTranscript(tmpDir, sessionId, [
{ type: "session", version: 1, id: sessionId },
{
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.4",
usage: {
input: 1_000,
output: 200,
cacheRead: 100,
cost: { total: 0.0042 },
},
},
},
...Array.from({ length: 80 }, () => ({ message: { role: "user", content: filler } })),
{
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.4",
usage: {
input: 500,
output: 150,
cacheRead: 50,
cost: { total: 0.0021 },
},
},
},
]);
const snapshot = readLatestSessionUsageFromTranscript(sessionId, storePath);
expect(snapshot).toMatchObject({
modelProvider: "openai",
model: "gpt-5.4",
inputTokens: 1500,
outputTokens: 350,
cacheRead: 150,
totalTokens: 550,
totalTokensFresh: true,
});
expect(snapshot?.costUsd).toBeCloseTo(0.0063, 8);
});
test("returns null when the transcript has no assistant usage snapshot", () => {

View File

@ -569,8 +569,6 @@ export type SessionTranscriptUsageSnapshot = {
costUsd?: number;
};
const USAGE_READ_SIZES = [16 * 1024, 64 * 1024, 256 * 1024, 1024 * 1024];
function extractTranscriptUsageCost(raw: unknown): number | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
@ -583,17 +581,6 @@ function extractTranscriptUsageCost(raw: unknown): number | undefined {
return typeof total === "number" && Number.isFinite(total) && total >= 0 ? total : undefined;
}
function readTailChunk(fd: number, size: number, maxBytes: number): string | null {
const readLen = Math.min(size, maxBytes);
if (readLen <= 0) {
return null;
}
const readStart = Math.max(0, size - readLen);
const buf = Buffer.alloc(readLen);
fs.readSync(fd, buf, 0, readLen, readStart);
return buf.toString("utf-8");
}
function resolvePositiveUsageNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
@ -604,9 +591,18 @@ function extractLatestUsageFromTranscriptChunk(
const lines = chunk.split(/\r?\n/).filter((line) => line.trim().length > 0);
const snapshot: SessionTranscriptUsageSnapshot = {};
let sawSnapshot = false;
let inputTokens = 0;
let outputTokens = 0;
let cacheRead = 0;
let cacheWrite = 0;
let sawInputTokens = false;
let sawOutputTokens = false;
let sawCacheRead = false;
let sawCacheWrite = false;
let costUsdTotal = 0;
let sawCost = false;
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i];
for (const line of lines) {
try {
const parsed = JSON.parse(line) as Record<string, unknown>;
const message =
@ -655,49 +651,62 @@ function extractLatestUsageFromTranscriptChunk(
}
sawSnapshot = true;
if (!snapshot.modelProvider && modelProvider) {
snapshot.modelProvider = modelProvider;
if (!isDeliveryMirror) {
if (modelProvider) {
snapshot.modelProvider = modelProvider;
}
if (model) {
snapshot.model = model;
}
}
if (!snapshot.model && model) {
snapshot.model = model;
if (typeof usage?.input === "number" && Number.isFinite(usage.input)) {
inputTokens += usage.input;
sawInputTokens = true;
}
if (snapshot.inputTokens === undefined) {
snapshot.inputTokens = resolvePositiveUsageNumber(usage?.input);
if (typeof usage?.output === "number" && Number.isFinite(usage.output)) {
outputTokens += usage.output;
sawOutputTokens = true;
}
if (snapshot.outputTokens === undefined) {
snapshot.outputTokens = resolvePositiveUsageNumber(usage?.output);
if (typeof usage?.cacheRead === "number" && Number.isFinite(usage.cacheRead)) {
cacheRead += usage.cacheRead;
sawCacheRead = true;
}
if (snapshot.cacheRead === undefined) {
snapshot.cacheRead = resolvePositiveUsageNumber(usage?.cacheRead);
if (typeof usage?.cacheWrite === "number" && Number.isFinite(usage.cacheWrite)) {
cacheWrite += usage.cacheWrite;
sawCacheWrite = true;
}
if (snapshot.cacheWrite === undefined) {
snapshot.cacheWrite = resolvePositiveUsageNumber(usage?.cacheWrite);
}
if (snapshot.totalTokens === undefined && typeof totalTokens === "number") {
if (typeof totalTokens === "number") {
snapshot.totalTokens = totalTokens;
snapshot.totalTokensFresh = true;
}
if (
snapshot.costUsd === undefined &&
typeof costUsd === "number" &&
Number.isFinite(costUsd)
) {
snapshot.costUsd = costUsd;
}
if (
snapshot.modelProvider &&
snapshot.model &&
snapshot.totalTokens !== undefined &&
snapshot.costUsd !== undefined
) {
break;
if (typeof costUsd === "number" && Number.isFinite(costUsd)) {
costUsdTotal += costUsd;
sawCost = true;
}
} catch {
// skip malformed lines
}
}
return sawSnapshot ? snapshot : null;
if (!sawSnapshot) {
return null;
}
if (sawInputTokens) {
snapshot.inputTokens = inputTokens;
}
if (sawOutputTokens) {
snapshot.outputTokens = outputTokens;
}
if (sawCacheRead) {
snapshot.cacheRead = cacheRead;
}
if (sawCacheWrite) {
snapshot.cacheWrite = cacheWrite;
}
if (sawCost) {
snapshot.costUsd = costUsdTotal;
}
return snapshot;
}
export function readLatestSessionUsageFromTranscript(
@ -713,24 +722,11 @@ export function readLatestSessionUsageFromTranscript(
return withOpenTranscriptFd(filePath, (fd) => {
const stat = fs.fstatSync(fd);
const size = stat.size;
if (size === 0) {
if (stat.size === 0) {
return null;
}
for (const maxBytes of USAGE_READ_SIZES) {
const chunk = readTailChunk(fd, size, maxBytes);
if (!chunk) {
continue;
}
const snapshot = extractLatestUsageFromTranscriptChunk(chunk);
if (snapshot) {
return snapshot;
}
if (maxBytes >= size) {
break;
}
}
return null;
const chunk = fs.readFileSync(fd, "utf-8");
return extractLatestUsageFromTranscriptChunk(chunk);
});
}

View File

@ -877,6 +877,55 @@ describe("listSessionsFromStore search", () => {
expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(0.007725, 8);
});
test("prefers persisted estimated session cost from the store", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-store-cost-"));
const storePath = path.join(tmpDir, "sessions.json");
fs.writeFileSync(
path.join(tmpDir, "sess-main.jsonl"),
[
JSON.stringify({ type: "session", version: 1, id: "sess-main" }),
JSON.stringify({
message: {
role: "assistant",
provider: "anthropic",
model: "claude-sonnet-4-6",
usage: {
input: 2_000,
output: 500,
cacheRead: 1_200,
cost: { total: 0.007725 },
},
},
}),
].join("\n"),
"utf-8",
);
try {
const result = listSessionsFromStore({
cfg: baseCfg,
storePath,
store: {
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
modelProvider: "anthropic",
model: "claude-sonnet-4-6",
estimatedCostUsd: 0.1234,
totalTokens: 0,
totalTokensFresh: false,
} as SessionEntry,
},
opts: {},
});
expect(result.sessions[0]?.estimatedCostUsd).toBe(0.1234);
expect(result.sessions[0]?.totalTokens).toBe(3_200);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("keeps zero estimated session cost when configured model pricing resolves to free", () => {
const cfg = {
session: { mainKey: "main" },

View File

@ -234,10 +234,15 @@ function resolveEstimatedSessionCostUsd(params: {
cfg: OpenClawConfig;
provider?: string;
model?: string;
entry?: Pick<SessionEntry, "inputTokens" | "outputTokens" | "cacheRead" | "cacheWrite">;
entry?: Pick<
SessionEntry,
"estimatedCostUsd" | "inputTokens" | "outputTokens" | "cacheRead" | "cacheWrite"
>;
explicitCostUsd?: number;
}): number | undefined {
const explicitCostUsd = resolveNonNegativeNumber(params.explicitCostUsd);
const explicitCostUsd = resolveNonNegativeNumber(
params.explicitCostUsd ?? params.entry?.estimatedCostUsd,
);
if (explicitCostUsd !== undefined) {
return explicitCostUsd;
}