diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 967fe633d57..b720b17f079 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -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 }, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 48ff50231ea..dd8719f0ba8 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -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(); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 19ac3b4b425..6293119b638 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -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"); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 039e6c77291..63c9ba0628d 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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; 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) ??