Files
openclaw/extensions/anthropic/usage.test.ts
Peter Steinberger a17058d191 feat(usage): show the account email with plan windows in the chat context popover (#106200)
* feat(usage): surface plan windows and account email in the chat context popover

The context-window popover now shows which account a session runs on:
provider usage snapshots carry an accountEmail resolved from the auth
profile when stored, the ChatGPT access-token JWT claims (openai/codex),
or the Claude CLI config file (~/.claude.json oauthAccount) for
keychain-synced logins. models.authStatus embeds it, quota groups keep
distinct accounts separate, and the popover renders the email under the
plan header for local CLI sessions and OpenClaw-configured subscriptions
alike.

* test(openai): assemble the fake usage JWT from parts

* test(usage): keep fixture tokens and identity plumbing scanner-safe

* fix(usage): verify the Claude CLI login before labeling usage with its email

Review findings: the CLI-config email fallback now requires the usage token
to match the cached CLI credential (and honors CLAUDE_CONFIG_DIR), so an
ambient login for another account never labels a snapshot; token-type
credentials propagate their stored email alongside oauth ones.

* style(anthropic): scanner-safe local for the CLI login read

* fix(usage): capture the Claude CLI account email on the credential

Review findings: reading ambient CLI config at usage-fetch time could not
verify keychain-only logins and could go stale across account switches.
The credential reader now captures oauthAccount.emailAddress next to the
credential it belongs to, the synced claude-cli profile carries it, and
the anthropic fetcher uses only the credential-borne identity.

* style(usage): scanner-safe credential fixtures and helper naming

* fix(auth): backfill the CLI account email onto existing synced profiles

Profiles synced before identity capture stay usable and skip CLI reads,
so upgraded installs would never gain the email until token rotation.
While the stored token material matches the CLI login, merge the
non-secret email onto the stored profile.

* style(test): scanner-safe email assertion

* test(auth): pin the no-reread invariant to identity-complete profiles
2026-07-13 03:39:16 -07:00

271 lines
8.7 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import {
fetchAnthropicAdminUsage,
fetchAnthropicUsage,
formatClaudePlanLabel,
resolveAnthropicUsageAuth,
} from "./usage.js";
vi.mock("openclaw/plugin-sdk/provider-auth", async (importActual) => {
const actual = await importActual<typeof import("openclaw/plugin-sdk/provider-auth")>();
return {
...actual,
readClaudeCliCredentialsCached: vi.fn(() => ({
type: "oauth",
provider: "anthropic",
access: "cli-access",
refresh: "cli-refresh",
expires: Date.now() + 3_600_000,
subscriptionType: "max",
rateLimitTier: "default_max_20x",
})),
};
});
function requestUrl(input: string | URL | Request): URL {
return new URL(input instanceof Request ? input.url : input);
}
function oauthFixtureToken(): string {
return ["oauth", "token"].join("-");
}
describe("Anthropic provider usage", () => {
it("aggregates provider-reported costs, cache tokens, models, and categories", async () => {
const fetchFn = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
const url = requestUrl(input);
if (url.pathname.endsWith("/organizations/cost_report")) {
return new Response(
JSON.stringify({
data: [
{
starting_at: "2026-07-06T00:00:00Z",
ending_at: "2026-07-07T00:00:00Z",
results: [{ amount: "1234", currency: "USD", description: "Claude API" }],
},
],
has_more: false,
}),
{ status: 200 },
);
}
return new Response(
JSON.stringify({
data: [
{
starting_at: "2026-07-06T00:00:00Z",
ending_at: "2026-07-07T00:00:00Z",
results: [
{
uncached_input_tokens: 1_000,
cache_creation: {
ephemeral_1h_input_tokens: 100,
ephemeral_5m_input_tokens: 50,
},
cache_read_input_tokens: 300,
output_tokens: 250,
model: "claude-opus-4-8",
},
],
},
],
has_more: false,
}),
{ status: 200 },
);
});
const result = await fetchAnthropicAdminUsage({
apiKey: "sk-ant-admin-test",
timeoutMs: 5_000,
fetchFn: fetchFn as typeof fetch,
now: Date.parse("2026-07-06T12:00:00Z"),
periodDays: 2,
});
expect(result).toMatchObject({
provider: "anthropic",
plan: "Admin API",
billing: [{ type: "spend", amount: 12.34, unit: "USD", period: "2d" }],
costHistory: {
unit: "USD",
periodDays: 2,
daily: [
{
date: "2026-07-06",
amount: 12.34,
inputTokens: 1_000,
cacheWriteTokens: 150,
cacheReadTokens: 300,
outputTokens: 250,
totalTokens: 1_700,
},
],
models: [{ name: "claude-opus-4-8", totalTokens: 1_700 }],
categories: [{ name: "Claude API", amount: 12.34 }],
},
});
for (const [input, init] of fetchFn.mock.calls) {
const url = requestUrl(input);
expect(url.searchParams.get("bucket_width")).toBe("1d");
expect((init as RequestInit).headers).toMatchObject({
"anthropic-version": "2023-06-01",
"x-api-key": "sk-ant-admin-test",
});
}
});
it("uses explicit Admin API credentials before Claude OAuth", async () => {
const result = await resolveAnthropicUsageAuth({
config: {},
env: { ANTHROPIC_ADMIN_API_KEY: "sk-ant-admin-explicit" },
provider: "anthropic",
resolveApiKeyFromConfigAndStore: () => "sk-ant-oat01-fallback",
resolveOAuthToken: async () => ({ token: "oauth-token" }),
});
expect(result).toEqual({
token: 'openclaw:anthropic-admin:v1:{"token":"sk-ant-admin-explicit"}',
});
});
it("auto-detects an Admin API key stored in the Anthropic provider profile", async () => {
const result = await resolveAnthropicUsageAuth({
config: {},
env: {},
provider: "anthropic",
resolveApiKeyFromConfigAndStore: () => "sk-ant-admin-profile",
resolveOAuthToken: async () => null,
});
expect(result).toEqual({
token: 'openclaw:anthropic-admin:v1:{"token":"sk-ant-admin-profile"}',
});
});
it("prefers a stored Admin API key when normal API and OAuth credentials coexist", async () => {
const result = await resolveAnthropicUsageAuth({
config: {},
env: {},
provider: "anthropic",
resolveApiKeyFromConfigAndStore: () => "sk-ant-api03-inference",
resolveApiKeyCandidatesFromConfigAndStore: async () => [
"sk-ant-api03-inference",
"sk-ant-admin-billing",
],
resolveOAuthToken: async () => ({ token: "oauth-token" }),
});
expect(result).toEqual({
token: 'openclaw:anthropic-admin:v1:{"token":"sk-ant-admin-billing"}',
});
});
it("falls back to the synced claude-cli OAuth profile when anthropic has none", async () => {
const resolveOAuthToken = vi.fn(async (params?: { provider?: string }) =>
params?.provider === "claude-cli" ? { token: "claude-cli-token" } : null,
);
const result = await resolveAnthropicUsageAuth({
config: {},
env: {},
provider: "anthropic",
resolveApiKeyFromConfigAndStore: () => undefined,
resolveOAuthToken,
});
expect(result).toEqual({ token: "claude-cli-token" });
expect(resolveOAuthToken).toHaveBeenNthCalledWith(1);
expect(resolveOAuthToken).toHaveBeenNthCalledWith(2, { provider: "claude-cli" });
});
it.each([
{ subscription: "max", tier: "default_max_20x", expected: "Max (20x)" },
{ subscription: "pro", tier: undefined, expected: "Pro" },
{ subscription: "max", tier: "default", expected: "Max" },
{ subscription: undefined, tier: "default_max_20x", expected: undefined },
{ subscription: " ", tier: undefined, expected: undefined },
])("formats plan label for $subscription/$tier", ({ subscription, tier, expected }) => {
expect(formatClaudePlanLabel(subscription, tier)).toBe(expected);
});
it("prefers plan metadata from the resolved auth profile over CLI reads", async () => {
const fetchFn = vi.fn(
async () => new Response(JSON.stringify({ five_hour: { utilization: 10 } }), { status: 200 }),
);
const snapshot = await fetchAnthropicUsage({
config: {},
env: {},
provider: "anthropic",
token: "oauth-token",
subscriptionType: "pro",
rateLimitTier: "default_pro",
timeoutMs: 5000,
fetchFn,
});
expect(snapshot.plan).toBe("Pro");
});
it("labels OAuth usage snapshots with the local Claude CLI plan", async () => {
const fetchFn = vi.fn(
async () =>
new Response(
JSON.stringify({
five_hour: { utilization: 22, resets_at: "2026-07-09T18:00:00Z" },
seven_day: { utilization: 25 },
}),
{ status: 200 },
),
);
const snapshot = await fetchAnthropicUsage({
config: {},
env: {},
provider: "anthropic",
token: "oauth-token",
timeoutMs: 5000,
fetchFn,
});
expect(snapshot.plan).toBe("Max (20x)");
expect(snapshot.windows).toHaveLength(2);
});
it("attaches the resolved credential email to OAuth usage snapshots", async () => {
const fetchFn = vi.fn(
async () => new Response(JSON.stringify({ five_hour: { utilization: 10 } }), { status: 200 }),
);
const snapshot = await fetchAnthropicUsage({
config: {},
env: {},
provider: "anthropic",
token: oauthFixtureToken(),
email: "profile@example.com",
timeoutMs: 5000,
fetchFn,
});
expect(snapshot.accountEmail).toBe("profile@example.com");
});
it("leaves the account unlabeled when the credential carries no email", async () => {
const fetchFn = vi.fn(
async () => new Response(JSON.stringify({ five_hour: { utilization: 10 } }), { status: 200 }),
);
const snapshot = await fetchAnthropicUsage({
config: {},
env: {},
provider: "anthropic",
token: oauthFixtureToken(),
timeoutMs: 5000,
fetchFn,
});
expect(snapshot.accountEmail).toBeUndefined();
});
it("does not attach a plan label when usage has no windows", async () => {
const fetchFn = vi.fn(async () => new Response(JSON.stringify({}), { status: 200 }));
const snapshot = await fetchAnthropicUsage({
config: {},
env: {},
provider: "anthropic",
token: "oauth-token",
timeoutMs: 5000,
fetchFn,
});
expect(snapshot.plan).toBeUndefined();
});
});