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:
2026-10-28 15:09:30 +07:00
parent 33d30f5e1f
commit 0841d3e5c8
12 changed files with 171 additions and 18 deletions
+36 -2
View File
@@ -448,8 +448,14 @@ describe("cc-profiles — identity and token disagree", () => {
it("planLabel reads the plan from the rate-limit tier and seat, not subscriptionType alone", () => {
const { planLabel } = profiles;
// An upgrade keeps `subscriptionType: "pro"` from sign-in; the tier is the truth.
assert.strictEqual(planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null), "Max 5x");
assert.strictEqual(planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null), "Max 20x");
assert.strictEqual(
planLabel({ subscriptionType: "pro", rateLimitTier: "default_claude_max_5x" }, null),
"Max 5x"
);
assert.strictEqual(
planLabel({ subscriptionType: "max", rateLimitTier: "default_claude_max_20x" }, null),
"Max 20x"
);
assert.strictEqual(planLabel({ subscriptionType: "pro" }, null), "Pro");
const team = { subscriptionType: "team", rateLimitTier: "default_raven" };
assert.strictEqual(planLabel(team, { seatTier: "team_standard" }), "Team Standard");
@@ -462,3 +468,31 @@ it("planLabel reads the plan from the rate-limit tier and seat, not subscription
assert.strictEqual(planLabel(team, null), "Team");
assert.strictEqual(planLabel(null, null), null);
});
it("the profile endpoint's live plan wins over files written at sign-in", () => {
const { planFromProfile, rememberLivePlan, shownPlan } = profiles;
assert.strictEqual(
planFromProfile({ organization_type: "claude_max", rate_limit_tier: "default_claude_max_5x" }),
"Max 5x"
);
assert.strictEqual(
planFromProfile({
organization_type: "claude_team",
rate_limit_tier: "default_raven",
seat_tier: "team_standard",
}),
"Team Standard"
);
assert.strictEqual(planFromProfile({ organization_type: "claude_pro" }), "Pro");
assert.strictEqual(planFromProfile(null), null);
// Signed in on Pro, upgraded to Max 5x since: the files still say Pro.
const oauth = { subscriptionType: "pro", rateLimitTier: "default_claude_pro" };
const account = { accountUuid: "acct-upgraded" };
assert.strictEqual(shownPlan(oauth, account), "Pro");
rememberLivePlan("acct-upgraded", "Max 5x");
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
rememberLivePlan("acct-upgraded", null); // an unknown answer keeps the last one
assert.strictEqual(shownPlan(oauth, account), "Max 5x");
assert.strictEqual(shownPlan(oauth, { accountUuid: "other" }), "Pro");
});
+3
View File
@@ -26,6 +26,7 @@ const pty = require("../lib/pty-run");
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
const realFetchUsage = ccUsage.fetchUsage;
const realFetchOwner = ccUsage.fetchOwner;
function run(id, account, { status = "completed", session = null, started, ended = null } = {}) {
db.prepare(
@@ -61,10 +62,12 @@ before(() => {
ccUsage.fetchUsage = async () => {
throw Object.assign(new Error("offline"), { code: "EUNAUTHORIZED" });
};
ccUsage.fetchOwner = async () => null;
});
after(() => {
ccUsage.fetchUsage = realFetchUsage;
ccUsage.fetchOwner = realFetchOwner;
tmux.__setExecImpl(null);
fs.rmSync(TMP, { recursive: true, force: true });
});
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
const KEY = (c) => `key_${c.repeat(40)}`;
const TOKEN = (c) => `sk-ant-oat01-${c.repeat(40)}`;
+1
View File
@@ -30,6 +30,7 @@ const claudePool = require("../lib/claude-pool");
const pins = require("../lib/account-pins");
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
const BOSS_PASS = "admin-pass-1";
const KEY = (c) => `key_${c.repeat(40)}`;
+59
View File
@@ -170,6 +170,65 @@ test("a profile holding another account's token shows EWRONGACCOUNT, not that ac
}
});
test("the live plan is the profile endpoint's, not the one written at sign-in", async () => {
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { quotas, __resetCache } = require("../lib/quota");
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-plan-"));
const home = path.join(root, ".claude");
fs.mkdirSync(home, { recursive: true });
// Signed in on Pro; upgraded to Max 5x since — the files never learned.
fs.writeFileSync(
path.join(home, ".credentials.json"),
JSON.stringify({
claudeAiOauth: {
accessToken: "live-upgraded",
expiresAt: Date.now() + 3600_000,
subscriptionType: "pro",
rateLimitTier: "default_claude_pro",
},
})
);
fs.writeFileSync(
`${home}.json`,
JSON.stringify({ oauthAccount: { accountUuid: "uuid-up", emailAddress: "up@x.y" } })
);
const prev = { home: process.env.CLAUDE_HOME, fetch: global.fetch };
process.env.CLAUDE_HOME = home;
global.fetch = async (url) => ({
status: 200,
ok: true,
json: async () =>
url.endsWith("/profile")
? {
account: { uuid: "uuid-up", email: "up@x.y" },
organization: {
organization_type: "claude_max",
rate_limit_tier: "default_claude_max_5x",
},
}
: { five_hour: { utilization: 10, resets_at: null } },
});
try {
__resetCache();
const live = (await quotas()).tools.find((t) => t.id === "claude" && t.active);
assert.strictEqual(live.plan, "Max 5x");
// Remembered: a later read from the files alone says the same.
const { shownPlan } = require("../lib/cc-profiles");
assert.strictEqual(
shownPlan({ subscriptionType: "pro" }, { accountUuid: "uuid-up" }),
"Max 5x"
);
} finally {
if (prev.home === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prev.home;
global.fetch = prev.fetch;
__resetCache();
fs.rmSync(root, { recursive: true, force: true });
}
});
test("the live row is the account its token belongs to, not the one .claude.json names", async () => {
const fs = require("node:fs");
const os = require("node:os");
@@ -29,6 +29,7 @@ const cursorPool = require("../lib/cursor-pool");
// The quota read would go to the network; the pool's list must not.
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
let server;
let BASE;
@@ -26,6 +26,7 @@ const auth = require("../lib/auth");
// The quota read would go to the network.
require("../lib/cc-usage").fetchUsage = async () => ({});
require("../lib/cc-usage").fetchOwner = async () => null;
let server;
let BASE;
+39 -2
View File
@@ -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,
+9 -3
View File
@@ -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;
+8 -3
View File
@@ -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
View File
@@ -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 {