5ba9e07d78
Closes #19, #20, #21, #27. #19 — a saved auth profile now reports how much of its account's quota is already spent, which is the only number that decides which one is worth switching to. Nothing under CLAUDE_HOME records it, so it comes from the same endpoint the CLI's own /usage reads, answered for whichever account's OAuth token is presented. It is a POST and a button, never a poll: every profile that is not the active one is normally expired, so answering means refreshing it first, and a refresh ROTATES the stored refresh token. The rotated pair is written straight back — dropping it would leave the profile holding a credential Anthropic has already retired, i.e. an account needing a fresh login. The token is read, put in an Authorization header, and never returned, logged or echoed; what leaves the server is two percentages and two reset times. #20 — a settings patch that changes nothing wrote nothing but still took a timestamped backup, so opening the config panel quietly grew a backup directory inside every lane. patchSettings now compares the rendered output with what is already on disk and returns early, fixed once where all callers route through. A lane's git exclude also picks up .claude/.cc-config-backups/ beside settings.local.json. #21 — the router apply route was the only mutating lane route that never broadcast its lane. The write reached disk and the column, but the client's cached row kept the old router_profile forever: the picker looked right until the user switched lanes and back, at which point the effect re-read the stale row and flipped the selection to the previous profile. One broadcastLane, plus a lane-switch guard so an apply that resolves after the user has moved on no longer paints its result onto whichever lane is now on screen. #27 — Add-lane in worktree mode left the dialog up after a clean setup with a dead confirm button and no obvious way out. It closes itself when nothing failed, and when something did, the summary stays and the confirm becomes a real Close.
171 lines
6.7 KiB
JavaScript
171 lines
6.7 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.
|
|
*
|
|
* - **Nothing here runs on a poll.** A saved profile's access token is short
|
|
* lived — every profile that is not the active one is normally expired — so
|
|
* reading its usage means refreshing it first, and a refresh ROTATES the
|
|
* stored refresh token. Doing that on a page load would rewrite the user's
|
|
* credentials for every account they have, repeatedly, for a number on a
|
|
* screen. It is therefore driven by an explicit per-profile request, and the
|
|
* rotated tokens are written straight back (`cc-profiles.writeProfile`) —
|
|
* a refresh whose result is dropped leaves the profile holding a refresh
|
|
* token Anthropic has already retired, i.e. an account the user must log in
|
|
* to 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) 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}`);
|
|
const creds = stored.credentials || {};
|
|
const fresh = Number(creds.expiresAt) > Date.now() + EXPIRY_MARGIN_MS;
|
|
|
|
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.
|
|
if (e.code !== "EUNAUTHORIZED" || refreshed) 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 };
|