mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 07:56:06 +00:00
* 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
236 lines
7.6 KiB
TypeScript
236 lines
7.6 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);
|
|
}
|
|
|
|
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("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();
|
|
});
|
|
});
|