Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/reference/api-usage-costs.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ Map of OpenClaw features that can call paid provider APIs, where each reads its
- `/usage cost` prints a local cost summary; `/usage off` disables the footer.
- Gemini CLI note: both `stream-json` and legacy `json` output carry usage under `stats`. OpenClaw normalizes `stats.cached` into `cacheRead` and derives input tokens from `stats.input_tokens - stats.cached` when needed.

**Control UI → Usage** (cross-session analysis)

- Shows transcript-derived token and estimated-cost totals for the selected date range, with breakdowns by provider, model, agent, channel, and token type.
- Compares shorter calendar windows ending on the selected range end date. Missing dates count as zero-usage calendar days; they are not skipped to create a denser window.
- Labels the daily chart scale directly. A `√` badge means square-root compression is keeping low-usage days visible.
- These totals describe the available local session history, not a provider invoice or lifetime billing ledger. The UI warns when pricing is missing for some entries.

**CLI usage windows** (provider quotas, not per-message cost)

- `openclaw status --usage` and `openclaw channels list` show provider **usage windows** as `X% left`.
Expand Down
6 changes: 3 additions & 3 deletions src/gateway/server-methods/usage.cost-usage-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ describe("costUsageCache bounded growth", () => {
// oldest-first, never the newest.
const lastStartMs = Date.UTC(2026, 0, 1) + (ITERATIONS - 1) * DAY_MS;
const lastEndMs = lastStartMs + ((ITERATIONS - 1) % 3 === 0 ? DAY_MS : 7 * DAY_MS) - 1;
const lastCacheKey = `agent:__default__:${lastStartMs}-${lastEndMs}`;
const lastCacheKey = `agent:__default__:${lastStartMs}-${lastEndMs}:gateway`;
expect(testApi.costUsageCache.has(lastCacheKey)).toBe(true);

// Tertiary: the oldest entry must have been evicted once the cap was
// exceeded. Pre-fix all 600 entries remain and this fails too.
const firstStartMs = Date.UTC(2026, 0, 1);
const firstEndMs = firstStartMs + DAY_MS - 1;
const firstCacheKey = `agent:__default__:${firstStartMs}-${firstEndMs}`;
const firstCacheKey = `agent:__default__:${firstStartMs}-${firstEndMs}:gateway`;
expect(testApi.costUsageCache.has(firstCacheKey)).toBe(false);
});

Expand Down Expand Up @@ -125,7 +125,7 @@ describe("costUsageCache bounded growth", () => {
});
await Promise.resolve();

expect(testApi.costUsageCache.has("agent:__default__:1-2")).toBe(true);
expect(testApi.costUsageCache.has("agent:__default__:1-2:gateway")).toBe(true);
expect(mocks.loadCostUsageSummaryFromCache).toHaveBeenCalledTimes(257);
void inFlight.catch(() => {});
void repeated.catch(() => {});
Expand Down
12 changes: 12 additions & 0 deletions src/gateway/server-methods/usage.sessions-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,18 @@ describe("sessions.usage", () => {
expect(result.totals.totalTokens).toBe(60);
});

it("passes the requested timezone offset to session daily summaries", async () => {
await runSessionsUsage({
...BASE_USAGE_RANGE,
mode: "specific",
utcOffset: "UTC-5",
});

expect(vi.mocked(loadSessionCostSummariesFromCache)).toHaveBeenCalledWith(
expect.objectContaining({ dailyUtcOffsetMinutes: -300 }),
);
});

it("discovers usage for requested disk-only agents not listed in config", async () => {
const respond = await runSessionsUsage({ ...BASE_USAGE_RANGE, agentId: "codex" });

Expand Down
44 changes: 44 additions & 0 deletions src/gateway/server-methods/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,31 @@ describe("gateway usage helpers", () => {
});
});

it("keeps cost usage cache entries scoped by daily timezone offset", async () => {
const config = {} as OpenClawConfig;

await testApi.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
dailyUtcOffsetMinutes: 0,
config,
});
await testApi.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
dailyUtcOffsetMinutes: -300,
config,
});
await testApi.loadCostUsageSummaryCached({
startMs: 1,
endMs: 2,
dailyUtcOffsetMinutes: 0,
config,
});

expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(2);
});

it("passes usage.cost agentId through to the cost summary loader", async () => {
const respond = vi.fn();

Expand All @@ -344,6 +369,25 @@ describe("gateway usage helpers", () => {
);
});

it("buckets usage.cost daily rows with the requested UTC offset", async () => {
const respond = vi.fn();

await usageHandlers["usage.cost"]({
respond,
params: {
startDate: "2026-02-01",
endDate: "2026-02-02",
mode: "specific",
utcOffset: "UTC-5",
},
context: { getRuntimeConfig: () => ({}) },
} as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]);

expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledWith(
expect.objectContaining({ dailyUtcOffsetMinutes: -300 }),
);
});

