fix(plan): a Claude plan is the one Anthropic reports now, not the sign-in's
An account signed in on Pro and upgraded to Max 5x kept showing "Pro": the plan was read only from .credentials.json / .claude.json, whose subscriptionType and rate-limit tiers are written at sign-in. fetchOwner already asks GET /api/oauth/profile for a token's owner; it now also reads organization_type / rate_limit_tier / seat_tier into a plan (planFromProfile), remembered per account uuid (rememberLivePlan). The live row, saved profiles (identityOf) and pool accounts (poolStatus, whose quota refresh now asks a login's owner too) show that plan when one was seen (shownPlan), else planLabel of the files as before.
This commit is contained in:
@@ -220,6 +220,40 @@ function planLabel(oauth, account) {
|
||||
return sub ? cap(sub) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan Anthropic's profile endpoint reports for an account RIGHT NOW,
|
||||
* keyed by account uuid (cc-usage.js `fetchOwner` fills it). The stored
|
||||
* `subscriptionType` and rate-limit tiers are written at sign-in, so an
|
||||
* account upgraded since (Pro → Max 5x) keeps reading as its old plan from
|
||||
* the files alone; the live answer wins wherever one was seen.
|
||||
*/
|
||||
const livePlans = new Map();
|
||||
|
||||
/**
|
||||
* `planLabel` of the profile endpoint's `organization` (`organization_type`
|
||||
* `claude_max`, `rate_limit_tier` `default_claude_max_5x`, `seat_tier`), or null.
|
||||
*/
|
||||
function planFromProfile(org) {
|
||||
if (!org || typeof org !== "object") return null;
|
||||
const type = String(org.organization_type || "");
|
||||
return planLabel(
|
||||
{ subscriptionType: type.replace(/^claude_/, ""), rateLimitTier: org.rate_limit_tier || "" },
|
||||
{ seatTier: org.seat_tier || "", organizationType: type }
|
||||
);
|
||||
}
|
||||
|
||||
/** Remember an account's live plan (null/unknown leaves the last one). */
|
||||
function rememberLivePlan(accountUuid, plan) {
|
||||
if (!accountUuid || !plan) return;
|
||||
if (livePlans.size > 100) livePlans.clear(); // a few accounts per machine
|
||||
livePlans.set(accountUuid, plan);
|
||||
}
|
||||
|
||||
/** The plan to show: the live one when seen, else what the files say. */
|
||||
function shownPlan(oauth, account) {
|
||||
return livePlans.get(account?.accountUuid) || planLabel(oauth, account);
|
||||
}
|
||||
|
||||
/** Token-free description of an account, safe to send to the browser. */
|
||||
function identityOf({ oauth, account }) {
|
||||
return {
|
||||
@@ -229,8 +263,8 @@ function identityOf({ oauth, account }) {
|
||||
organization: (account && account.organizationName) || null,
|
||||
seatTier: (account && account.seatTier) || null,
|
||||
subscriptionType: (oauth && oauth.subscriptionType) || null,
|
||||
/** The readable plan (`planLabel`) — what the UI shows. */
|
||||
plan: planLabel(oauth, account),
|
||||
/** The readable plan (`shownPlan`: live when seen, else `planLabel`) — what the UI shows. */
|
||||
plan: shownPlan(oauth, account),
|
||||
/** When the stored access token expires; a switch still works after that
|
||||
* (the refresh token is stored too), it just refreshes on first use. */
|
||||
expiresAt: (oauth && oauth.expiresAt) || null,
|
||||
@@ -588,6 +622,9 @@ module.exports = {
|
||||
liveCredentialsFor,
|
||||
readActiveAuth,
|
||||
planLabel,
|
||||
planFromProfile,
|
||||
rememberLivePlan,
|
||||
shownPlan,
|
||||
// exported for tests
|
||||
profilesDir,
|
||||
configPaths,
|
||||
|
||||
@@ -165,7 +165,8 @@ async function fetchUsage(accessToken) {
|
||||
const owners = new Map();
|
||||
|
||||
/**
|
||||
* The account a token belongs to — `{uuid, email}` — or null when the profile
|
||||
* The account a token belongs to — `{uuid, email, plan}` (`plan` as the
|
||||
* profile endpoint reports it now, see cc-profiles.js `livePlans`) — 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
|
||||
@@ -179,9 +180,14 @@ async function fetchOwner(accessToken) {
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const account = (await res.json())?.account;
|
||||
const body = await res.json();
|
||||
const account = body?.account;
|
||||
if (!account?.uuid) return null;
|
||||
const owner = { uuid: account.uuid, email: account.email || null };
|
||||
// The plan as it is NOW (an upgrade since sign-in included): the stored
|
||||
// files keep the plan the account had when it signed in.
|
||||
const plan = profiles.planFromProfile(body.organization);
|
||||
profiles.rememberLivePlan(account.uuid, plan);
|
||||
const owner = { uuid: account.uuid, email: account.email || null, plan };
|
||||
if (owners.size > 50) owners.clear(); // ponytail: crude cap; a few accounts per machine
|
||||
owners.set(accessToken, owner);
|
||||
return owner;
|
||||
|
||||
@@ -379,7 +379,7 @@ const inFlight = new Set();
|
||||
|
||||
/** Read every account's quota whose reading is older than QUOTA_TTL_MS. */
|
||||
async function refreshQuotas({ force = false } = {}) {
|
||||
const { fetchUsage } = require("./cc-usage");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
await Promise.all(
|
||||
allAccounts().map(async (a) => {
|
||||
const cached = quotaCache.get(a.name);
|
||||
@@ -388,7 +388,12 @@ async function refreshQuotas({ force = false } = {}) {
|
||||
}
|
||||
inFlight.add(a.name);
|
||||
try {
|
||||
const u = await fetchUsage(bearerOf(a));
|
||||
// A login's owner too: the profile endpoint's answer is its plan as it
|
||||
// is now (cc-profiles.js livePlans), the stored one is the sign-in's.
|
||||
const [u] = await Promise.all([
|
||||
fetchUsage(bearerOf(a)),
|
||||
a.kind === "login" ? fetchOwner(bearerOf(a)) : null,
|
||||
]);
|
||||
quotaCache.set(a.name, { at: Date.now(), ...u });
|
||||
} catch (e) {
|
||||
// Keep the last good windows: a hiccup must not unblock a full account.
|
||||
@@ -436,7 +441,7 @@ function poolStatus() {
|
||||
kind: a.kind,
|
||||
email: a.kind === "login" ? (a.account?.emailAddress ?? null) : null,
|
||||
// "Max 5x", "Team Premium"…; a setup-token says nothing of its plan.
|
||||
plan: a.kind === "login" ? require("./cc-profiles").planLabel(a.oauth, a.account) : null,
|
||||
plan: a.kind === "login" ? require("./cc-profiles").shownPlan(a.oauth, a.account) : null,
|
||||
refreshError: a.kind === "login" ? (a.refreshError ?? null) : null,
|
||||
addedAt: a.addedAt,
|
||||
expiresAt: expiresAt(a),
|
||||
|
||||
+12
-7
@@ -36,7 +36,7 @@
|
||||
* `AGENTHUB_AGY_USAGE=0` never runs it.
|
||||
*
|
||||
* Each tool also carries its signed-in `account` (an email), its `plan` (the
|
||||
* subscription's name: Claude's `planLabel` (tier + seat, not the coarse `subscriptionType`),
|
||||
* subscription's name: Claude's plan as the profile endpoint reports it now, else `planLabel` of the stored files (tier + seat, not the coarse `subscriptionType`),
|
||||
* Codex's `chatgpt_plan_type` claim, Cursor's `membershipType`) and
|
||||
* `expiresAt`, when its stored access token runs out — Claude's
|
||||
* `claudeAiOauth.expiresAt`, Codex's and Cursor's access-token `exp` (claims
|
||||
@@ -60,7 +60,7 @@ const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const aiTools = require("./ai-tools");
|
||||
const { getCodexHome } = require("./codex-home");
|
||||
const { readActiveAuth, readProfile, listProfiles, planLabel } = require("./cc-profiles");
|
||||
const { readActiveAuth, readProfile, listProfiles, shownPlan } = require("./cc-profiles");
|
||||
const { fetchUsage, fetchOwner } = require("./cc-usage");
|
||||
const { allRolloutFiles } = require("./codex-ingest");
|
||||
const { getCursorDir } = require("./cursor-usage");
|
||||
@@ -122,7 +122,8 @@ async function usageFor(token, uuid) {
|
||||
code: "EWRONGACCOUNT",
|
||||
});
|
||||
}
|
||||
return claudeWindows(usage);
|
||||
// The plan as the profile endpoint says it now (null: it did not answer).
|
||||
return { windows: claudeWindows(usage), plan: owner?.plan || null };
|
||||
}
|
||||
|
||||
/** A member's Claude login: `<home>/.claude/.credentials.json` + `.claude.json`. */
|
||||
@@ -142,7 +143,7 @@ async function liveClaude(readAuth = readActiveAuth) {
|
||||
if (!oauth || !oauth.accessToken) return null; // API-key user or not logged in
|
||||
who = {
|
||||
account: account?.emailAddress || account?.displayName || null,
|
||||
plan: planLabel(oauth, account),
|
||||
plan: shownPlan(oauth, account),
|
||||
expiresAt: iso(oauth.expiresAt),
|
||||
};
|
||||
if (!fresh(oauth.expiresAt)) return { ...who, windows: [], error: "ELIVEEXPIRED" };
|
||||
@@ -154,6 +155,8 @@ async function liveClaude(readAuth = readActiveAuth) {
|
||||
return {
|
||||
...who,
|
||||
account: owner?.email || who.account,
|
||||
// Live: the files keep the plan of the sign-in, not of an upgrade since.
|
||||
plan: owner?.plan || who.plan,
|
||||
ownerUuid: owner?.uuid || account?.accountUuid || null,
|
||||
windows: claudeWindows(usage),
|
||||
};
|
||||
@@ -183,9 +186,9 @@ async function profileClaude(item) {
|
||||
}
|
||||
if (creds?.accessToken && fresh(creds.expiresAt)) {
|
||||
try {
|
||||
const windows = await usageFor(creds.accessToken, item.accountUuid);
|
||||
const { windows, plan } = await usageFor(creds.accessToken, item.accountUuid);
|
||||
lastGood.set(item.name, { windows, at: Date.now() });
|
||||
return { ...who, windows };
|
||||
return { ...who, plan: plan || who.plan, windows };
|
||||
} catch (e) {
|
||||
return { ...who, windows: [], error: e.code || "EUPSTREAM" };
|
||||
}
|
||||
@@ -289,7 +292,9 @@ function jwtClaims(token) {
|
||||
/** Codex's ChatGPT login from `auth.json`: email, plan + access-token expiry. */
|
||||
function codexAccount(codexHome = getCodexHome()) {
|
||||
try {
|
||||
const { tokens } = JSON.parse(readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 }));
|
||||
const { tokens } = JSON.parse(
|
||||
readFileSafe(path.join(codexHome, "auth.json"), "utf8", { max: 1 << 20 })
|
||||
);
|
||||
const { exp } = jwtClaims(tokens.access_token);
|
||||
const id = jwtClaims(tokens.id_token);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user