import type { ProviderFetchUsageSnapshotContext, ProviderResolveUsageAuthContext, ProviderResolvedUsageAuth, } from "openclaw/plugin-sdk/plugin-entry"; import { readClaudeCliCredentialsCached, validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; import { addProviderUsageModel, asProviderUsageObject, buildUsageHttpErrorSnapshot, buildProviderUsageHistorySnapshot, cleanProviderUsageCredential, createProviderUsageDailyAccumulator, decodeProviderUsageAdminToken, encodeProviderUsageAdminToken, fetchProviderUsagePages, fetchClaudeUsage, parseProviderUsageNonNegativeInteger, parseProviderUsageNumber, resolveProviderUsageDailyPeriod, resolveProviderUsageDisplayName, type ProviderUsageSnapshot, } from "openclaw/plugin-sdk/provider-usage"; import { CLAUDE_CLI_BACKEND_ID } from "./cli-constants.js"; const ANTHROPIC_COST_URL = "https://api.anthropic.com/v1/organizations/cost_report"; const ANTHROPIC_MESSAGES_USAGE_URL = "https://api.anthropic.com/v1/organizations/usage_report/messages"; const ANTHROPIC_ADMIN_TOKEN_PREFIX = "openclaw:anthropic-admin:v1:"; const ANTHROPIC_USAGE_RESPONSE_MAX_BYTES = 4 * 1024 * 1024; const ANTHROPIC_USAGE_HISTORY_DAYS = 30; function normalizeAdminKey(raw: string | undefined): string | undefined { const cleaned = cleanProviderUsageCredential(raw); if (!cleaned) { return undefined; } const withoutBearer = cleaned.toLowerCase().startsWith("bearer ") ? cleaned.slice("bearer ".length).trim() : cleaned; return withoutBearer.toLowerCase().startsWith("sk-ant-admin") ? withoutBearer : undefined; } function encodeAdminToken(token: string): string { return encodeProviderUsageAdminToken(ANTHROPIC_ADMIN_TOKEN_PREFIX, token); } function decodeAdminToken(raw: string): string | undefined { return decodeProviderUsageAdminToken(ANTHROPIC_ADMIN_TOKEN_PREFIX, raw); } function utcDay(value: string): string | undefined { const date = new Date(value); return Number.isFinite(date.getTime()) ? date.toISOString().slice(0, 10) : undefined; } async function fetchPages(params: { baseUrl: string; groupBy: "description" | "model"; apiKey: string; startingAt: string; endingAt: string; periodDays: number; timeoutMs: number; fetchFn: typeof fetch; }): Promise<{ ok: true; data: unknown[] } | { ok: false; status?: number }> { return await fetchProviderUsagePages({ responseLabel: "Anthropic usage", responseMaxBytes: ANTHROPIC_USAGE_RESPONSE_MAX_BYTES, timeoutMs: params.timeoutMs, fetchFn: params.fetchFn, buildRequest: (page) => { const url = new URL(params.baseUrl); url.searchParams.set("starting_at", params.startingAt); url.searchParams.set("ending_at", params.endingAt); url.searchParams.set("bucket_width", "1d"); url.searchParams.set("limit", String(params.periodDays)); url.searchParams.set("group_by[]", params.groupBy); if (page) { url.searchParams.set("page", page); } return { url, headers: { Accept: "application/json", "anthropic-version": "2023-06-01", "x-api-key": params.apiKey, }, }; }, }); } function aggregateHistory(params: { costs: unknown[]; messages: unknown[]; periodDays: number; }): ProviderUsageSnapshot { const daily = new Map>(); const getDaily = (startingAt: string) => { const date = utcDay(startingAt); if (!date) { return undefined; } const current = daily.get(date) ?? createProviderUsageDailyAccumulator(date); daily.set(date, current); return current; }; for (const rawBucket of params.costs) { const bucket = asProviderUsageObject(rawBucket); const startingAt = typeof bucket?.starting_at === "string" ? bucket.starting_at : undefined; if (!startingAt || !Array.isArray(bucket?.results)) { continue; } const accumulator = getDaily(startingAt); if (!accumulator) { continue; } for (const rawResult of bucket.results) { const result = asProviderUsageObject(rawResult); const amount = (parseProviderUsageNumber(result?.amount) ?? 0) / 100; const category = resolveProviderUsageDisplayName( result?.description ?? result?.cost_type, "Claude API", ); accumulator.amount += amount; accumulator.categories.set(category, (accumulator.categories.get(category) ?? 0) + amount); } } for (const rawBucket of params.messages) { const bucket = asProviderUsageObject(rawBucket); const startingAt = typeof bucket?.starting_at === "string" ? bucket.starting_at : undefined; if (!startingAt || !Array.isArray(bucket?.results)) { continue; } const accumulator = getDaily(startingAt); if (!accumulator) { continue; } for (const rawResult of bucket.results) { const result = asProviderUsageObject(rawResult); if (!result) { continue; } const cacheCreation = asProviderUsageObject(result.cache_creation); const inputTokens = parseProviderUsageNonNegativeInteger(result.uncached_input_tokens); const cacheWriteTokens = parseProviderUsageNonNegativeInteger(cacheCreation?.ephemeral_1h_input_tokens) + parseProviderUsageNonNegativeInteger(cacheCreation?.ephemeral_5m_input_tokens); const cacheReadTokens = parseProviderUsageNonNegativeInteger(result.cache_read_input_tokens); const outputTokens = parseProviderUsageNonNegativeInteger(result.output_tokens); const totalTokens = inputTokens + cacheWriteTokens + cacheReadTokens + outputTokens; accumulator.inputTokens += inputTokens; accumulator.cacheWriteTokens += cacheWriteTokens; accumulator.cacheReadTokens += cacheReadTokens; accumulator.outputTokens += outputTokens; accumulator.totalTokens += totalTokens; addProviderUsageModel( accumulator, resolveProviderUsageDisplayName(result.model, "Claude API"), { inputTokens, cacheReadTokens, cacheWriteTokens, outputTokens, totalTokens, }, ); } } return buildProviderUsageHistorySnapshot({ provider: "anthropic", displayName: "Anthropic", plan: "Admin API", periodDays: params.periodDays, unit: "USD", daily: daily.values(), formatSummary: ({ totalTokens }) => `${totalTokens.toLocaleString("en-US")} tokens`, }); } async function fetchAnthropicAdminUsage(params: { apiKey: string; timeoutMs: number; fetchFn: typeof fetch; now?: number; periodDays?: number; }): Promise { const period = resolveProviderUsageDailyPeriod({ now: params.now ?? Date.now(), periodDays: params.periodDays, defaultPeriodDays: ANTHROPIC_USAGE_HISTORY_DAYS, }); const common = { apiKey: params.apiKey, startingAt: new Date(period.startMs).toISOString(), endingAt: new Date(period.endMs).toISOString(), periodDays: period.periodDays, timeoutMs: params.timeoutMs, fetchFn: params.fetchFn, }; const [costs, messages] = await Promise.all([ fetchPages({ ...common, baseUrl: ANTHROPIC_COST_URL, groupBy: "description" }), fetchPages({ ...common, baseUrl: ANTHROPIC_MESSAGES_USAGE_URL, groupBy: "model" }), ]); if (!costs.ok || !messages.ok) { const failedStatus = !costs.ok ? costs.status : !messages.ok ? messages.status : undefined; if (failedStatus === 401 || failedStatus === 403) { return { provider: "anthropic", displayName: "Anthropic", windows: [], error: "Admin API key required", }; } return failedStatus ? buildUsageHttpErrorSnapshot({ provider: "anthropic", status: failedStatus }) : { provider: "anthropic", displayName: "Anthropic", windows: [], error: "Usage unavailable", }; } return aggregateHistory({ costs: costs.data, messages: messages.data, periodDays: period.periodDays, }); } export async function resolveAnthropicUsageAuth( ctx: ProviderResolveUsageAuthContext, ): Promise { const explicitAdminKey = cleanProviderUsageCredential(ctx.env.ANTHROPIC_ADMIN_KEY) ?? cleanProviderUsageCredential(ctx.env.ANTHROPIC_ADMIN_API_KEY); if (explicitAdminKey) { return { token: encodeAdminToken(explicitAdminKey) }; } const storedCandidates = (await ctx.resolveApiKeyCandidatesFromConfigAndStore?.()) ?? []; const storedAdminKey = storedCandidates .map(normalizeAdminKey) .find((candidate): candidate is string => Boolean(candidate)); if (storedAdminKey) { return { token: encodeAdminToken(storedAdminKey) }; } const oauthToken = await ctx.resolveOAuthToken(); if (oauthToken) { return oauthToken; } // Claude CLI-only setups have their keychain login synced under the // claude-cli profile, not anthropic; without this fallback those setups // never surface subscription usage windows. Usage snapshots are keyed per // provider, so when a native anthropic OAuth account and a different Claude // Code account coexist, the native account wins and both auth rows display // its quota. Per-profile usage attribution is tracked in #102807. const claudeCliToken = await ctx.resolveOAuthToken({ provider: CLAUDE_CLI_BACKEND_ID }); if (claudeCliToken) { return claudeCliToken; } const apiKey = ctx.resolveApiKeyFromConfigAndStore(); const adminKey = normalizeAdminKey(apiKey); if (adminKey) { return { token: encodeAdminToken(adminKey) }; } if (apiKey && validateAnthropicSetupToken(apiKey) === undefined) { return { token: apiKey }; } return { handled: true }; } /** Formats keychain plan metadata like ("max", "default_max_20x") as "Max (20x)". */ function formatClaudePlanLabel( subscriptionType?: string, rateLimitTier?: string, ): string | undefined { const base = subscriptionType?.trim(); if (!base) { return undefined; } const label = base.charAt(0).toUpperCase() + base.slice(1); const tier = rateLimitTier?.trim().match(/_(\d+x)$/i)?.[1]; return tier ? `${label} (${tier})` : label; } // Best-effort plan label. Preferred source is plan metadata on the resolved // auth profile (captured when the external CLI login was synced — the only // prompt-free source on keychain-backed macOS installs). Fallback reads the // Claude CLI credential file without keychain prompts for file-based logins. // When multiple Claude accounts are in play the CLI login may differ from the // profile that fetched usage; a mislabeled plan chip is acceptable, a second // network call is not. function resolveClaudePlanLabel(ctx: ProviderFetchUsageSnapshotContext): string | undefined { const fromAuth = formatClaudePlanLabel(ctx.subscriptionType, ctx.rateLimitTier); if (fromAuth) { return fromAuth; } const credential = readClaudeCliCredentialsCached({ allowKeychainPrompt: false, ttlMs: 5 * 60_000, }); if (!credential || credential.type !== "oauth") { return undefined; } return formatClaudePlanLabel(credential.subscriptionType, credential.rateLimitTier); } export async function fetchAnthropicUsage( ctx: ProviderFetchUsageSnapshotContext, ): Promise { const adminKey = decodeAdminToken(ctx.token); if (adminKey) { return await fetchAnthropicAdminUsage({ apiKey: adminKey, timeoutMs: ctx.timeoutMs, fetchFn: ctx.fetchFn, }); } const snapshot = await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn); if (snapshot.error) { return snapshot; } // Identity is captured on the credential (profile store or the CLI-sync // read), so a fetch-time ambient config read can never mislabel an account. const accountEmail = ctx.email; // Plan labels stay window-gated: a windowless response has no plan quota to // label, while the account identity is still worth surfacing. const plan = snapshot.plan ?? (snapshot.windows.length > 0 ? resolveClaudePlanLabel(ctx) : undefined); return { ...snapshot, ...(plan ? { plan } : {}), ...(accountEmail ? { accountEmail } : {}), }; }