Skip to content

Commit 428c68a

Browse files
authored
feat: show provider plan usage (5h/weekly/credits) in the chat context popover, keep API cost for API billing (#102784)
* feat(usage): route claude-cli to Anthropic plan usage with plan label and billing * feat(webchat): redesign context popover with plan-usage bars and API-cost gating * fix(usage): match plan usage across CLI provider aliases and source plan label from synced auth profile * docs(plugins): document plan metadata on usage auth token * chore(usage): document provider-level billing attribution limits, fix overview test types * fix(webchat): avoid map-spread in quota group collection * test(webchat): deflake subscription popover reset fixture
1 parent 2587bd5 commit 428c68a

83 files changed

Lines changed: 1416 additions & 318 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/concepts/usage-tracking.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ title: "Usage tracking"
2020
- CLI: `openclaw status --usage` prints a full per-provider usage/quota breakdown.
2121
- CLI: `openclaw models status` lists OAuth/token auth profiles and shows a usage-window summary next to each provider that has one.
2222
- Control UI: **Usage** shows provider plan and billing cards above OpenClaw's session-derived token and estimated-cost analysis. Anthropic and OpenAI Admin API credentials add provider-reported today, 7-day, and 30-day spend, daily trends, token totals, top models, and cost categories.
23+
- Control UI: the chat composer's context ring popover shows **plan usage** for subscription providers — per-window bars (5-hour, weekly, model-scoped) with reset times, the provider plan when known (for example `Max (20x)`), and extra-usage credits. Sessions billed through a plan hide per-token dollar estimates; API-billed sessions keep `Est. cost` and the cost-by-type breakdown. Claude Code CLI (`claude-cli`) setups reuse the same Anthropic subscription usage.
2324
- macOS menu bar: a root "Usage" section appears below Context when provider usage snapshots are available. See [Menu bar](/platforms/mac/menu-bar).
2425

2526
`openclaw channels list` no longer prints provider usage; it points users to `openclaw status` or `openclaw models list` instead.

docs/plugins/architecture-internals.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,9 @@ that still runs on OpenClaw's normal inference loop.
325325

326326
`resolveUsageAuth` decides whether OpenClaw should call `fetchUsageSnapshot` or
327327
fall back to generic credential resolution for usage/status surfaces. Return
328-
`{ token, accountId? }` when the provider has a usage credential, return
328+
`{ token, accountId?, subscriptionType?, rateLimitTier? }` when the provider
329+
has a usage credential (the optional plan metadata flows into
330+
`fetchUsageSnapshot`), return
329331
`{ handled: true }` when provider-owned usage auth has handled the request and
330332
must suppress generic API-key/OAuth fallback, and return `null` or `undefined`
331333
when the provider did not handle usage auth.

docs/plugins/sdk-provider-plugins.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -600,8 +600,11 @@ catalog, API-key auth, and dynamic model resolution.
600600
},
601601
```
602602

603-
`resolveUsageAuth` has three outcomes. Return `{ token, accountId? }`
604-
when the provider has a usage/billing credential. Return
603+
`resolveUsageAuth` has three outcomes. Return
604+
`{ token, accountId?, subscriptionType?, rateLimitTier? }` when the
605+
provider has a usage/billing credential (the optional fields carry
606+
non-secret plan metadata from the resolved profile into
607+
`fetchUsageSnapshot`). Return
605608
`{ handled: true }` only when the provider has definitively handled usage
606609
auth but has no usable usage token, and OpenClaw must skip generic
607610
API-key/OAuth fallback. Return `null` or `undefined` when the provider did

extensions/anthropic/usage.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
import { describe, expect, it, vi } from "vitest";
2-
import { fetchAnthropicAdminUsage, resolveAnthropicUsageAuth } from "./usage.js";
2+
import {
3+
fetchAnthropicAdminUsage,
4+
fetchAnthropicUsage,
5+
formatClaudePlanLabel,
6+
resolveAnthropicUsageAuth,
7+
} from "./usage.js";
8+
9+
vi.mock("openclaw/plugin-sdk/provider-auth", async (importActual) => {
10+
const actual = await importActual<typeof import("openclaw/plugin-sdk/provider-auth")>();
11+
return {
12+
...actual,
13+
readClaudeCliCredentialsCached: vi.fn(() => ({
14+
type: "oauth",
15+
provider: "anthropic",
16+
access: "cli-access",
17+
refresh: "cli-refresh",
18+
expires: Date.now() + 3_600_000,
19+
subscriptionType: "max",
20+
rateLimitTier: "default_max_20x",
21+
})),
22+
};
23+
});
324

425
function requestUrl(input: string | URL | Request): URL {
526
return new URL(input instanceof Request ? input.url : input);
@@ -132,4 +153,83 @@ describe("Anthropic provider usage", () => {
132153
token: 'openclaw:anthropic-admin:v1:{"token":"sk-ant-admin-billing"}',
133154
});
134155
});
156+
157+
it("falls back to the synced claude-cli OAuth profile when anthropic has none", async () => {
158+
const resolveOAuthToken = vi.fn(async (params?: { provider?: string }) =>
159+
params?.provider === "claude-cli" ? { token: "claude-cli-token" } : null,
160+
);
161+
const result = await resolveAnthropicUsageAuth({
162+
config: {},
163+
env: {},
164+
provider: "anthropic",
165+
resolveApiKeyFromConfigAndStore: () => undefined,
166+
resolveOAuthToken,
167+
});
168+
expect(result).toEqual({ token: "claude-cli-token" });
169+
expect(resolveOAuthToken).toHaveBeenNthCalledWith(1);
170+
expect(resolveOAuthToken).toHaveBeenNthCalledWith(2, { provider: "claude-cli" });
171+
});
172+
173+
it.each([
174+
{ subscription: "max", tier: "default_max_20x", expected: "Max (20x)" },
175+
{ subscription: "pro", tier: undefined, expected: "Pro" },
176+
{ subscription: "max", tier: "default", expected: "Max" },
177+
{ subscription: undefined, tier: "default_max_20x", expected: undefined },
178+
{ subscription: " ", tier: undefined, expected: undefined },
179+
])("formats plan label for $subscription/$tier", ({ subscription, tier, expected }) => {
180+
expect(formatClaudePlanLabel(subscription, tier)).toBe(expected);
181+
});
182+
183+
it("prefers plan metadata from the resolved auth profile over CLI reads", async () => {
184+
const fetchFn = vi.fn(
185+
async () => new Response(JSON.stringify({ five_hour: { utilization: 10 } }), { status: 200 }),
186+
);
187+
const snapshot = await fetchAnthropicUsage({
188+
config: {},
189+
env: {},
190+
provider: "anthropic",
191+
token: "oauth-token",
192+
subscriptionType: "pro",
193+
rateLimitTier: "default_pro",
194+
timeoutMs: 5000,
195+
fetchFn,
196+
});
197+
expect(snapshot.plan).toBe("Pro");
198+
});
199+
200+
it("labels OAuth usage snapshots with the local Claude CLI plan", async () => {
201+
const fetchFn = vi.fn(
202+
async () =>
203+
new Response(
204+
JSON.stringify({
205+
five_hour: { utilization: 22, resets_at: "2026-07-09T18:00:00Z" },
206+
seven_day: { utilization: 25 },
207+
}),
208+
{ status: 200 },
209+
),
210+
);
211+
const snapshot = await fetchAnthropicUsage({
212+
config: {},
213+
env: {},
214+
provider: "anthropic",
215+
token: "oauth-token",
216+
timeoutMs: 5000,
217+
fetchFn,
218+
});
219+
expect(snapshot.plan).toBe("Max (20x)");
220+
expect(snapshot.windows).toHaveLength(2);
221+
});
222+
223+
it("does not attach a plan label when usage has no windows", async () => {
224+
const fetchFn = vi.fn(async () => new Response(JSON.stringify({}), { status: 200 }));
225+
const snapshot = await fetchAnthropicUsage({
226+
config: {},
227+
env: {},
228+
provider: "anthropic",
229+
token: "oauth-token",
230+
timeoutMs: 5000,
231+
fetchFn,
232+
});
233+
expect(snapshot.plan).toBeUndefined();
234+
});
135235
});

extensions/anthropic/usage.ts

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import type {
33
ProviderResolveUsageAuthContext,
44
ProviderResolvedUsageAuth,
55
} from "openclaw/plugin-sdk/plugin-entry";
6-
import { validateAnthropicSetupToken } from "openclaw/plugin-sdk/provider-auth";
6+
import {
7+
readClaudeCliCredentialsCached,
8+
validateAnthropicSetupToken,
9+
} from "openclaw/plugin-sdk/provider-auth";
710
import {
811
buildUsageHttpErrorSnapshot,
912
fetchClaudeUsage,
@@ -12,6 +15,7 @@ import {
1215
type ProviderUsageSnapshot,
1316
} from "openclaw/plugin-sdk/provider-usage";
1417
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
18+
import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js";
1519

1620
const ANTHROPIC_COST_URL = "https://api.anthropic.com/v1/organizations/cost_report";
1721
const ANTHROPIC_MESSAGES_USAGE_URL =
@@ -435,6 +439,17 @@ export async function resolveAnthropicUsageAuth(
435439
return oauthToken;
436440
}
437441

442+
// Claude CLI-only setups have their keychain login synced under the
443+
// claude-cli profile, not anthropic; without this fallback those setups
444+
// never surface subscription usage windows. Usage snapshots are keyed per
445+
// provider, so when a native anthropic OAuth account and a different Claude
446+
// Code account coexist, the native account wins and both auth rows display
447+
// its quota. Per-profile usage attribution is tracked in #102807.
448+
const claudeCliToken = await ctx.resolveOAuthToken({ provider: CLAUDE_CLI_BACKEND_ID });
449+
if (claudeCliToken) {
450+
return claudeCliToken;
451+
}
452+
438453
const apiKey = ctx.resolveApiKeyFromConfigAndStore();
439454
const adminKey = normalizeAdminKey(apiKey);
440455
if (adminKey) {
@@ -446,15 +461,57 @@ export async function resolveAnthropicUsageAuth(
446461
return { handled: true };
447462
}
448463

464+
/** Formats keychain plan metadata like ("max", "default_max_20x") as "Max (20x)". */
465+
export function formatClaudePlanLabel(
466+
subscriptionType?: string,
467+
rateLimitTier?: string,
468+
): string | undefined {
469+
const base = subscriptionType?.trim();
470+
if (!base) {
471+
return undefined;
472+
}
473+
const label = base.charAt(0).toUpperCase() + base.slice(1);
474+
const tier = rateLimitTier?.trim().match(/_(\d+x)$/i)?.[1];
475+
return tier ? `${label} (${tier})` : label;
476+
}
477+
478+
// Best-effort plan label. Preferred source is plan metadata on the resolved
479+
// auth profile (captured when the external CLI login was synced — the only
480+
// prompt-free source on keychain-backed macOS installs). Fallback reads the
481+
// Claude CLI credential file without keychain prompts for file-based logins.
482+
// When multiple Claude accounts are in play the CLI login may differ from the
483+
// profile that fetched usage; a mislabeled plan chip is acceptable, a second
484+
// network call is not.
485+
function resolveClaudePlanLabel(ctx: ProviderFetchUsageSnapshotContext): string | undefined {
486+
const fromAuth = formatClaudePlanLabel(ctx.subscriptionType, ctx.rateLimitTier);
487+
if (fromAuth) {
488+
return fromAuth;
489+
}
490+
const credential = readClaudeCliCredentialsCached({
491+
allowKeychainPrompt: false,
492+
ttlMs: 5 * 60_000,
493+
});
494+
if (!credential || credential.type !== "oauth") {
495+
return undefined;
496+
}
497+
return formatClaudePlanLabel(credential.subscriptionType, credential.rateLimitTier);
498+
}
499+
449500
export async function fetchAnthropicUsage(
450501
ctx: ProviderFetchUsageSnapshotContext,
451502
): Promise<ProviderUsageSnapshot> {
452503
const adminKey = decodeAdminToken(ctx.token);
453-
return adminKey
454-
? await fetchAnthropicAdminUsage({
455-
apiKey: adminKey,
456-
timeoutMs: ctx.timeoutMs,
457-
fetchFn: ctx.fetchFn,
458-
})
459-
: await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn);
504+
if (adminKey) {
505+
return await fetchAnthropicAdminUsage({
506+
apiKey: adminKey,
507+
timeoutMs: ctx.timeoutMs,
508+
fetchFn: ctx.fetchFn,
509+
});
510+
}
511+
const snapshot = await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn);
512+
if (snapshot.error || snapshot.plan || snapshot.windows.length === 0) {
513+
return snapshot;
514+
}
515+
const plan = resolveClaudePlanLabel(ctx);
516+
return plan ? { ...snapshot, plan } : snapshot;
460517
}

src/agents/auth-profiles/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export type OAuthCredentials = {
2121
projectId?: string;
2222
accountId?: string;
2323
chatgptPlanType?: string;
24+
/** Non-secret subscription plan captured from external CLI logins (e.g. "max"). */
25+
subscriptionType?: string;
26+
/** Non-secret rate-limit tier captured from external CLI logins (e.g. "default_max_20x"). */
27+
rateLimitTier?: string;
2428
idToken?: string;
2529
};
2630

src/agents/cli-credentials.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ function mockClaudeCliCredentialRead() {
3636
accessToken: `token-${Date.now()}`,
3737
refreshToken: "cached-refresh",
3838
expiresAt: Date.now() + 60_000,
39+
subscriptionType: "max",
40+
rateLimitTier: "default_max_20x",
3941
},
4042
}),
4143
);
@@ -110,6 +112,8 @@ describe("cli credentials", () => {
110112
provider: "anthropic",
111113
access: "token-1735689600000",
112114
refresh: "cached-refresh",
115+
subscriptionType: "max",
116+
rateLimitTier: "default_max_20x",
113117
});
114118
expectFields(second, {
115119
type: "oauth",

src/agents/cli-credentials.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,16 @@ export type ClaudeCliCredential =
5353
access: string;
5454
refresh: string;
5555
expires: number;
56+
subscriptionType?: string;
57+
rateLimitTier?: string;
5658
}
5759
| {
5860
type: "token";
5961
provider: "anthropic";
6062
token: string;
6163
expires: number;
64+
subscriptionType?: string;
65+
rateLimitTier?: string;
6266
};
6367

6468
/** Credential shape parsed from Codex CLI storage. */
@@ -103,9 +107,24 @@ function parseClaudeCliOauthCredential(claudeOauth: unknown): ClaudeCliCredentia
103107
if (!claudeOauth || typeof claudeOauth !== "object") {
104108
return null;
105109
}
106-
const accessToken = (claudeOauth as Record<string, unknown>).accessToken;
107-
const refreshToken = (claudeOauth as Record<string, unknown>).refreshToken;
108-
const expiresAt = (claudeOauth as Record<string, unknown>).expiresAt;
110+
const data = claudeOauth as Record<string, unknown>;
111+
const accessToken = data.accessToken;
112+
const refreshToken = data.refreshToken;
113+
const expiresAt = data.expiresAt;
114+
// Plan metadata (e.g. subscriptionType "max", rateLimitTier "default_max_20x")
115+
// lets usage surfaces label subscription windows without another API call.
116+
const subscriptionType =
117+
typeof data.subscriptionType === "string" && data.subscriptionType.trim()
118+
? data.subscriptionType.trim()
119+
: undefined;
120+
const rateLimitTier =
121+
typeof data.rateLimitTier === "string" && data.rateLimitTier.trim()
122+
? data.rateLimitTier.trim()
123+
: undefined;
124+
const planFields = {
125+
...(subscriptionType ? { subscriptionType } : {}),
126+
...(rateLimitTier ? { rateLimitTier } : {}),
127+
};
109128

110129
if (typeof accessToken !== "string" || !accessToken) {
111130
return null;
@@ -120,13 +139,15 @@ function parseClaudeCliOauthCredential(claudeOauth: unknown): ClaudeCliCredentia
120139
access: accessToken,
121140
refresh: refreshToken,
122141
expires: expiresAt,
142+
...planFields,
123143
};
124144
}
125145
return {
126146
type: "token",
127147
provider: "anthropic",
128148
token: accessToken,
129149
expires: expiresAt,
150+
...planFields,
130151
};
131152
}
132153

src/gateway/server-methods/models-auth-status.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,53 @@ describe("models.authStatus", () => {
414414
expect(mocks.loadProviderUsageSummary).not.toHaveBeenCalled();
415415
});
416416

417+
it("routes claude-cli OAuth profiles to Anthropic usage with plan and billing", async () => {
418+
const profile = {
419+
profileId: "claude-cli",
420+
provider: "claude-cli",
421+
type: "oauth",
422+
status: "ok",
423+
source: "store",
424+
label: "claude-cli",
425+
} satisfies AuthHealthSummary["profiles"][number];
426+
mocks.buildAuthHealthSummary.mockReturnValue({
427+
now: 0,
428+
warnAfterMs: 0,
429+
profiles: [profile],
430+
providers: [{ provider: "claude-cli", status: "ok", profiles: [profile] }],
431+
});
432+
mocks.loadProviderUsageSummary.mockResolvedValue({
433+
updatedAt: 0,
434+
providers: [
435+
{
436+
provider: "anthropic",
437+
displayName: "Claude",
438+
plan: "Max (20x)",
439+
windows: [{ label: "5h", usedPercent: 22 }],
440+
billing: [{ type: "budget", used: 157.85, limit: 400, unit: "USD", period: "month" }],
441+
},
442+
],
443+
});
444+
445+
const opts = createOptions();
446+
await handler(opts);
447+
448+
expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({
449+
providers: ["anthropic"],
450+
agentDir: "/tmp/agent",
451+
timeoutMs: 3500,
452+
});
453+
const [, payload] = firstRespondCall(opts) ?? [];
454+
const result = payload as ModelAuthStatusResult;
455+
expect(result.providers[0]?.displayName).toBe("Claude");
456+
expect(result.providers[0]?.usage).toEqual({
457+
providerId: "anthropic",
458+
windows: [{ label: "5h", usedPercent: 22 }],
459+
plan: "Max (20x)",
460+
billing: [{ type: "budget", used: 157.85, limit: 400, unit: "USD", period: "month" }],
461+
});
462+
});
463+
417464
it("adds DeepSeek API-key balance summaries to auth status usage", async () => {
418465
mocks.buildAuthHealthSummary.mockReturnValue({
419466
now: 0,
@@ -444,6 +491,7 @@ describe("models.authStatus", () => {
444491
const [, payload] = firstRespondCall(opts) ?? [];
445492
const result = payload as ModelAuthStatusResult;
446493
expect(result.providers[0]?.usage).toEqual({
494+
providerId: "deepseek",
447495
windows: [],
448496
summary: "Balance ¥42.50",
449497
});

0 commit comments

Comments
 (0)