it("passes usage.cost all-agent scope through to all configured agent loaders", async () => {
const respond = vi.fn();

Expand Down
24 changes: 23 additions & 1 deletion src/gateway/server-methods/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,13 @@ const resolveDateInterpretation = (params: {
return { mode: "utc" };
};

const resolveDayBucketUtcOffsetMinutes = (interpretation: DateInterpretation) =>
interpretation.mode === "gateway"
? undefined
: interpretation.mode === "specific"
? interpretation.utcOffsetMinutes
: 0;

/**
* Parse a date string (YYYY-MM-DD) to start-of-day timestamp based on interpretation mode.
* Returns undefined if invalid.
Expand Down Expand Up @@ -742,11 +749,13 @@ function mergeDailyModelRows(
async function loadCostUsageSummaryCached(params: {
startMs: number;
endMs: number;
dailyUtcOffsetMinutes?: number;
config: OpenClawConfig;
agentId?: string;
agentScope?: "all";
}): Promise<CostUsageSummary> {
const cacheKey = `${params.agentScope === "all" ? "all" : `agent:${params.agentId ?? "__default__"}`}:${params.startMs}-${params.endMs}`;
const dailyOffsetKey = params.dailyUtcOffsetMinutes ?? "gateway";
const cacheKey = `${params.agentScope === "all" ? "all" : `agent:${params.agentId ?? "__default__"}`}:${params.startMs}-${params.endMs}:${dailyOffsetKey}`;
const now = Date.now();
const cached = costUsageCache.get(cacheKey);
if (
Expand All @@ -771,11 +780,13 @@ async function loadCostUsageSummaryCached(params: {
? loadAllAgentCostUsageSummary({
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
config: params.config,
})
: loadCostUsageSummaryFromCache({
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
config: params.config,
agentId: params.agentId,
requestRefresh: true,
Expand Down Expand Up @@ -816,6 +827,7 @@ async function loadCostUsageSummaryCached(params: {
async function loadAllAgentCostUsageSummary(params: {
startMs: number;
endMs: number;
dailyUtcOffsetMinutes?: number;
config: OpenClawConfig;
}): Promise<CostUsageSummary> {
const agentIds = listAgentsForGateway(params.config).agents.map((agent) =>
Expand All @@ -826,6 +838,7 @@ async function loadAllAgentCostUsageSummary(params: {
loadCostUsageSummaryFromCache({
startMs: params.startMs,
endMs: params.endMs,
dailyUtcOffsetMinutes: params.dailyUtcOffsetMinutes,
config: params.config,
agentId,
requestRefresh: true,
Expand Down Expand Up @@ -908,6 +921,10 @@ export const usageHandlers: GatewayRequestHandlers = {
respond(true, summary, undefined);
},
"usage.cost": async ({ respond, params, context }) => {
const dateInterpretation = resolveDateInterpretation({
mode: params?.mode,
utcOffset: params?.utcOffset,
});
const dateRange = resolveDateRange({
startDate: params?.startDate,
endDate: params?.endDate,
Expand All @@ -927,6 +944,7 @@ export const usageHandlers: GatewayRequestHandlers = {
const summary = await loadCostUsageSummaryCached({
startMs,
endMs,
dailyUtcOffsetMinutes: resolveDayBucketUtcOffsetMinutes(dateInterpretation),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align cost and session daily buckets

When the UI requests UTC or browser-local dates from a gateway running in another timezone, this new offset is applied only to usage.cost; sessions.usage still returns activityDates and dailyBreakdown from cached session summaries built with gateway-local formatDayKey(...) in src/infra/session-cost-usage.ts. The Usage view then intersects those session dates with data.costDaily, so a message near midnight can land on 2026-02-11 in the cost rows but 2026-02-12 in the session row, making selected-session/day filtering hide or misattribute the new cost-window/daily-chart data. Please either pass the same bucket contract through the session-summary path or avoid mixing these two daily sources.

Useful? React with 👍 / 👎.

config,
agentId,
agentScope,
Expand Down Expand Up @@ -960,6 +978,9 @@ export const usageHandlers: GatewayRequestHandlers = {
}
const config = context.getRuntimeConfig();
const { startMs, endMs } = dateRange.value;
const dailyUtcOffsetMinutes = resolveDayBucketUtcOffsetMinutes(
resolveDateInterpretation({ mode: p.mode, utcOffset: p.utcOffset }),
);
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
const includeContextWeight = p.includeContextWeight ?? false;
const specificKey = normalizeOptionalString(p.key) ?? null;
Expand Down Expand Up @@ -1227,6 +1248,7 @@ export const usageHandlers: GatewayRequestHandlers = {
agentId,
startMs,
endMs,
dailyUtcOffsetMinutes,
}),
})),
limit: SESSIONS_USAGE_AGENT_LOAD_CONCURRENCY,
Expand Down
126 changes: 126 additions & 0 deletions src/infra/session-cost-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,57 @@ describe("session cost usage", () => {
});
});

it("rebuckets cached session daily fields with the request timezone offset", async () => {
const root = await makeSessionCostRoot("cost-cache-request-offset");
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "sess-offset.jsonl");
await fs.writeFile(
sessionFile,
[
{
type: "message",
timestamp: "2026-02-12T00:29:00.000Z",
message: { role: "user", content: "hello" },
},
{
type: "message",
timestamp: "2026-02-12T00:30:00.000Z",
message: {
role: "assistant",
provider: "openai",
model: "gpt-5.5",
usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 0.00001 } },
},
},
]
.map((entry) => JSON.stringify(entry))
.join("\n"),
"utf-8",
);

await withStateDir(root, async () => {
await refreshCostUsageCache({ sessionFiles: [sessionFile] });
const result = await loadSessionCostSummariesFromCache({
sessions: [{ sessionId: "sess-offset", sessionFile }],
agentId: "main",
startMs: Date.UTC(2026, 1, 11, 2),
endMs: Date.UTC(2026, 1, 12, 1, 59, 59, 999),
dailyUtcOffsetMinutes: -120,
});
const summary = requireValue(result.summaries[0], "offset session summary missing");

expect(summary.activityDates).toEqual(["2026-02-11"]);
expect(summary.dailyBreakdown).toEqual([{ date: "2026-02-11", tokens: 15, cost: 0.00001 }]);
expect(summary.dailyMessageCounts?.map((entry) => entry.date)).toEqual(["2026-02-11"]);
expect(summary.dailyLatency?.map((entry) => entry.date)).toEqual(["2026-02-11"]);
expect(summary.dailyModelUsage?.map((entry) => entry.date)).toEqual(["2026-02-11"]);
expect(new Set(summary.utcQuarterHourMessageCounts?.map((entry) => entry.date))).toEqual(
new Set(["2026-02-12"]),
);
});
});

it("ignores compaction checkpoint transcript snapshots in daily totals and discovery", async () => {
const root = await makeSessionCostRoot("cost-checkpoint");
const sessionsDir = path.join(root, "agents", "main", "sessions");
Expand Down Expand Up @@ -655,6 +706,38 @@ describe("session cost usage", () => {
});
});

it("buckets daily totals with the request timezone offset", async () => {
const root = await makeSessionCostRoot("cost-offset-bucket");
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
await fs.writeFile(
path.join(sessionsDir, "sess-offset.jsonl"),
transcriptText("sess-offset", {
type: "message",
timestamp: "2026-02-12T00:30:00.000Z",
message: {
role: "assistant",
usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 0.00001 } },
},
}),
"utf-8",
);

await withStateDir(root, async () => {
const startMs = Date.UTC(2026, 1, 11, 2);
const endMs = Date.UTC(2026, 1, 12, 1, 59, 59, 999);
const summary = await loadCostUsageSummary({
startMs,
endMs,
dailyUtcOffsetMinutes: -120,
});

expect(summary.daily.map((entry) => entry.date)).toEqual(["2026-02-11"]);
expect(summary.daily[0]?.totalTokens).toBe(15);
expect(summary.daily[0]?.totalCost).toBeCloseTo(0.00001, 8);
});
});

it("fills missing days between sparse activity within the requested range", async () => {
const root = await makeSessionCostRoot("cost-sparse");
const sessionsDir = path.join(root, "agents", "main", "sessions");
Expand Down Expand Up @@ -1699,6 +1782,49 @@ describe("session cost usage", () => {
});
});

it("preserves offset-aware synchronous fallback for aggregate-only cache entries", async () => {
const root = await makeSessionCostRoot("cost-cache-session-sync-offset");
const sessionsDir = path.join(root, "agents", "main", "sessions");
await fs.mkdir(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "sess-cache-session-sync-offset.jsonl");
await fs.writeFile(
sessionFile,
JSON.stringify({
type: "message",
timestamp: "2026-02-05T00:30:00.000Z",
message: {
role: "assistant",
usage: {
input: 10,
output: 20,
totalTokens: 30,
cost: { total: 0.03 },
},
},
}),
"utf-8",
);

await withStateDir(root, async () => {
await refreshCostUsageCache();
const summary = await loadSessionCostSummaryFromCache({
sessionId: "sess-cache-session-sync-offset",
sessionFile,
startMs: Date.UTC(2026, 1, 4, 2),
endMs: Date.UTC(2026, 1, 5, 1, 59, 59, 999),
dailyUtcOffsetMinutes: -120,
requestRefresh: false,
refreshMode: "sync-when-empty",
});

expect(summary.summary?.totalTokens).toBe(30);
expect(summary.summary?.dailyBreakdown).toEqual([
{ date: "2026-02-04", tokens: 30, cost: 0.03 },
]);
expect(summary.cacheStatus.status).toBe("partial");
});
});

it("limits session summary refreshes to requested files", async () => {
const root = await makeSessionCostRoot("cost-cache-session-requested-files");
const sessionsDir = path.join(root, "agents", "main", "sessions");
Expand Down
Loading
Loading