1aee080fca
The quota check failed with `invalid_grant` for the account the user was logged into - the one profile they would try first. An OAuth refresh token is single use, so every refresh Claude Code performs on its own account retires the copy sitting in that account's saved profile: the stored credentials of the LIVE account are the one set guaranteed to be rejected, however recently the profile was saved. Confirmed on a real install - the saved profile held a refresh token three rotations behind `.credentials.json`. `cc-profiles.liveCredentialsFor` now matches a profile's accountUuid against the live credentials and, on a hit, the usage call uses what the CLI is holding. That token is never refreshed and that file is never written: rotating it would log the user out of their own terminal. An expired live token answers ELIVEEXPIRED - using Claude Code once makes the CLI refresh it - and a genuinely spent refresh token is now reported as ESTALE with what to do about it, instead of echoing Anthropic's raw 400. Opening the Profiles modal also asks for every saved account at once, rather than one button per row: comparing accounts is the only reason the panel exists. The guard against rewriting credentials over and over is no longer "one row at a time" but the rules above - the live account costs a plain read, and any other profile refreshes only once its token has actually expired, which then leaves it valid for hours of further opens. Each row resolves on its own, so a superseded account still lets its neighbours report.
204 lines
8.4 KiB
JavaScript
204 lines
8.4 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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 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<string>} 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) };
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
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, refreshProfile, CLIENT_ID, USAGE_URL, TOKEN_URL };
|