mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-21 12:04:59 +00:00
@@ -1222,6 +1222,8 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let startdate: String?
|
||||
public let enddate: String?
|
||||
public let mode: AnyCodable?
|
||||
public let utcoffset: String?
|
||||
public let limit: Int?
|
||||
public let includecontextweight: Bool?
|
||||
|
||||
@@ -1229,12 +1231,16 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
key: String?,
|
||||
startdate: String?,
|
||||
enddate: String?,
|
||||
mode: AnyCodable?,
|
||||
utcoffset: String?,
|
||||
limit: Int?,
|
||||
includecontextweight: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.startdate = startdate
|
||||
self.enddate = enddate
|
||||
self.mode = mode
|
||||
self.utcoffset = utcoffset
|
||||
self.limit = limit
|
||||
self.includecontextweight = includecontextweight
|
||||
}
|
||||
@@ -1242,6 +1248,8 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
case key
|
||||
case startdate = "startDate"
|
||||
case enddate = "endDate"
|
||||
case mode
|
||||
case utcoffset = "utcOffset"
|
||||
case limit
|
||||
case includecontextweight = "includeContextWeight"
|
||||
}
|
||||
|
||||
@@ -1222,6 +1222,8 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let startdate: String?
|
||||
public let enddate: String?
|
||||
public let mode: AnyCodable?
|
||||
public let utcoffset: String?
|
||||
public let limit: Int?
|
||||
public let includecontextweight: Bool?
|
||||
|
||||
@@ -1229,12 +1231,16 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
key: String?,
|
||||
startdate: String?,
|
||||
enddate: String?,
|
||||
mode: AnyCodable?,
|
||||
utcoffset: String?,
|
||||
limit: Int?,
|
||||
includecontextweight: Bool?
|
||||
) {
|
||||
self.key = key
|
||||
self.startdate = startdate
|
||||
self.enddate = enddate
|
||||
self.mode = mode
|
||||
self.utcoffset = utcoffset
|
||||
self.limit = limit
|
||||
self.includecontextweight = includecontextweight
|
||||
}
|
||||
@@ -1242,6 +1248,8 @@ public struct SessionsUsageParams: Codable, Sendable {
|
||||
case key
|
||||
case startdate = "startDate"
|
||||
case enddate = "endDate"
|
||||
case mode
|
||||
case utcoffset = "utcOffset"
|
||||
case limit
|
||||
case includecontextweight = "includeContextWeight"
|
||||
}
|
||||
|
||||
@@ -114,6 +114,12 @@ export const SessionsUsageParamsSchema = Type.Object(
|
||||
startDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
|
||||
/** End date for range filter (YYYY-MM-DD). */
|
||||
endDate: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" })),
|
||||
/** How start/end dates should be interpreted. Defaults to UTC when omitted. */
|
||||
mode: Type.Optional(
|
||||
Type.Union([Type.Literal("utc"), Type.Literal("gateway"), Type.Literal("specific")]),
|
||||
),
|
||||
/** UTC offset to use when mode is `specific` (for example, UTC-4 or UTC+5:30). */
|
||||
utcOffset: Type.Optional(Type.String({ pattern: "^UTC[+-]\\d{1,2}(?::[0-5]\\d)?$" })),
|
||||
/** Maximum sessions to return (default 50). */
|
||||
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
/** Include context weight breakdown (systemPromptReport). */
|
||||
|
||||
@@ -21,6 +21,8 @@ import { loadCostUsageSummary } from "../../infra/session-cost-usage.js";
|
||||
import { __test } from "./usage.js";
|
||||
|
||||
describe("gateway usage helpers", () => {
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
beforeEach(() => {
|
||||
__test.costUsageCache.clear();
|
||||
vi.useRealTimers();
|
||||
@@ -35,6 +37,20 @@ describe("gateway usage helpers", () => {
|
||||
expect(__test.parseDateToMs(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseUtcOffsetToMinutes supports whole-hour and half-hour offsets", () => {
|
||||
expect(__test.parseUtcOffsetToMinutes("UTC-4")).toBe(-240);
|
||||
expect(__test.parseUtcOffsetToMinutes("UTC+5:30")).toBe(330);
|
||||
expect(__test.parseUtcOffsetToMinutes(" UTC+14 ")).toBe(14 * 60);
|
||||
});
|
||||
|
||||
it("parseUtcOffsetToMinutes rejects invalid offsets", () => {
|
||||
expect(__test.parseUtcOffsetToMinutes("UTC+14:30")).toBeUndefined();
|
||||
expect(__test.parseUtcOffsetToMinutes("UTC+5:99")).toBeUndefined();
|
||||
expect(__test.parseUtcOffsetToMinutes("UTC+25")).toBeUndefined();
|
||||
expect(__test.parseUtcOffsetToMinutes("GMT+5")).toBeUndefined();
|
||||
expect(__test.parseUtcOffsetToMinutes(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseDays coerces strings/numbers to integers", () => {
|
||||
expect(__test.parseDays(7.9)).toBe(7);
|
||||
expect(__test.parseDays("30")).toBe(30);
|
||||
@@ -42,22 +58,84 @@ describe("gateway usage helpers", () => {
|
||||
expect(__test.parseDays("nope")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseDateRange uses explicit start/end (inclusive end of day)", () => {
|
||||
it("parseDateRange uses explicit start/end as UTC when mode is missing (backward compatible)", () => {
|
||||
const range = __test.parseDateRange({ startDate: "2026-02-01", endDate: "2026-02-02" });
|
||||
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
|
||||
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + 24 * 60 * 60 * 1000 - 1);
|
||||
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
|
||||
});
|
||||
|
||||
it("parseDateRange uses explicit UTC mode", () => {
|
||||
const range = __test.parseDateRange({
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
mode: "utc",
|
||||
});
|
||||
expect(range.startMs).toBe(Date.UTC(2026, 1, 1));
|
||||
expect(range.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
|
||||
});
|
||||
|
||||
it("parseDateRange uses specific UTC offset for explicit dates", () => {
|
||||
const range = __test.parseDateRange({
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
});
|
||||
const start = Date.UTC(2026, 1, 1) - 5.5 * 60 * 60 * 1000;
|
||||
const endStart = Date.UTC(2026, 1, 2) - 5.5 * 60 * 60 * 1000;
|
||||
expect(range.startMs).toBe(start);
|
||||
expect(range.endMs).toBe(endStart + dayMs - 1);
|
||||
});
|
||||
|
||||
it("parseDateRange falls back to UTC when specific mode offset is missing or invalid", () => {
|
||||
const missingOffset = __test.parseDateRange({
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
mode: "specific",
|
||||
});
|
||||
const invalidOffset = __test.parseDateRange({
|
||||
startDate: "2026-02-01",
|
||||
endDate: "2026-02-02",
|
||||
mode: "specific",
|
||||
utcOffset: "bad-value",
|
||||
});
|
||||
expect(missingOffset.startMs).toBe(Date.UTC(2026, 1, 1));
|
||||
expect(missingOffset.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
|
||||
expect(invalidOffset.startMs).toBe(Date.UTC(2026, 1, 1));
|
||||
expect(invalidOffset.endMs).toBe(Date.UTC(2026, 1, 2) + dayMs - 1);
|
||||
});
|
||||
|
||||
it("parseDateRange uses specific offset for today/day math after UTC midnight", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-17T03:57:00.000Z"));
|
||||
const range = __test.parseDateRange({
|
||||
days: 1,
|
||||
mode: "specific",
|
||||
utcOffset: "UTC-5",
|
||||
});
|
||||
expect(range.startMs).toBe(Date.UTC(2026, 1, 16, 5, 0, 0, 0));
|
||||
expect(range.endMs).toBe(Date.UTC(2026, 1, 17, 4, 59, 59, 999));
|
||||
});
|
||||
|
||||
it("parseDateRange uses gateway local day boundaries in gateway mode", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
|
||||
const range = __test.parseDateRange({ days: 1, mode: "gateway" });
|
||||
const expectedStart = new Date(2026, 1, 5).getTime();
|
||||
expect(range.startMs).toBe(expectedStart);
|
||||
expect(range.endMs).toBe(expectedStart + dayMs - 1);
|
||||
});
|
||||
|
||||
it("parseDateRange clamps days to at least 1 and defaults to 30 days", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-02-05T12:34:56.000Z"));
|
||||
const oneDay = __test.parseDateRange({ days: 0 });
|
||||
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
|
||||
expect(oneDay.endMs).toBe(Date.UTC(2026, 1, 5) + dayMs - 1);
|
||||
expect(oneDay.startMs).toBe(Date.UTC(2026, 1, 5));
|
||||
|
||||
const def = __test.parseDateRange({});
|
||||
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + 24 * 60 * 60 * 1000 - 1);
|
||||
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * 24 * 60 * 60 * 1000);
|
||||
expect(def.endMs).toBe(Date.UTC(2026, 1, 5) + dayMs - 1);
|
||||
expect(def.startMs).toBe(Date.UTC(2026, 1, 5) - 29 * dayMs);
|
||||
});
|
||||
|
||||
it("loadCostUsageSummaryCached caches within TTL", async () => {
|
||||
|
||||
@@ -39,8 +39,12 @@ import {
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
const COST_USAGE_CACHE_TTL_MS = 30_000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type DateRange = { startMs: number; endMs: number };
|
||||
type DateInterpretation =
|
||||
| { mode: "utc" | "gateway" }
|
||||
| { mode: "specific"; utcOffsetMinutes: number };
|
||||
|
||||
type CostUsageCacheEntry = {
|
||||
summary?: CostUsageSummary;
|
||||
@@ -84,11 +88,9 @@ function resolveSessionUsageFileOrRespond(
|
||||
return { config, entry, agentId, sessionId, sessionFile };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date string (YYYY-MM-DD) to start of day timestamp in UTC.
|
||||
* Returns undefined if invalid.
|
||||
*/
|
||||
const parseDateToMs = (raw: unknown): number | undefined => {
|
||||
const parseDateParts = (
|
||||
raw: unknown,
|
||||
): { year: number; monthIndex: number; day: number } | undefined => {
|
||||
if (typeof raw !== "string" || !raw.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -96,13 +98,98 @@ const parseDateToMs = (raw: unknown): number | undefined => {
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, year, month, day] = match;
|
||||
// Use UTC to ensure consistent behavior across timezones
|
||||
const ms = Date.UTC(parseInt(year), parseInt(month) - 1, parseInt(day));
|
||||
if (Number.isNaN(ms)) {
|
||||
const [, yearStr, monthStr, dayStr] = match;
|
||||
const year = Number(yearStr);
|
||||
const monthIndex = Number(monthStr) - 1;
|
||||
const day = Number(dayStr);
|
||||
if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || !Number.isFinite(day)) {
|
||||
return undefined;
|
||||
}
|
||||
return ms;
|
||||
return { year, monthIndex, day };
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a UTC offset string in the format UTC+H, UTC-H, UTC+HH, UTC-HH, UTC+H:MM, UTC-HH:MM.
|
||||
* Returns the UTC offset in minutes (east-positive), or undefined if invalid.
|
||||
*/
|
||||
const parseUtcOffsetToMinutes = (raw: unknown): number | undefined => {
|
||||
if (typeof raw !== "string" || !raw.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const match = /^UTC([+-])(\d{1,2})(?::([0-5]\d))?$/.exec(raw.trim());
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const sign = match[1] === "+" ? 1 : -1;
|
||||
const hours = Number(match[2]);
|
||||
const minutes = Number(match[3] ?? "0");
|
||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) {
|
||||
return undefined;
|
||||
}
|
||||
if (hours > 14 || (hours === 14 && minutes !== 0)) {
|
||||
return undefined;
|
||||
}
|
||||
const totalMinutes = sign * (hours * 60 + minutes);
|
||||
if (totalMinutes < -12 * 60 || totalMinutes > 14 * 60) {
|
||||
return undefined;
|
||||
}
|
||||
return totalMinutes;
|
||||
};
|
||||
|
||||
const resolveDateInterpretation = (params: {
|
||||
mode?: unknown;
|
||||
utcOffset?: unknown;
|
||||
}): DateInterpretation => {
|
||||
if (params.mode === "gateway") {
|
||||
return { mode: "gateway" };
|
||||
}
|
||||
if (params.mode === "specific") {
|
||||
const utcOffsetMinutes = parseUtcOffsetToMinutes(params.utcOffset);
|
||||
if (utcOffsetMinutes !== undefined) {
|
||||
return { mode: "specific", utcOffsetMinutes };
|
||||
}
|
||||
}
|
||||
// Backward compatibility: when mode is missing (or invalid), keep current UTC interpretation.
|
||||
return { mode: "utc" };
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a date string (YYYY-MM-DD) to start-of-day timestamp based on interpretation mode.
|
||||
* Returns undefined if invalid.
|
||||
*/
|
||||
const parseDateToMs = (
|
||||
raw: unknown,
|
||||
interpretation: DateInterpretation = { mode: "utc" },
|
||||
): number | undefined => {
|
||||
const parts = parseDateParts(raw);
|
||||
if (!parts) {
|
||||
return undefined;
|
||||
}
|
||||
const { year, monthIndex, day } = parts;
|
||||
if (interpretation.mode === "gateway") {
|
||||
const ms = new Date(year, monthIndex, day).getTime();
|
||||
return Number.isNaN(ms) ? undefined : ms;
|
||||
}
|
||||
if (interpretation.mode === "specific") {
|
||||
const ms = Date.UTC(year, monthIndex, day) - interpretation.utcOffsetMinutes * 60 * 1000;
|
||||
return Number.isNaN(ms) ? undefined : ms;
|
||||
}
|
||||
const ms = Date.UTC(year, monthIndex, day);
|
||||
return Number.isNaN(ms) ? undefined : ms;
|
||||
};
|
||||
|
||||
const getTodayStartMs = (now: Date, interpretation: DateInterpretation): number => {
|
||||
if (interpretation.mode === "gateway") {
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
}
|
||||
if (interpretation.mode === "specific") {
|
||||
const shifted = new Date(now.getTime() + interpretation.utcOffsetMinutes * 60 * 1000);
|
||||
return (
|
||||
Date.UTC(shifted.getUTCFullYear(), shifted.getUTCMonth(), shifted.getUTCDate()) -
|
||||
interpretation.utcOffsetMinutes * 60 * 1000
|
||||
);
|
||||
}
|
||||
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
};
|
||||
|
||||
const parseDays = (raw: unknown): number | undefined => {
|
||||
@@ -126,29 +213,31 @@ const parseDateRange = (params: {
|
||||
startDate?: unknown;
|
||||
endDate?: unknown;
|
||||
days?: unknown;
|
||||
mode?: unknown;
|
||||
utcOffset?: unknown;
|
||||
}): DateRange => {
|
||||
const now = new Date();
|
||||
// Use UTC for consistent date handling
|
||||
const todayStartMs = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||
const todayEndMs = todayStartMs + 24 * 60 * 60 * 1000 - 1;
|
||||
const interpretation = resolveDateInterpretation(params);
|
||||
const todayStartMs = getTodayStartMs(now, interpretation);
|
||||
const todayEndMs = todayStartMs + DAY_MS - 1;
|
||||
|
||||
const startMs = parseDateToMs(params.startDate);
|
||||
const endMs = parseDateToMs(params.endDate);
|
||||
const startMs = parseDateToMs(params.startDate, interpretation);
|
||||
const endMs = parseDateToMs(params.endDate, interpretation);
|
||||
|
||||
if (startMs !== undefined && endMs !== undefined) {
|
||||
// endMs should be end of day
|
||||
return { startMs, endMs: endMs + 24 * 60 * 60 * 1000 - 1 };
|
||||
return { startMs, endMs: endMs + DAY_MS - 1 };
|
||||
}
|
||||
|
||||
const days = parseDays(params.days);
|
||||
if (days !== undefined) {
|
||||
const clampedDays = Math.max(1, days);
|
||||
const start = todayStartMs - (clampedDays - 1) * 24 * 60 * 60 * 1000;
|
||||
const start = todayStartMs - (clampedDays - 1) * DAY_MS;
|
||||
return { startMs: start, endMs: todayEndMs };
|
||||
}
|
||||
|
||||
// Default to last 30 days
|
||||
const defaultStartMs = todayStartMs - 29 * 24 * 60 * 60 * 1000;
|
||||
const defaultStartMs = todayStartMs - 29 * DAY_MS;
|
||||
return { startMs: defaultStartMs, endMs: todayEndMs };
|
||||
};
|
||||
|
||||
@@ -239,7 +328,11 @@ async function loadCostUsageSummaryCached(params: {
|
||||
|
||||
// Exposed for unit tests (kept as a single export to avoid widening the public API surface).
|
||||
export const __test = {
|
||||
parseDateParts,
|
||||
parseUtcOffsetToMinutes,
|
||||
resolveDateInterpretation,
|
||||
parseDateToMs,
|
||||
getTodayStartMs,
|
||||
parseDays,
|
||||
parseDateRange,
|
||||
discoverAllSessionsForUsage,
|
||||
@@ -313,6 +406,8 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
startDate: params?.startDate,
|
||||
endDate: params?.endDate,
|
||||
days: params?.days,
|
||||
mode: params?.mode,
|
||||
utcOffset: params?.utcOffset,
|
||||
});
|
||||
const summary = await loadCostUsageSummaryCached({ startMs, endMs, config });
|
||||
respond(true, summary, undefined);
|
||||
@@ -335,6 +430,8 @@ export const usageHandlers: GatewayRequestHandlers = {
|
||||
const { startMs, endMs } = parseDateRange({
|
||||
startDate: p.startDate,
|
||||
endDate: p.endDate,
|
||||
mode: p.mode,
|
||||
utcOffset: p.utcOffset,
|
||||
});
|
||||
const limit = typeof p.limit === "number" && Number.isFinite(p.limit) ? p.limit : 50;
|
||||
const includeContextWeight = p.includeContextWeight ?? false;
|
||||
|
||||
190
ui/src/ui/controllers/usage.node.test.ts
Normal file
190
ui/src/ui/controllers/usage.node.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { __test, loadUsage, type UsageState } from "./usage.ts";
|
||||
|
||||
type RequestFn = (method: string, params?: unknown) => Promise<unknown>;
|
||||
|
||||
function createState(request: RequestFn, overrides: Partial<UsageState> = {}): UsageState {
|
||||
return {
|
||||
client: { request } as unknown as UsageState["client"],
|
||||
connected: true,
|
||||
usageLoading: false,
|
||||
usageResult: null,
|
||||
usageCostSummary: null,
|
||||
usageError: null,
|
||||
usageStartDate: "2026-02-16",
|
||||
usageEndDate: "2026-02-16",
|
||||
usageSelectedSessions: [],
|
||||
usageSelectedDays: [],
|
||||
usageTimeSeries: null,
|
||||
usageTimeSeriesLoading: false,
|
||||
usageTimeSeriesCursorStart: null,
|
||||
usageTimeSeriesCursorEnd: null,
|
||||
usageSessionLogs: null,
|
||||
usageSessionLogsLoading: false,
|
||||
usageTimeZone: "local",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("usage controller date interpretation params", () => {
|
||||
beforeEach(() => {
|
||||
__test.resetLegacyUsageDateParamsCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("formats UTC offsets for whole and half-hour timezones", () => {
|
||||
expect(__test.formatUtcOffset(240)).toBe("UTC-4");
|
||||
expect(__test.formatUtcOffset(-330)).toBe("UTC+5:30");
|
||||
expect(__test.formatUtcOffset(0)).toBe("UTC+0");
|
||||
});
|
||||
|
||||
it("sends specific mode with browser offset when usage timezone is local", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const state = createState(request, { usageTimeZone: "local" });
|
||||
vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330);
|
||||
|
||||
await loadUsage(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends utc mode without offset when usage timezone is utc", async () => {
|
||||
const request = vi.fn(async () => ({}));
|
||||
const state = createState(request, { usageTimeZone: "utc" });
|
||||
|
||||
await loadUsage(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "utc",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "utc",
|
||||
});
|
||||
});
|
||||
|
||||
it("captures useful error strings in loadUsage", async () => {
|
||||
const request = vi.fn(async () => {
|
||||
throw new Error("request failed");
|
||||
});
|
||||
const state = createState(request);
|
||||
|
||||
await loadUsage(state);
|
||||
|
||||
expect(state.usageError).toBe("request failed");
|
||||
});
|
||||
|
||||
it("serializes non-Error objects without object-to-string coercion", () => {
|
||||
expect(__test.toErrorMessage({ reason: "nope" })).toBe('{"reason":"nope"}');
|
||||
});
|
||||
|
||||
it("falls back and remembers compatibility when sessions.usage rejects mode/utcOffset", async () => {
|
||||
const storage = createStorageMock();
|
||||
vi.stubGlobal("localStorage", storage as unknown as Storage);
|
||||
vi.spyOn(Date.prototype, "getTimezoneOffset").mockReturnValue(-330);
|
||||
|
||||
const request = vi.fn(async (method: string, params?: unknown) => {
|
||||
if (method === "sessions.usage") {
|
||||
const record = (params ?? {}) as Record<string, unknown>;
|
||||
if ("mode" in record || "utcOffset" in record) {
|
||||
throw new Error(
|
||||
"invalid sessions.usage params: at root: unexpected property 'mode'; at root: unexpected property 'utcOffset'",
|
||||
);
|
||||
}
|
||||
return { sessions: [] };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const state = createState(request, {
|
||||
usageTimeZone: "local",
|
||||
settings: { gatewayUrl: "ws://127.0.0.1:18789" },
|
||||
});
|
||||
|
||||
await loadUsage(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(1, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(2, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
mode: "specific",
|
||||
utcOffset: "UTC+5:30",
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(3, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(4, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
});
|
||||
|
||||
// Subsequent loads for the same gateway should skip mode/utcOffset immediately.
|
||||
await loadUsage(state);
|
||||
|
||||
expect(request).toHaveBeenNthCalledWith(5, "sessions.usage", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
limit: 1000,
|
||||
includeContextWeight: true,
|
||||
});
|
||||
expect(request).toHaveBeenNthCalledWith(6, "usage.cost", {
|
||||
startDate: "2026-02-16",
|
||||
endDate: "2026-02-16",
|
||||
});
|
||||
|
||||
// Persisted flag should survive cache resets (simulating app reload).
|
||||
__test.resetLegacyUsageDateParamsCache();
|
||||
expect(__test.shouldSendLegacyDateInterpretation(state)).toBe(false);
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
|
||||
function createStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem(key: string) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,8 +19,169 @@ export type UsageState = {
|
||||
usageTimeSeriesCursorEnd: number | null;
|
||||
usageSessionLogs: SessionLogEntry[] | null;
|
||||
usageSessionLogsLoading: boolean;
|
||||
usageTimeZone: "local" | "utc";
|
||||
settings?: { gatewayUrl?: string };
|
||||
};
|
||||
|
||||
type DateInterpretationMode = "utc" | "gateway" | "specific";
|
||||
|
||||
type UsageDateInterpretationParams = {
|
||||
mode: DateInterpretationMode;
|
||||
utcOffset?: string;
|
||||
};
|
||||
|
||||
const LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY = "openclaw.control.usage.date-params.v1";
|
||||
const LEGACY_USAGE_DATE_PARAMS_DEFAULT_GATEWAY_KEY = "__default__";
|
||||
const LEGACY_USAGE_DATE_PARAMS_MODE_RE = /unexpected property ['"]mode['"]/i;
|
||||
const LEGACY_USAGE_DATE_PARAMS_OFFSET_RE = /unexpected property ['"]utcoffset['"]/i;
|
||||
const LEGACY_USAGE_DATE_PARAMS_INVALID_RE = /invalid sessions\.usage params/i;
|
||||
|
||||
let legacyUsageDateParamsCache: Set<string> | null = null;
|
||||
|
||||
function getLocalStorage(): Storage | null {
|
||||
// Support browser runtime and node tests (when localStorage is stubbed globally).
|
||||
if (typeof window !== "undefined" && window.localStorage) {
|
||||
return window.localStorage;
|
||||
}
|
||||
if (typeof localStorage !== "undefined") {
|
||||
return localStorage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadLegacyUsageDateParamsCache(): Set<string> {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return new Set<string>();
|
||||
}
|
||||
try {
|
||||
const raw = storage.getItem(LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return new Set<string>();
|
||||
}
|
||||
const parsed = JSON.parse(raw) as { unsupportedGatewayKeys?: unknown } | null;
|
||||
if (!parsed || !Array.isArray(parsed.unsupportedGatewayKeys)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
return new Set(
|
||||
parsed.unsupportedGatewayKeys
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
} catch {
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
function persistLegacyUsageDateParamsCache(cache: Set<string>) {
|
||||
const storage = getLocalStorage();
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
storage.setItem(
|
||||
LEGACY_USAGE_DATE_PARAMS_STORAGE_KEY,
|
||||
JSON.stringify({ unsupportedGatewayKeys: Array.from(cache) }),
|
||||
);
|
||||
} catch {
|
||||
// ignore quota/private-mode failures
|
||||
}
|
||||
}
|
||||
|
||||
function getLegacyUsageDateParamsCache(): Set<string> {
|
||||
if (!legacyUsageDateParamsCache) {
|
||||
legacyUsageDateParamsCache = loadLegacyUsageDateParamsCache();
|
||||
}
|
||||
return legacyUsageDateParamsCache;
|
||||
}
|
||||
|
||||
function normalizeGatewayCompatibilityKey(gatewayUrl?: string): string {
|
||||
const trimmed = gatewayUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return LEGACY_USAGE_DATE_PARAMS_DEFAULT_GATEWAY_KEY;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const pathname = parsed.pathname === "/" ? "" : parsed.pathname;
|
||||
return `${parsed.protocol}//${parsed.host}${pathname}`.toLowerCase();
|
||||
} catch {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveGatewayCompatibilityKey(state: UsageState): string {
|
||||
return normalizeGatewayCompatibilityKey(state.settings?.gatewayUrl);
|
||||
}
|
||||
|
||||
function shouldSendLegacyDateInterpretation(state: UsageState): boolean {
|
||||
return !getLegacyUsageDateParamsCache().has(resolveGatewayCompatibilityKey(state));
|
||||
}
|
||||
|
||||
function rememberLegacyDateInterpretation(state: UsageState) {
|
||||
const cache = getLegacyUsageDateParamsCache();
|
||||
cache.add(resolveGatewayCompatibilityKey(state));
|
||||
persistLegacyUsageDateParamsCache(cache);
|
||||
}
|
||||
|
||||
function isLegacyDateInterpretationUnsupportedError(err: unknown): boolean {
|
||||
const message = toErrorMessage(err);
|
||||
return (
|
||||
LEGACY_USAGE_DATE_PARAMS_INVALID_RE.test(message) &&
|
||||
(LEGACY_USAGE_DATE_PARAMS_MODE_RE.test(message) ||
|
||||
LEGACY_USAGE_DATE_PARAMS_OFFSET_RE.test(message))
|
||||
);
|
||||
}
|
||||
|
||||
const formatUtcOffset = (timezoneOffsetMinutes: number): string => {
|
||||
// `Date#getTimezoneOffset()` is minutes to add to local time to reach UTC.
|
||||
// Convert to UTC±H[:MM] where positive means east of UTC.
|
||||
const offsetFromUtcMinutes = -timezoneOffsetMinutes;
|
||||
const sign = offsetFromUtcMinutes >= 0 ? "+" : "-";
|
||||
const absMinutes = Math.abs(offsetFromUtcMinutes);
|
||||
const hours = Math.floor(absMinutes / 60);
|
||||
const minutes = absMinutes % 60;
|
||||
return minutes === 0
|
||||
? `UTC${sign}${hours}`
|
||||
: `UTC${sign}${hours}:${minutes.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const buildDateInterpretationParams = (
|
||||
timeZone: "local" | "utc",
|
||||
includeDateInterpretation: boolean,
|
||||
): UsageDateInterpretationParams | undefined => {
|
||||
if (!includeDateInterpretation) {
|
||||
return undefined;
|
||||
}
|
||||
if (timeZone === "utc") {
|
||||
return { mode: "utc" };
|
||||
}
|
||||
return {
|
||||
mode: "specific",
|
||||
utcOffset: formatUtcOffset(new Date().getTimezoneOffset()),
|
||||
};
|
||||
};
|
||||
|
||||
function toErrorMessage(err: unknown): string {
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (err instanceof Error && typeof err.message === "string" && err.message.trim()) {
|
||||
return err.message;
|
||||
}
|
||||
if (err && typeof err === "object") {
|
||||
try {
|
||||
const serialized = JSON.stringify(err);
|
||||
if (serialized) {
|
||||
return serialized;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return "request failed";
|
||||
}
|
||||
|
||||
export async function loadUsage(
|
||||
state: UsageState,
|
||||
overrides?: {
|
||||
@@ -28,7 +189,9 @@ export async function loadUsage(
|
||||
endDate?: string;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
// Capture client for TS18047 work around on it being possibly null
|
||||
const client = state.client;
|
||||
if (!client || !state.connected) {
|
||||
return;
|
||||
}
|
||||
if (state.usageLoading) {
|
||||
@@ -39,31 +202,71 @@ export async function loadUsage(
|
||||
try {
|
||||
const startDate = overrides?.startDate ?? state.usageStartDate;
|
||||
const endDate = overrides?.endDate ?? state.usageEndDate;
|
||||
const runUsageRequests = async (includeDateInterpretation: boolean) => {
|
||||
const dateInterpretation = buildDateInterpretationParams(
|
||||
state.usageTimeZone,
|
||||
includeDateInterpretation,
|
||||
);
|
||||
return await Promise.all([
|
||||
client.request("sessions.usage", {
|
||||
startDate,
|
||||
endDate,
|
||||
...dateInterpretation,
|
||||
limit: 1000, // Cap at 1000 sessions
|
||||
includeContextWeight: true,
|
||||
}),
|
||||
client.request("usage.cost", {
|
||||
startDate,
|
||||
endDate,
|
||||
...dateInterpretation,
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
// Load both endpoints in parallel
|
||||
const [sessionsRes, costRes] = await Promise.all([
|
||||
state.client.request("sessions.usage", {
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 1000, // Cap at 1000 sessions
|
||||
includeContextWeight: true,
|
||||
}),
|
||||
state.client.request("usage.cost", { startDate, endDate }),
|
||||
]);
|
||||
const applyUsageResults = (sessionsRes: unknown, costRes: unknown) => {
|
||||
if (sessionsRes) {
|
||||
state.usageResult = sessionsRes as SessionsUsageResult;
|
||||
}
|
||||
if (costRes) {
|
||||
state.usageCostSummary = costRes as CostUsageSummary;
|
||||
}
|
||||
};
|
||||
|
||||
if (sessionsRes) {
|
||||
state.usageResult = sessionsRes as SessionsUsageResult;
|
||||
}
|
||||
if (costRes) {
|
||||
state.usageCostSummary = costRes as CostUsageSummary;
|
||||
const includeDateInterpretation = shouldSendLegacyDateInterpretation(state);
|
||||
try {
|
||||
const [sessionsRes, costRes] = await runUsageRequests(includeDateInterpretation);
|
||||
applyUsageResults(sessionsRes, costRes);
|
||||
} catch (err) {
|
||||
if (includeDateInterpretation && isLegacyDateInterpretationUnsupportedError(err)) {
|
||||
// Older gateways reject `mode`/`utcOffset` in `sessions.usage`.
|
||||
// Remember this per gateway and retry once without those fields.
|
||||
rememberLegacyDateInterpretation(state);
|
||||
const [sessionsRes, costRes] = await runUsageRequests(false);
|
||||
applyUsageResults(sessionsRes, costRes);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
state.usageError = String(err);
|
||||
state.usageError = toErrorMessage(err);
|
||||
} finally {
|
||||
state.usageLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
formatUtcOffset,
|
||||
buildDateInterpretationParams,
|
||||
toErrorMessage,
|
||||
isLegacyDateInterpretationUnsupportedError,
|
||||
normalizeGatewayCompatibilityKey,
|
||||
shouldSendLegacyDateInterpretation,
|
||||
rememberLegacyDateInterpretation,
|
||||
resetLegacyUsageDateParamsCache: () => {
|
||||
legacyUsageDateParamsCache = null;
|
||||
},
|
||||
};
|
||||
|
||||
export async function loadSessionTimeSeries(state: UsageState, sessionKey: string) {
|
||||
if (!state.client || !state.connected) {
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user