Persist accumulated session cost
This commit is contained in:
parent
02d5c07e62
commit
cfef9d5d45
@ -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,
|
||||
|
||||
@ -254,6 +254,7 @@ export function createFollowupRunner(params: {
|
||||
await persistRunSessionUsage({
|
||||
storePath,
|
||||
sessionKey,
|
||||
cfg,
|
||||
usage,
|
||||
lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage,
|
||||
promptTokens,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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" },
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user