Fix dashboard session cost metadata

This commit is contained in:
Tyler Yust
2026-03-12 15:46:16 -07:00
parent 6f9e2b664c
commit 02d5c07e62
4 changed files with 195 additions and 3 deletions

View File

@@ -40,6 +40,7 @@ import {
archiveFileOnDisk,
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
loadGatewaySessionRow,
loadSessionEntry,
pruneLegacyStoreKeys,
readSessionPreviewItemsFromTranscript,
@@ -117,11 +118,26 @@ function emitSessionsChanged(
if (connIds.size === 0) {
return;
}
const sessionRow = payload.sessionKey ? loadGatewaySessionRow(payload.sessionKey) : null;
context.broadcastToConnIds(
"sessions.changed",
{
...payload,
ts: Date.now(),
...(sessionRow
? {
totalTokens: sessionRow.totalTokens,
totalTokensFresh: sessionRow.totalTokensFresh,
contextTokens: sessionRow.contextTokens,
estimatedCostUsd: sessionRow.estimatedCostUsd,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
status: sessionRow.status,
startedAt: sessionRow.startedAt,
endedAt: sessionRow.endedAt,
runtimeMs: sessionRow.runtimeMs,
}
: {}),
},
connIds,
{ dropIfSlow: true },

View File

@@ -5,6 +5,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
import { WebSocket } from "ws";
import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "./protocol/client-info.js";
import { sessionsHandlers } from "./server-methods/sessions.js";
import { startGatewayServerHarness, type GatewayServerHarness } from "./server.e2e-ws-harness.js";
import { createToolSummaryPreviewTranscriptLines } from "./session-preview.test-helpers.js";
import {
@@ -447,6 +448,84 @@ describe("gateway server sessions", () => {
ws.close();
});
test("sessions.changed mutation events include live usage metadata", async () => {
const { dir } = await createSessionStoreDir();
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
[
JSON.stringify({ type: "session", version: 1, id: "sess-main" }),
JSON.stringify({
id: "msg-usage-zero",
message: {
role: "assistant",
provider: "openai-codex",
model: "gpt-5.3-codex-spark",
usage: {
input: 5_107,
output: 1_827,
cacheRead: 1_536,
cacheWrite: 0,
cost: { total: 0 },
},
timestamp: Date.now(),
},
}),
].join("\n"),
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
modelProvider: "openai-codex",
model: "gpt-5.3-codex-spark",
contextTokens: 123_456,
totalTokens: 0,
totalTokensFresh: false,
},
},
});
const broadcastToConnIds = vi.fn();
const respond = vi.fn();
await sessionsHandlers["sessions.patch"]({
params: {
key: "main",
label: "Renamed",
},
respond,
context: {
broadcastToConnIds,
getSessionEventSubscriberConnIds: () => new Set(["conn-1"]),
loadGatewayModelCatalog: async () => ({ providers: [] }),
} as never,
client: null,
isWebchatConnect: () => false,
});
expect(respond).toHaveBeenCalledWith(
true,
expect.objectContaining({ ok: true, key: "agent:main:main" }),
undefined,
);
expect(broadcastToConnIds).toHaveBeenCalledWith(
"sessions.changed",
expect.objectContaining({
sessionKey: "agent:main:main",
reason: "patch",
totalTokens: 6_643,
totalTokensFresh: true,
contextTokens: 123_456,
estimatedCostUsd: 0,
modelProvider: "openai-codex",
model: "gpt-5.3-codex-spark",
}),
new Set(["conn-1"]),
{ dropIfSlow: true },
);
});
test("lists and patches session store via sessions.* RPC", async () => {
const { dir, storePath } = await createSessionStoreDir();
const now = Date.now();

View File

@@ -877,6 +877,99 @@ describe("listSessionsFromStore search", () => {
expect(result.sessions[0]?.estimatedCostUsd).toBeCloseTo(0.007725, 8);
});
test("keeps zero estimated session cost when configured model pricing resolves to free", () => {
const cfg = {
session: { mainKey: "main" },
agents: { list: [{ id: "main", default: true }] },
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 unknown as OpenClawConfig;
const result = listSessionsFromStore({
cfg,
storePath: "/tmp/sessions.json",
store: {
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
modelProvider: "openai-codex",
model: "gpt-5.3-codex-spark",
inputTokens: 5_107,
outputTokens: 1_827,
cacheRead: 1_536,
cacheWrite: 0,
} as SessionEntry,
},
opts: {},
});
expect(result.sessions[0]?.estimatedCostUsd).toBe(0);
});
test("falls back to transcript usage for totalTokens and zero estimatedCostUsd", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-zero-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: "openai-codex",
model: "gpt-5.3-codex-spark",
usage: {
input: 5_107,
output: 1_827,
cacheRead: 1_536,
cost: { total: 0 },
},
},
}),
].join("\n"),
"utf-8",
);
try {
const result = listSessionsFromStore({
cfg: baseCfg,
storePath,
store: {
"agent:main:main": {
sessionId: "sess-main",
updatedAt: Date.now(),
modelProvider: "openai-codex",
model: "gpt-5.3-codex-spark",
totalTokens: 0,
totalTokensFresh: false,
inputTokens: 0,
outputTokens: 0,
cacheRead: 0,
cacheWrite: 0,
} as SessionEntry,
},
opts: {},
});
expect(result.sessions[0]?.totalTokens).toBe(6_643);
expect(result.sessions[0]?.totalTokensFresh).toBe(true);
expect(result.sessions[0]?.estimatedCostUsd).toBe(0);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
test("falls back to transcript usage for totalTokens and estimatedCostUsd, and derives contextTokens from the resolved model", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-"));
const storePath = path.join(tmpDir, "sessions.json");

View File

@@ -226,6 +226,10 @@ function resolvePositiveNumber(value: number | null | undefined): number | undef
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function resolveNonNegativeNumber(value: number | null | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function resolveEstimatedSessionCostUsd(params: {
cfg: OpenClawConfig;
provider?: string;
@@ -233,7 +237,7 @@ function resolveEstimatedSessionCostUsd(params: {
entry?: Pick<SessionEntry, "inputTokens" | "outputTokens" | "cacheRead" | "cacheWrite">;
explicitCostUsd?: number;
}): number | undefined {
const explicitCostUsd = resolvePositiveNumber(params.explicitCostUsd);
const explicitCostUsd = resolveNonNegativeNumber(params.explicitCostUsd);
if (explicitCostUsd !== undefined) {
return explicitCostUsd;
}
@@ -266,7 +270,7 @@ function resolveEstimatedSessionCostUsd(params: {
},
cost,
});
return resolvePositiveNumber(estimated);
return resolveNonNegativeNumber(estimated);
}
function resolveChildSessionKeys(
@@ -1085,7 +1089,7 @@ export function buildGatewaySessionRow(params: {
provider: modelProvider,
model,
entry,
}) ?? resolvePositiveNumber(transcriptUsage?.estimatedCostUsd);
}) ?? resolveNonNegativeNumber(transcriptUsage?.estimatedCostUsd);
const contextTokens =
resolvePositiveNumber(entry?.contextTokens) ??
resolvePositiveNumber(transcriptUsage?.contextTokens) ??