fix(gateway): repair usage cost aggregation across agents (#93022)

* fix(gateway): aggregate usage cost across agents

* fix(clownfish): address review for ghcrawl-157040-autonomous-smoke (1)

Co-authored-by: luke-skywalker-open-claw <262978557+luke-skywalker-open-claw@users.noreply.github.com>

Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com>
This commit is contained in:
openclaw-clownfish[bot]
2026-06-15 01:06:36 +08:00
committed by GitHub
parent b470316fc0
commit 4c2fef4a3b
3 changed files with 129 additions and 2 deletions
+93
View File
@@ -37,6 +37,39 @@ import { testApi, usageHandlers } from "./usage.js";
describe("gateway usage helpers", () => {
const dayMs = 24 * 60 * 60 * 1000;
const costSummary = (params: { date?: string; totalTokens: number; totalCost: number }) => ({
updatedAt: Date.now(),
days: 1,
daily: [
{
date: params.date ?? "2026-02-01",
input: params.totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: params.totalTokens,
totalCost: params.totalCost,
inputCost: params.totalCost,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
},
],
totals: {
input: params.totalTokens,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: params.totalTokens,
totalCost: params.totalCost,
inputCost: params.totalCost,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
},
});
function expectUtcDateRange(
range: ReturnType<typeof testApi.parseDateRange>,
@@ -257,4 +290,64 @@ describe("gateway usage helpers", () => {
expect.objectContaining({ agentId: "research" }),
);
});
it("aggregates usage.cost only for explicit all-agent scope", async () => {
vi.mocked(loadCostUsageSummaryFromCache).mockImplementation(async (params) =>
params?.agentId === "opus"
? costSummary({ totalTokens: 20, totalCost: 2 })
: costSummary({ totalTokens: 10, totalCost: 1 }),
);
const config = {
agents: { list: [{ id: "main" }, { id: "opus" }] },
session: {},
} as OpenClawConfig;
const context = { getRuntimeConfig: () => config };
const params = { startDate: "2026-02-01", endDate: "2026-02-01", mode: "utc" };
const defaultRespond = vi.fn();
await usageHandlers["usage.cost"]({
respond: defaultRespond,
params,
context,
} as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]);
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(1);
expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[0]?.[0]?.agentId).toBeUndefined();
expect(defaultRespond.mock.calls[0]?.[1]).toMatchObject({
totals: { totalTokens: 10, totalCost: 1 },
});
const aggregateRespond = vi.fn();
await usageHandlers["usage.cost"]({
respond: aggregateRespond,
params: { ...params, agentScope: "all" },
context,
} as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]);
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(3);
expect(
vi.mocked(loadCostUsageSummaryFromCache)
.mock.calls.slice(1)
.map((call) => call[0]?.agentId),
).toEqual(["main", "opus"]);
expect(aggregateRespond.mock.calls[0]?.[0]).toBe(true);
expect(aggregateRespond.mock.calls[0]?.[1]).toMatchObject({
totals: { totalTokens: 30, totalCost: 3 },
daily: [{ date: "2026-02-01", totalTokens: 30, totalCost: 3 }],
});
const mainRespond = vi.fn();
await usageHandlers["usage.cost"]({
respond: mainRespond,
params: { ...params, agentId: "main" },
context,
} as unknown as Parameters<(typeof usageHandlers)["usage.cost"]>[0]);
expect(vi.mocked(loadCostUsageSummaryFromCache)).toHaveBeenCalledTimes(4);
expect(vi.mocked(loadCostUsageSummaryFromCache).mock.calls[3]?.[0]?.agentId).toBe("main");
expect(mainRespond.mock.calls[0]?.[1]).toMatchObject({
totals: { totalTokens: 10, totalCost: 1 },
});
});
});
@@ -0,0 +1,23 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../../../../src/test-utils/env.js";
import { formatDayLabel, formatFullDate } from "./usage-metrics.ts";
describe("usage metrics date labels", () => {
it("formats YYYY-MM-DD values as stable calendar dates in negative UTC offsets", async () => {
await withEnvAsync({ TZ: "America/Los_Angeles" }, async () => {
const date = new Date(2026, 1, 1);
expect(formatDayLabel("2026-02-01")).toBe(
date.toLocaleDateString(undefined, { month: "short", day: "numeric" }),
);
expect(formatFullDate("2026-02-01")).toBe(
date.toLocaleDateString(undefined, { month: "long", day: "numeric", year: "numeric" }),
);
});
});
it("leaves invalid day labels unchanged", () => {
expect(formatDayLabel("2026-02-31")).toBe("2026-02-31");
expect(formatFullDate("2026-02-31")).toBe("2026-02-31");
});
});
+13 -2
View File
@@ -452,8 +452,19 @@ function parseYmdDate(dateStr: string): Date | null {
return null;
}
const [, y, m, d] = match;
const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d)));
return Number.isNaN(date.valueOf()) ? null : date;
const year = Number(y);
const monthIndex = Number(m) - 1;
const day = Number(d);
const date = new Date(year, monthIndex, day);
if (
Number.isNaN(date.valueOf()) ||
date.getFullYear() !== year ||
date.getMonth() !== monthIndex ||
date.getDate() !== day
) {
return null;
}
return date;
}
function formatDayLabel(dateStr: string): string {