/** * @file cc-usage.js * @description How much of an Anthropic account's quota is already spent — * the number that decides which saved auth profile (`cc-profiles.js`) is worth * switching to. * * There is no local source for it. Nothing under CLAUDE_HOME records a * utilization or a reset time; the credentials file carries only * `subscriptionType` and `rateLimitTier`. The figure comes from the same * endpoint the CLI's own `/usage` reads, `GET /api/oauth/usage`, answered for * whichever account's OAuth token is presented. * * Two rules this module exists to hold: * * - **A profile's token is sent to exactly one place: Anthropic.** It is read * from the profile file, put in an `Authorization` header, and never * returned, logged or echoed. What leaves this module is two percentages * and two reset timestamps. * * - **The active account is read from the CLI, never from its saved copy.** * A refresh token is single use, so every refresh Claude Code performs on * the account it is logged into retires the copy stored in that profile: * the saved credentials of the LIVE account are the one set certain to be * rejected. `cc-profiles.liveCredentialsFor` hands back what the CLI is * actually holding, and that token is never refreshed here — rotating it * would log the user out of their own terminal. * * - **Nothing here runs on a poll, and no refresh happens that a token's own * expiry did not force.** Opening the Profiles modal asks for every saved * account at once — comparing them is the point — so the guard against * rewriting a user's credentials over and over is not "one row at a time", * it is here: the live account never refreshes at all, and any other profile * refreshes only once its token has actually expired, which then leaves it * valid for hours of further opens. What a refresh does return is written * straight back (`cc-profiles.writeProfile`), because Anthropic retires the * refresh token it was handed and dropping the replacement would leave an * account the user has to log into again. * * @author Nguyễn Ngọc Trí Vĩ */ const profiles = require("./cc-profiles"); /** Claude Code's own public OAuth client. A refresh is only accepted for the * client the token was issued to, so this is not a configurable value. */ const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const PROFILE_URL = "https://api.anthropic.com/api/oauth/profile"; const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"; /** Treat a token expiring within this window as already expired: a refresh is * cheaper than a 401 that costs the whole call. */ const EXPIRY_MARGIN_MS = 60_000; const TIMEOUT_MS = 15_000; function err(code, message) { const e = new Error(message); e.code = code; return e; } async function postJson(url, body, headers) { const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body), signal: AbortSignal.timeout(TIMEOUT_MS), }); const text = await res.text(); if (!res.ok) { // The one upstream failure a user can act on: this profile's refresh token // has already been spent, which happens whenever a NEWER login or refresh // for the same account rotated it. Saying so beats echoing a raw 400. if (text.includes("invalid_grant")) { throw err( "ESTALE", "this profile's saved login has been superseded - activate the account once and save the profile again" ); } throw err("EUPSTREAM", `${url} answered ${res.status}: ${text.slice(0, 200)}`); } try { return JSON.parse(text); } catch { throw err("EUPSTREAM", `${url} did not answer JSON`); } } /** * Exchange a profile's refresh token for a fresh access token AND persist what * comes back. * * The persist is not an optimisation. Anthropic returns a new refresh token and * retires the one just used, so a refresh that is not written back leaves the * profile file holding a dead credential — the account would have to be logged * into again before it could ever be activated. * * @param {string} name a saved profile's identifier * @returns {Promise} the fresh access token (never returned to a route) */ async function refreshProfile(name) { const stored = profiles.readProfile(name); if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`); const creds = stored.credentials || {}; if (!creds.refreshToken) { throw err("ENOREFRESH", `profile ${name} has no refresh token — re-save it from a login`); } const data = await postJson(TOKEN_URL, { grant_type: "refresh_token", refresh_token: creds.refreshToken, client_id: CLIENT_ID, }); if (!data.access_token) throw err("EUPSTREAM", "the token endpoint returned no access_token"); profiles.writeProfile(name, { ...stored, credentials: { ...creds, accessToken: data.access_token, // Both are rotated by the exchange; keeping the old refresh token here // would pin the profile to a credential that no longer works. refreshToken: data.refresh_token || creds.refreshToken, expiresAt: Date.now() + (Number(data.expires_in) || 0) * 1000, }, }); return data.access_token; } /** The two windows the UI reports, flattened out of a response that also * carries a dozen experiment-named buckets this has no use for. */ function windowOf(value) { if (!value || typeof value !== "object") return null; const utilization = Number(value.utilization); return { utilization: Number.isFinite(utilization) ? utilization : null, resetsAt: typeof value.resets_at === "string" ? value.resets_at : null, }; } /** Ask Anthropic what this token's account has spent. */ async function fetchUsage(accessToken) { const res = await fetch(USAGE_URL, { headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20", }, signal: AbortSignal.timeout(TIMEOUT_MS), }); if (res.status === 401) throw err("EUNAUTHORIZED", "the account rejected this token"); if (!res.ok) throw err("EUPSTREAM", `the usage endpoint answered ${res.status}`); const data = await res.json(); return { fiveHour: windowOf(data.five_hour), sevenDay: windowOf(data.seven_day) }; } /** Token → owner. An access token never changes hands, so a hit never goes stale. */ const owners = new Map(); /** * The account a token belongs to — `{uuid, email}` — or null when the profile * endpoint does not say. A saved profile's label and its token come from two * different files (`oauthAccount` and the credentials store), and a running * Claude Code session can leave them naming different accounts, so the token's * own answer is the only one that says who a request will be billed to. */ async function fetchOwner(accessToken) { if (owners.has(accessToken)) return owners.get(accessToken); try { const res = await fetch(PROFILE_URL, { headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20" }, signal: AbortSignal.timeout(TIMEOUT_MS), }); if (!res.ok) return null; const account = (await res.json())?.account; if (!account?.uuid) return null; const owner = { uuid: account.uuid, email: account.email || null }; if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine owners.set(accessToken, owner); return owner; } catch { return null; } } /** Who the CLI's live token belongs to; null when offline, expired or not OAuth. */ async function liveOwner() { const { oauth } = profiles.readActiveAuth(); if (!oauth?.accessToken || !(Number(oauth.expiresAt) > Date.now() + EXPIRY_MARGIN_MS)) { return null; } return fetchOwner(oauth.accessToken); } /** * Usage for one saved profile, refreshing its token first when that token has * expired (which, for any profile that is not the active one, it usually has). * * @param {string} name a saved profile's identifier * @returns {Promise<{name:string, refreshed:boolean, fiveHour:object|null, * sevenDay:object|null, checkedAt:string}>} percentages and reset times only */ async function profileUsage(name) { const stored = profiles.readProfile(name); if (!stored) throw err("ENOTFOUND", `no saved profile named ${name}`); // For the account that is logged in RIGHT NOW the saved copy is the one set // of credentials guaranteed to fail — the CLI has rotated its refresh token // since, retiring the profile's. Use what the CLI holds, and never refresh // it: that would retire the token the user's own terminal is using, and this // module does not write `.credentials.json` back. const live = profiles.liveCredentialsFor(stored, { owner: await liveOwner() }); const creds = live || stored.credentials || {}; const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS; if (live && !fresh) { throw err( "ELIVEEXPIRED", "the active account's token has expired - use Claude Code once so it refreshes, then check again" ); } let token = fresh ? creds.accessToken : await refreshProfile(name); let refreshed = !fresh; if (!token) throw err("ENOAUTH", `profile ${name} holds no access token`); try { const usage = await fetchUsage(token); return { name, refreshed, ...usage, checkedAt: new Date().toISOString() }; } catch (e) { // A token that looked fresh but was revoked server-side is worth one // retry through the refresh path; a second 401 is a real answer. Not for // the live account, for the same reason its token is never refreshed here. if (e.code !== "EUNAUTHORIZED" || refreshed || live) throw e; token = await refreshProfile(name); refreshed = true; const usage = await fetchUsage(token); return { name, refreshed, ...usage, checkedAt: new Date().toISOString() }; } } module.exports = { profileUsage, fetchUsage, fetchOwner, liveOwner, refreshProfile, CLIENT_ID, USAGE_URL, TOKEN_URL, };