mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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:
@@ -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"),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ type UsageDataState = {
|
||||
costDaily: CostDailyEntry[];
|
||||
cacheStatus: SessionsUsageResult["cacheStatus"];
|
||||
providerUsage: ProviderUsageSummary["providers"];
|
||||
providerUsageUnavailable: boolean;
|
||||
};
|
||||
|
||||
export type UsageFilterState = {
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user