diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index edc441a2552..55fb1883dca 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 8c7eccb5f02..593a56a1066 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -254,6 +254,7 @@ export function createFollowupRunner(params: { await persistRunSessionUsage({ storePath, sessionKey, + cfg, usage, lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage, promptTokens, diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index 6638a6738ef..d3594fcdf42 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -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 = { 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; diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index db0870b704a..138f9c3f349 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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", () => { diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 6db6b1708cb..28fa58b2006 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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. diff --git a/src/commands/agent/session-store.ts b/src/commands/agent/session-store.ts index 08bde6bb9a8..a3f0ec6c395 100644 --- a/src/commands/agent/session-store.ts +++ b/src/commands/agent/session-store.ts @@ -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; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index bbe01a0b00d..85a4beabdb5 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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; diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 4c7a5c87fe2..f31797643b4 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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 = { @@ -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, diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index a6ffdebb0af..ca95b86aca1 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -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", () => { diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 5ca974cbb1e..6ad14349c42 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -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; 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); }); } diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 6293119b638..a2718cee7f4 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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" }, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 63c9ba0628d..fb0bceacd81 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -234,10 +234,15 @@ function resolveEstimatedSessionCostUsd(params: { cfg: OpenClawConfig; provider?: string; model?: string; - entry?: Pick; + 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; }