fix(ui): surface provider-usage failures instead of silent empty panels (#120309)

* fix(ui): surface provider-usage failures instead of empty panels

* fix(ui): complete typed route-data fixtures and stop reporting cancelled usage requests as failed

- Add providerUsageUnavailable to the five UsageRouteData fixtures in
  gateway-source-replacement.test.ts; check-test-types passes again.
- requestProviderUsage reports failed only for non-cancelled rejections; an
  aborted request is the caller superseding its own load, not an outage.
- Cover answered, failed, and cancelled outcomes.

* chore(ui): keep ProviderUsageFetch local to its module

* fix(ui): clear the provider failure flag when an aggregate usage load fails

- A failed aggregate refresh says nothing about provider usage; the stale
  flag no longer keeps claiming the last provider request failed after a
  later usage.cost or sessions.usage rejection.
- Sequential regression: usage.status failure, then an aggregate failure,
  ends with the flag cleared.

* test(ui): type the usage route data on the test element

* test(ui): cover provider usage request outcomes

* fix(ui): model provider usage request outcome

* fix(ui): preserve provider usage outcomes

* fix(ui): resolve provider usage build
This commit is contained in:
Sasan
2026-08-20 21:15:14 -04:00
committed by GitHub
parent 319df0e7a1
commit 034325d3e2
21 changed files with 611 additions and 42 deletions
+12
View File
@@ -380,6 +380,18 @@ describe("Control UI Vite config", () => {
find: "@openclaw/normalization-core/phone-presentation",
replacement: path.join(repoRoot, "packages/normalization-core/src/phone-presentation.ts"),
});
const resultAliasIndex = aliases.findIndex(
(alias) => alias.find === "@openclaw/normalization-core/result",
);
const rootAliasIndex = aliases.findIndex(
(alias) => alias.find === "@openclaw/normalization-core",
);
expect(aliases[resultAliasIndex]).toEqual({
find: "@openclaw/normalization-core/result",
replacement: path.join(repoRoot, "packages/normalization-core/src/result.ts"),
});
expect(resultAliasIndex).toBeGreaterThanOrEqual(0);
expect(rootAliasIndex).toBeGreaterThan(resultAliasIndex);
});
it("uses Node package resolution for external packages inherited by worktrees", () => {
@@ -0,0 +1,117 @@
// Control UI E2E proves provider-usage request failures remain distinct from provider data.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { expect, it } from "vitest";
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
const suite = createControlUiE2eSuite({
name: "Control UI Model Provider usage outcomes mocked Gateway E2E",
startServerBeforeBrowser: true,
unavailableMessage: (executablePath) =>
`Playwright Chromium is not available at ${executablePath}`,
});
const now = Date.now();
const recordVisuals = process.env.OPENCLAW_UI_E2E_RECORD === "1";
const artifactDir = path.resolve(".artifacts/control-ui-e2e/model-providers");
const unavailableMessage =
"Provider usage is unavailable; the last request failed. Refresh to retry.";
function providerUsageResponses(usageStatus: unknown) {
return {
"config.get": { config: {}, hash: "provider-usage-outcome" },
"models.list": { models: [] },
"models.authStatus": {
ts: now,
providers: [
{
provider: "openai",
displayName: "OpenAI",
status: "ok",
profiles: [],
},
],
},
"sessions.usage": { aggregates: { byProvider: [] } },
"usage.status": usageStatus,
};
}
suite.define(() => {
it("shows a visible warning when the provider usage request fails", async () => {
await suite.withPage(
{
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 1_000, width: 1_440 },
},
async ({ page }) => {
const gateway = await installMockGateway(page, {
methodResponses: providerUsageResponses({
__mockError: { code: "INTERNAL_ERROR", message: "gateway transport unavailable" },
}),
});
await page.goto(`${suite.server.baseUrl}settings/model-providers`);
await page.locator('[data-provider-id="openai"]').waitFor();
await expect
.poll(async () => (await gateway.getRequests("usage.status")).length)
.toBeGreaterThan(0);
await expect
.poll(() => page.locator(".settings-page").textContent())
.toContain(unavailableMessage);
if (recordVisuals) {
await mkdir(artifactDir, { recursive: true });
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "provider-usage-request-failed.png"),
});
}
},
);
});
it("keeps provider-scoped usage errors as data without the global warning", async () => {
await suite.withPage(
{
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 1_000, width: 1_440 },
},
async ({ page }) => {
const gateway = await installMockGateway(page, {
methodResponses: providerUsageResponses({
updatedAt: now,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [],
error: "provider API unavailable",
},
],
}),
});
await page.goto(`${suite.server.baseUrl}settings/model-providers`);
const card = page.locator('[data-provider-id="openai"]');
await card.waitFor();
await expect
.poll(async () => (await gateway.getRequests("usage.status")).length)
.toBeGreaterThan(0);
await expect.poll(() => card.textContent()).toContain("provider API unavailable");
await expect
.poll(() => page.locator(".settings-page").textContent())
.not.toContain(unavailableMessage);
if (recordVisuals) {
await mkdir(artifactDir, { recursive: true });
await card.screenshot({
animations: "disabled",
path: path.join(artifactDir, "provider-usage-provider-error.png"),
});
}
},
);
});
});
+113
View File
@@ -11,6 +11,9 @@ const suite = createControlUiE2eSuite({
`Playwright Chromium is not available at ${executablePath}`,
});
const recordVisuals = process.env.OPENCLAW_UI_E2E_RECORD === "1";
const providerUsageArtifactDir = path.resolve(".artifacts/control-ui-e2e/provider-usage-outcomes");
const totals = {
input: 1_200_000,
output: 300_000,
@@ -25,6 +28,20 @@ const totals = {
missingCostEntries: 0,
};
const emptyTotals = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
totalCost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
missingCostEntries: 0,
};
function dayOffset(offset: number): string {
const date = new Date();
date.setHours(12, 0, 0, 0);
@@ -56,7 +73,103 @@ const daily = [
dailyEntry(0, 11, 1_100_000),
];
function emptyUsageResponses() {
const updatedAt = Date.now();
const date = dayOffset(0);
return {
"sessions.usage": {
updatedAt,
startDate: date,
endDate: date,
sessions: [],
totals: emptyTotals,
aggregates: {
messages: { total: 0, user: 0, assistant: 0, toolCalls: 0, toolResults: 0, errors: 0 },
tools: { totalCalls: 0, uniqueTools: 0, tools: [] },
byModel: [],
byProvider: [],
byAgent: [],
byChannel: [],
daily: [],
},
},
"usage.cost": { updatedAt, days: 1, daily: [], totals: emptyTotals },
};
}
suite.define(() => {
it("shows a visible provider usage warning when the usage status request fails", async () => {
await suite.withPage(
{
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 1_000, width: 1_440 },
},
async ({ page }) => {
const gateway = await installMockGateway(page, {
methodResponses: {
...emptyUsageResponses(),
"usage.status": {
__mockError: { code: "INTERNAL_ERROR", message: "gateway transport unavailable" },
},
},
});
await page.goto(`${suite.server.baseUrl}usage`);
await expect
.poll(async () => (await gateway.getRequests("usage.status")).length)
.toBeGreaterThan(0);
await page.locator(".usage-empty-state").waitFor();
await expect
.poll(() => page.locator(".usage-page").textContent())
.toContain("Provider usage is unavailable; the last request failed. Refresh to retry.");
if (recordVisuals) {
await mkdir(providerUsageArtifactDir, { recursive: true });
await page.locator(".usage-page").screenshot({
animations: "disabled",
path: path.join(providerUsageArtifactDir, "usage-status-request-failed.png"),
});
}
},
);
});
it("does not show the provider usage warning for a valid empty response", async () => {
await suite.withPage(
{
locale: "en-US",
serviceWorkers: "block",
viewport: { height: 1_000, width: 1_440 },
},
async ({ page }) => {
const gateway = await installMockGateway(page, {
methodResponses: {
...emptyUsageResponses(),
"usage.status": { updatedAt: Date.now(), providers: [] },
},
});
await page.goto(`${suite.server.baseUrl}usage`);
await expect
.poll(async () => (await gateway.getRequests("usage.status")).length)
.toBeGreaterThan(0);
await page.locator(".usage-empty-state").waitFor();
await expect
.poll(() => page.locator(".usage-page").textContent())
.not.toContain(
"Provider usage is unavailable; the last request failed. Refresh to retry.",
);
if (recordVisuals) {
await mkdir(providerUsageArtifactDir, { recursive: true });
await page.locator(".usage-page").screenshot({
animations: "disabled",
path: path.join(providerUsageArtifactDir, "usage-status-empty.png"),
});
}
},
);
});
it("keeps pending sessions visible when their UTC activity day is selected", async () => {
const selectedDay = "2026-05-14";
const updatedAt = Date.parse("2026-05-14T00:30:00.000Z");
+1
View File
@@ -4693,6 +4693,7 @@ export const en: TranslationMap = {
providerUsage: {
title: "Provider plans & billing",
subtitle: "Live plan, quota, balance, and budget data reported by configured providers.",
unavailable: "Provider usage is unavailable; the last request failed. Refresh to retry.",
balance: "Balance",
spend: "Usage",
budget: "Budget",
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import type { GatewayBrowserClient } from "../api/gateway.ts";
import { requestProviderUsage } from "./provider-usage-request.ts";
function clientWith(request: GatewayBrowserClient["request"]): GatewayBrowserClient {
return { request } as unknown as GatewayBrowserClient;
}
describe("requestProviderUsage", () => {
it("returns the summary for an answered request", async () => {
const summary = { updatedAt: 1, providers: [] };
const client = clientWith((async () => summary) as GatewayBrowserClient["request"]);
await expect(requestProviderUsage(client)).resolves.toEqual({ ok: true, value: summary });
});
it("records a rejected request as failed", async () => {
const client = clientWith((async () => {
throw new Error("gateway unreachable");
}) as GatewayBrowserClient["request"]);
await expect(requestProviderUsage(client)).resolves.toEqual({
ok: false,
error: { kind: "request-failed" },
});
});
it("does not record a cancelled request as failed", async () => {
const controller = new AbortController();
const client = clientWith((async () => {
controller.abort();
throw new Error("aborted");
}) as GatewayBrowserClient["request"]);
await expect(requestProviderUsage(client, { signal: controller.signal })).rejects.toThrow(
"aborted",
);
});
});
+27
View File
@@ -0,0 +1,27 @@
// One boundary for the usage.status RPC. A successful empty response is valid data;
// request failure remains a separate closed Result arm for consumer views.
// Never convert cancellation into that arm: Lit Task discards superseded work.
import { err, ok, type Result } from "@openclaw/normalization-core/result";
import type { UsageSummary } from "../../../src/infra/provider-usage.types.js";
import type { GatewayBrowserClient } from "../api/gateway.ts";
type ProviderUsageRequestFailure = { kind: "request-failed" };
export type ProviderUsageRequestResult = Result<UsageSummary, ProviderUsageRequestFailure>;
export async function requestProviderUsage(
client: GatewayBrowserClient,
opts?: { signal?: AbortSignal },
): Promise<ProviderUsageRequestResult> {
try {
const summary = opts?.signal
? await client.request<UsageSummary>("usage.status", undefined, { signal: opts.signal })
: await client.request<UsageSummary>("usage.status");
return ok<UsageSummary, ProviderUsageRequestFailure>(summary);
} catch (error) {
if (opts?.signal?.aborted) {
throw error;
}
return err<UsageSummary, ProviderUsageRequestFailure>({ kind: "request-failed" });
}
}
@@ -214,7 +214,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
},
result,
costSummary: null,
providerUsageSummary: null,
providerUsage: null,
loadedAtMs: Date.now(),
error: null,
} satisfies UsageRouteData;
@@ -258,7 +258,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
},
result: staleResult,
costSummary: null,
providerUsageSummary: null,
providerUsage: null,
loadedAtMs: Date.now(),
error: null,
};
@@ -297,7 +297,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
},
result,
costSummary: null,
providerUsageSummary: null,
providerUsage: null,
loadedAtMs: Date.now(),
error: null,
};
@@ -347,7 +347,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
},
result: { sessions: [{ key: "cached" }] } as unknown as UsageRouteData["result"],
costSummary: null,
providerUsageSummary: null,
providerUsage: null,
loadedAtMs: Date.now(),
error: null,
};
@@ -404,7 +404,7 @@ describe("gateway source replacement across reconnect with a reused client", ()
},
result,
costSummary: null,
providerUsageSummary: null,
providerUsage: null,
loadedAtMs: Date.now(),
error: null,
};
@@ -686,19 +686,19 @@ describe("gateway source replacement across reconnect with a reused client", ()
const client = {} as GatewayBrowserClient;
const page = createPage("openclaw-usage-page", contextWithClient(client)) as TestPage & {
usageResult: unknown;
providerUsageSummary: unknown;
providerUsage: unknown;
usageSelectedSessions: string[];
};
document.body.append(page);
await page.updateComplete;
page.usageResult = { sessions: [{ key: "old" }] };
page.providerUsageSummary = { providers: [{ provider: "old" }] };
page.providerUsage = { ok: true, value: { providers: [{ provider: "old" }] } };
page.usageSelectedSessions = ["old"];
await replaceContext(page, client);
expect(page.usageResult).toBeNull();
expect(page.providerUsageSummary).toBeNull();
expect(page.providerUsage).toBeNull();
expect(page.usageSelectedSessions).toEqual([]);
});
+66 -1
View File
@@ -104,11 +104,76 @@ describe("loadModelProvidersData", () => {
expect(result.providerOutcomes).toEqual([]);
expect(result.catalogError).toBeNull();
expect(result.config).toEqual({});
expect(result.providerUsage).toEqual({ updatedAt: 1, providers: [] });
expect(result.providerUsage).toEqual({ ok: true, value: { updatedAt: 1, providers: [] } });
expect(result.costByProvider).toEqual([]);
expect(result.error).toBeNull();
});
it("records a usage.status failure instead of reducing it to no data", async () => {
const request = vi.fn(async (method: string) => {
switch (method) {
case "models.authStatus":
return { ts: 1, providers: [] };
case "models.list":
return { models: [] };
case "config.get":
return { config: {}, hash: "hash" };
case "usage.status":
throw new Error("usage.status failed");
case "sessions.usage":
return { aggregates: { byProvider: [] } };
default:
return {};
}
});
const client = { request } as unknown as GatewayBrowserClient;
const result = await loadModelProvidersData(client, { agentId: "main" });
expect(result.providerUsage).toEqual({
ok: false,
error: { kind: "request-failed" },
});
expect(result.error).toBeNull();
});
it("keeps provider-scoped usage errors as data instead of a global request failure", async () => {
const request = vi.fn(async (method: string) => {
switch (method) {
case "models.authStatus":
return { ts: 1, providers: [] };
case "models.list":
return { models: [] };
case "config.get":
return { config: {}, hash: "hash" };
case "usage.status":
return {
updatedAt: 1,
providers: [
{
provider: "openai",
displayName: "OpenAI",
windows: [],
error: "provider API unavailable",
},
],
};
case "sessions.usage":
return { aggregates: { byProvider: [] } };
default:
return {};
}
});
const client = { request } as unknown as GatewayBrowserClient;
const result = await loadModelProvidersData(client, { agentId: "main" });
expect(result.providerUsage).toMatchObject({
ok: true,
value: { providers: [{ error: "provider API unavailable" }] },
});
});
it("surfaces an explicit catalog refresh failure while retaining cached configured models", async () => {
const request = vi.fn(async (method: string, params?: unknown) => {
if (method === "models.list" && (params as { view?: string } | undefined)?.view === "all") {
+8 -5
View File
@@ -1,7 +1,6 @@
// Fetches the gateway signals behind the Models settings page.
// Each source degrades independently: a missing usage hook or an older
// gateway must not blank the provider list.
import type { UsageSummary } from "../../../../src/infra/provider-usage.types.js";
import type { SessionModelUsage } from "../../../../src/infra/session-cost-usage.types.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type {
@@ -17,6 +16,10 @@ import {
isMissingOperatorReadScopeError,
} from "../../lib/gateway-errors.ts";
import { loadModelAuthStatus } from "../../lib/model-auth.ts";
import {
requestProviderUsage,
type ProviderUsageRequestResult,
} from "../../lib/provider-usage-request.ts";
import { requestSessionUsage } from "../../lib/sessions/index.ts";
import { loadModels } from "../chat/models.ts";
@@ -29,7 +32,7 @@ export type ModelProvidersData = {
providerOutcomes: ModelCatalogProviderOutcome[];
catalogError: string | null;
config: Record<string, unknown> | null;
providerUsage: UsageSummary | null;
providerUsage: ProviderUsageRequestResult | null;
costByProvider: SessionModelUsage[] | null;
updatedAt: number | null;
error: string | null;
@@ -94,7 +97,7 @@ export async function loadModelProvidersData(
agentId: opts.agentId,
preparedOnly: true,
}).catch(() => null);
const [authStatus, models, catalogResult, config, providerUsage, costByProvider] =
const [authStatus, models, catalogResult, config, providerUsageFetch, costByProvider] =
await Promise.all([
loadModelAuthStatus(client, opts).then(
(result) => ({ ok: true as const, result }),
@@ -105,7 +108,7 @@ export async function loadModelProvidersData(
request<ConfigSnapshot>("config.get", {})
.then((snapshot) => resolveEditableSnapshotConfig(snapshot))
.catch(() => null),
request<UsageSummary>("usage.status").catch(() => null),
requestProviderUsage(client, opts.signal ? { signal: opts.signal } : undefined),
requestSessionUsage(client, {
startDate: localDate(MODEL_PROVIDERS_COST_DAYS - 1),
endDate: localDate(0),
@@ -122,7 +125,7 @@ export async function loadModelProvidersData(
providerOutcomes: catalogResult.ok ? (catalogResult.result?.providerOutcomes ?? []) : [],
catalogError: catalogResult.ok ? null : errorMessage(catalogResult.error),
config,
providerUsage,
providerUsage: providerUsageFetch,
costByProvider,
updatedAt: Date.now(),
// Auth status is the primary provider list; its failure is the only one
@@ -605,8 +605,10 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
const modelBehavior = readModelBehaviorConfig(agentsDefaults);
// This keeps the pre-move General busy gate sourced from the same update state.
const configBusy = this.configBusy();
const providerUsage = data.providerUsage?.ok ? data.providerUsage.value : null;
const cards = buildModelProviderCards({
...data,
providerUsage,
configProviderIds: config.providerIds,
configApiKeyProviderIds: config.apiKeyProviderIds,
configProviderAuthModes: config.providerAuthModes,
@@ -625,6 +627,7 @@ export class ModelProvidersPage extends OpenClawLightDomElement {
loading: gatewaySnapshot.phase === "connected" && this.data === null && !rosterError,
refreshing: this.refreshTask.status === TaskStatus.PENDING,
error: rosterError ?? data.error ?? data.catalogError,
providerUsageFailed: data.providerUsage?.ok === false,
updatedAt: data.updatedAt,
costDays: MODEL_PROVIDERS_COST_DAYS,
credentialAgentLabel: selectedAgentLabel,
+10
View File
@@ -30,6 +30,7 @@ function props(overrides: Partial<ModelProvidersViewProps> = {}): ModelProviders
loading: false,
refreshing: false,
error: null,
providerUsageFailed: false,
updatedAt: 1,
costDays: 30,
credentialAgentLabel: "Writer",
@@ -118,6 +119,15 @@ function selectSegment(group: SegmentedGroup, value: string) {
}
describe("renderModelProviders", () => {
it("surfaces a provider-usage failure on the provider list", () => {
const container = document.createElement("div");
render(renderModelProviders(props({ providerUsageFailed: true })), container);
expect(container.textContent).toContain(
"Provider usage is unavailable; the last request failed. Refresh to retry.",
);
});
beforeEach(async () => {
await i18n.setLocale("en");
});
+14 -8
View File
@@ -44,6 +44,7 @@ type ModelProvidersViewProps = {
loading: boolean;
refreshing: boolean;
error: string | null;
providerUsageFailed: boolean;
updatedAt: number | null;
costDays: number;
credentialAgentLabel: string;
@@ -574,6 +575,16 @@ function renderModelReadiness(props: ModelProvidersViewProps) {
`;
}
function renderProviderNoticeRow(text: string) {
return html`
<div class="settings-row">
<div class="settings-row__text">
<span class="settings-row__desc provider-usage-error">${text}</span>
</div>
</div>
`;
}
export function renderModelProviders(props: ModelProvidersViewProps) {
if (!props.connected) {
return renderSettingsPage(
@@ -587,14 +598,9 @@ export function renderModelProviders(props: ModelProvidersViewProps) {
`);
}
const providerRows = html`
${props.error
? html`
<div class="settings-row">
<div class="settings-row__text">
<span class="settings-row__desc provider-usage-error">${props.error}</span>
</div>
</div>
`
${props.error ? renderProviderNoticeRow(props.error) : nothing}
${props.providerUsageFailed
? renderProviderNoticeRow(t("usage.providerUsage.unavailable"))
: nothing}
${props.cards.length === 0
? renderSettingsEmpty(
+4 -7
View File
@@ -1,7 +1,7 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { CostUsageSummary } from "../../api/types.ts";
import { requestProviderUsage } from "../../lib/provider-usage-request.ts";
import { buildSessionUsageDateParams, requestSessionUsage } from "../../lib/sessions/index.ts";
import type { ProviderUsageSummary } from "./data-types.ts";
export async function requestUsageSnapshot(
client: GatewayBrowserClient,
@@ -20,15 +20,12 @@ export async function requestUsageSnapshot(
...(query.agentId ? { agentId: query.agentId } : { agentScope: "all" as const }),
...buildSessionUsageDateParams(query.timeZone),
};
const [result, costSummary, providerUsageSummary] = await Promise.all([
const [result, costSummary, providerUsage] = await Promise.all([
requestSessionUsage(client, query),
signal
? client.request<CostUsageSummary>("usage.cost", costParams, { signal })
: client.request<CostUsageSummary>("usage.cost", costParams),
(signal
? client.request<ProviderUsageSummary>("usage.status", undefined, { signal })
: client.request<ProviderUsageSummary>("usage.status")
).catch(() => null),
requestProviderUsage(client, signal ? { signal } : undefined),
]);
return { result, costSummary, providerUsageSummary };
return { result, costSummary, providerUsage };
}
+26
View File
@@ -7,6 +7,32 @@ import { page } from "./route.ts";
import type { UsageRouteData } from "./usage-page.ts";
describe("usage route", () => {
it("records a provider usage request failure separately from an empty response", async () => {
const request = vi.fn(async (method: string) => {
switch (method) {
case "sessions.usage":
return { sessions: [], totals: null };
case "usage.cost":
return { daily: [] };
case "usage.status":
throw new Error("gateway transport unavailable");
default:
return {};
}
});
const client = { request } as unknown as GatewayBrowserClient;
const gateway = { snapshot: { phase: "connected", client } };
const context = {
gateway,
agentSelection: { state: { scopeId: "main" } },
} as unknown as ApplicationContext;
const result = (await page.loader?.(context, {} as RouteLoaderOptions)) as UsageRouteData;
expect(result.error).toBeNull();
expect(result.providerUsage).toEqual({ ok: false, error: { kind: "request-failed" } });
});
it("redacts secrets in displayed loader failures", async () => {
const request = vi.fn(async (method: string) => {
if (method === "sessions.usage") {
+5 -3
View File
@@ -40,7 +40,7 @@ async function loadUsageRouteData(context: ApplicationContext): Promise<UsageRou
query,
result: null,
costSummary: null,
providerUsageSummary: null,
providerUsage: null,
loadedAtMs: null,
error: null,
};
@@ -55,7 +55,9 @@ async function loadUsageRouteData(context: ApplicationContext): Promise<UsageRou
gateway,
gatewaySnapshot,
query,
...snapshot,
result: snapshot.result,
costSummary: snapshot.costSummary,
providerUsage: snapshot.providerUsage,
loadedAtMs: Date.now(),
error: null,
};
@@ -66,7 +68,7 @@ async function loadUsageRouteData(context: ApplicationContext): Promise<UsageRou
query,
result: null,
costSummary: null,
providerUsageSummary: null,
providerUsage: null,
loadedAtMs: null,
error: errorMessage(error),
};
+1
View File
@@ -48,6 +48,7 @@ type UsageDataState = {
costDaily: CostDailyEntry[];
cacheStatus: SessionsUsageResult["cacheStatus"];
providerUsage: ProviderUsageSummary["providers"];
providerUsageUnavailable: boolean;
};
export type UsageFilterState = {
+119
View File
@@ -6,10 +6,14 @@ import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gatewa
import type { SessionUsageTimeSeries } from "../../api/types.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { SessionLogEntry } from "./types.ts";
import type { UsageRouteData } from "./usage-page.ts";
import "./usage-page.ts";
type TestUsagePage = HTMLElement & {
context: ApplicationContext;
providerUsage: { ok: boolean } | null;
routeData: UsageRouteData;
usageError: string | null;
usageSelectedSessions: string[];
usageTimeSeries: SessionUsageTimeSeries | null;
usageTimeSeriesStatus: { error: string | null; hasLoaded: boolean; stale: boolean };
@@ -77,6 +81,121 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("UsagePage provider usage outcome", () => {
it("keeps the last successful provider usage data when a later aggregate load fails", async () => {
let phase = 1;
const summary = { updatedAt: 1, providers: [{ provider: "openai", windows: [] }] };
const request = vi.fn(async (method: string): Promise<unknown> => {
if (method === "usage.status") {
return summary;
}
if (method === "usage.cost") {
if (phase === 2) {
throw new Error("cost unavailable");
}
return { daily: [] };
}
return { sessions: [], totals: null };
});
const page = document.createElement("openclaw-usage-page") as TestUsagePage;
page.context = contextWithClient({ request } as unknown as GatewayBrowserClient);
page.render = () => nothing;
document.body.append(page);
await page.updateComplete;
page.routeData = {
gateway: page.context.gateway,
gatewaySnapshot: page.context.gateway.snapshot,
query: {
startDate: "2026-08-07",
endDate: "2026-08-07",
scope: "family",
timeZone: "local",
agentId: null,
},
result: null,
costSummary: null,
providerUsage: null,
loadedAtMs: null,
error: null,
};
await page.updateComplete;
const refresh = () => {
(page as unknown as { refreshPolicy: { reload: () => void } }).refreshPolicy.reload();
};
refresh();
await vi.waitFor(() => {
expect(page.providerUsage).toEqual({ ok: true, value: summary });
});
phase = 2;
refresh();
await vi.waitFor(() => {
expect(page.usageError).not.toBeNull();
});
expect(page.providerUsage).toEqual({ ok: true, value: summary });
});
it("clears a stale provider request failure when a later aggregate load fails", async () => {
let phase = 1;
const request = vi.fn(async (method: string): Promise<unknown> => {
if (method === "usage.status") {
if (phase === 1) {
throw new Error("provider usage unreachable");
}
return { updatedAt: 2, providers: [] };
}
if (method === "usage.cost") {
if (phase === 2) {
throw new Error("cost unavailable");
}
return { daily: [] };
}
return { sessions: [], totals: null };
});
const page = document.createElement("openclaw-usage-page") as TestUsagePage;
page.context = contextWithClient({ request } as unknown as GatewayBrowserClient);
page.render = () => nothing;
document.body.append(page);
await page.updateComplete;
page.routeData = {
gateway: page.context.gateway,
gatewaySnapshot: page.context.gateway.snapshot,
query: {
startDate: "2026-08-07",
endDate: "2026-08-07",
scope: "family",
timeZone: "local",
agentId: null,
},
result: null,
costSummary: null,
providerUsage: null,
loadedAtMs: null,
error: null,
};
await page.updateComplete;
// First load: only usage.status fails; the notice flag records the failure.
const refresh = () => {
(page as unknown as { refreshPolicy: { reload: () => void } }).refreshPolicy.reload();
};
refresh();
await vi.waitFor(() => {
expect(page.providerUsage).toMatchObject({ ok: false });
});
// Second load: usage.status succeeds but the aggregate fails on usage.cost.
// The stale flag must not keep claiming the last provider request failed.
phase = 2;
refresh();
await vi.waitFor(() => {
expect(page.usageError).not.toBeNull();
});
expect(page.providerUsage).toBeNull();
});
});
describe("UsagePage detail requests", () => {
it("commits only the latest time-series selection", async () => {
const first = deferred<SessionUsageTimeSeries>();
+11 -7
View File
@@ -25,6 +25,7 @@ import {
formatMissingOperatorReadScopeMessage,
isMissingOperatorReadScopeError,
} from "../../lib/gateway-errors.ts";
import type { ProviderUsageRequestResult } from "../../lib/provider-usage-request.ts";
import {
requestSessionUsageLogs,
requestSessionUsageTimeSeries,
@@ -36,7 +37,6 @@ import {
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { mergeUsageCacheStatus } from "./cache-status.ts";
import type { ProviderUsageSummary } from "./data-types.ts";
import { failUsageDetailRefresh } from "./detail-refresh.ts";
import {
currentLocalDate,
@@ -68,7 +68,7 @@ export type UsageRouteData = {
};
result: SessionsUsageResult | null;
costSummary: CostUsageSummary | null;
providerUsageSummary: ProviderUsageSummary | null;
providerUsage: ProviderUsageRequestResult | null;
loadedAtMs: number | null;
error: string | null;
};
@@ -86,7 +86,7 @@ class UsagePage extends OpenClawLightDomElement {
@state() private usageResult: SessionsUsageResult | null = null;
@state() private usageCostSummary: CostUsageSummary | null = null;
@state() private providerUsageSummary: ProviderUsageSummary | null = null;
@state() private providerUsage: ProviderUsageRequestResult | null = null;
@state() private usageError: string | null = null;
@state() private usageStartDate = currentLocalDate();
@state() private usageEndDate = currentLocalDate();
@@ -189,13 +189,16 @@ class UsagePage extends OpenClawLightDomElement {
this.usageTaskActiveClient = null;
this.usageResult = value.result;
this.usageCostSummary = value.costSummary;
this.providerUsageSummary = value.providerUsageSummary;
this.providerUsage = value.providerUsage;
this.usageError = null;
this.refreshPolicy.markLoaded();
this.refreshPolicy.flushPending();
},
onError: (error) => {
this.usageTaskActiveClient = null;
if (this.providerUsage?.ok === false) {
this.providerUsage = null;
}
if (isMissingOperatorReadScopeError(error)) {
this.usageResult = null;
this.usageCostSummary = null;
@@ -311,7 +314,7 @@ class UsagePage extends OpenClawLightDomElement {
this.usageAgentId = data.query.agentId;
this.usageResult = data.result;
this.usageCostSummary = data.costSummary;
this.providerUsageSummary = data.providerUsageSummary;
this.providerUsage = data.providerUsage;
this.refreshPolicy.setLastLoadedAtMs(data.loadedAtMs);
this.usageError = data.error;
}
@@ -338,7 +341,7 @@ class UsagePage extends OpenClawLightDomElement {
}
this.usageResult = null;
this.usageCostSummary = null;
this.providerUsageSummary = null;
this.providerUsage = null;
this.refreshPolicy.resetPayload();
this.usageError = null;
this.usageAgentId = this.context.agentSelection.state.scopeId;
@@ -501,7 +504,8 @@ class UsagePage extends OpenClawLightDomElement {
this.usageResult?.cacheStatus,
this.usageCostSummary?.cacheStatus,
),
providerUsage: this.providerUsageSummary?.providers ?? [],
providerUsage: this.providerUsage?.ok ? this.providerUsage.value.providers : [],
providerUsageUnavailable: this.providerUsage?.ok === false,
},
filters: {
startDate: this.usageStartDate,
+21
View File
@@ -63,6 +63,7 @@ function createUsageProps(overrides: Partial<UsageProps> = {}): UsageProps {
costDaily: [],
cacheStatus: undefined,
providerUsage: [],
providerUsageUnavailable: false,
},
filters: {
startDate: "2026-05-14",
@@ -154,6 +155,26 @@ function createUsageProps(overrides: Partial<UsageProps> = {}): UsageProps {
}
describe("renderUsage", () => {
it("surfaces a provider-usage failure instead of hiding the panel", () => {
const container = document.createElement("div");
const base = createUsageProps();
render(
renderUsage(createUsageProps({ data: { ...base.data, providerUsageUnavailable: true } })),
container,
);
expect(container.textContent).toContain(
"Provider usage is unavailable; the last request failed. Refresh to retry.",
);
});
it("keeps the provider panel hidden when usage is empty without a failure", () => {
const container = document.createElement("div");
render(renderUsage(createUsageProps()), container);
expect(container.textContent).not.toContain("Provider usage is unavailable");
});
it("keeps pending sessions on their selected local or UTC activity day", () => {
const localOffsetMs = -7 * 60 * 60 * 1000;
const localYear = vi
+8 -3
View File
@@ -143,8 +143,8 @@ function renderUsageEmptyState(onRefresh: () => void) {
type ProviderUsageSnapshot = ProviderUsageSummary["providers"][number];
function renderProviderUsage(providers: ProviderUsageSnapshot[]) {
if (providers.length === 0) {
function renderProviderUsage(providers: ProviderUsageSnapshot[], unavailable: boolean) {
if (providers.length === 0 && !unavailable) {
return nothing;
}
return renderSettingsSection(
@@ -154,6 +154,11 @@ function renderProviderUsage(providers: ProviderUsageSnapshot[]) {
description: t("usage.providerUsage.subtitle"),
},
html`
${unavailable
? html`
<div class="callout warning usage-callout">${t("usage.providerUsage.unavailable")}</div>
`
: nothing}
<div class="usage-panel provider-usage-section">
<div class="provider-usage-grid">
${providers.map(
@@ -798,7 +803,7 @@ export function renderUsage(props: UsageProps) {
</div>
</section>
${renderProviderUsage(data.providerUsage)}
${renderProviderUsage(data.providerUsage, data.providerUsageUnavailable)}
${isEmpty
? renderUsageEmptyState(filterActions.onRefresh)
: html`
+1
View File
@@ -312,6 +312,7 @@ export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] {
sourcePackageAlias("normalization-core", "number-coercion"),
sourcePackageAlias("normalization-core", "phone-presentation"),
sourcePackageAlias("normalization-core", "record-coerce"),
sourcePackageAlias("normalization-core", "result"),
sourcePackageAlias("normalization-core", "string-coerce"),
sourcePackageAlias("normalization-core", "string-normalization"),
sourcePackageAlias("normalization-core", "utf16-slice"),