Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
840c7fa
Show quota reset times in CLI status output
May 6, 2026
5c7bd32
Show remaining quota in status output
May 26, 2026
3e4dd95
Add headless mode, management reload API, and atomic account add/remove
May 26, 2026
5130867
Add background quota prober for idle accounts
May 26, 2026
4e04f4f
Show remaining quota in status output
May 26, 2026
5f0e5b8
Merge pull request #4 from brendandebeasi/feat/headless-reload-api
brendandebeasi May 26, 2026
d09b719
Merge pull request #3 from brendandebeasi/feat/quota-prober
brendandebeasi May 26, 2026
d660730
Fix account removal not propagating on reload for same-named accounts
May 27, 2026
c1d96a0
Merge pull request #5 from brendandebeasi/feat/reload-removal-count-a…
brendandebeasi May 27, 2026
426e6c5
Support multiple orgs per email (org-aware account identity)
May 27, 2026
d149efa
Merge pull request #6 from brendandebeasi/feat/multi-org-identity
brendandebeasi May 27, 2026
452d533
Recover accounts from the error state
May 27, 2026
b26bc15
Merge pull request #7 from brendandebeasi/feat/error-recovery
brendandebeasi May 27, 2026
afa957e
Probe quota on account add and show quota freshness in status
May 27, 2026
34a5417
Merge pull request #8 from brendandebeasi/feat/quota-on-add
brendandebeasi May 27, 2026
49d6b55
Read OAuth quota from the usage endpoint (cold, no message spend)
May 27, 2026
29f396a
Merge pull request #9 from brendandebeasi/feat/oauth-usage-quota
brendandebeasi May 27, 2026
cea31ff
Make `remove` org-aware for same-email accounts
May 27, 2026
693c6af
Merge pull request #10 from brendandebeasi/feat/remove-multi-org
brendandebeasi May 27, 2026
82169ef
Add per-account rotation priority
May 27, 2026
0a994ab
Merge pull request #11 from brendandebeasi/feat/account-priority
brendandebeasi May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
},
"upstream": "https://api.anthropic.com",
"switchThreshold": 0.98,
"pokeIntervalMs": 600000,
"accounts": [
{
"name": "primary-max",
Expand Down
115 changes: 91 additions & 24 deletions src/account-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,15 @@ export class AccountManager {
name: acct.name,
type: acct.type,
accountUuid: acct.accountUuid || null,
orgUuid: acct.orgUuid || null,
orgName: acct.orgName || null,
priority: Number.isFinite(acct.priority) ? acct.priority : 0,
credential: acct.accessToken || acct.apiKey,
refreshToken: acct.refreshToken || null,
expiresAt: acct.expiresAt || null,
status: 'active',
quota: emptyQuota(),
lastQuotaAt: null,
usage: {
totalInputTokens: 0,
totalOutputTokens: 0,
Expand All @@ -42,15 +46,23 @@ export class AccountManager {
}

/**
* Get the best available account, rotating if the current one is near quota.
* Returns null if all accounts are exhausted.
* Get the best available account by priority (lower `priority` = preferred),
* ties broken by config order. Always prefers the highest-priority available
* account, so it switches back up the moment a higher-priority one recovers.
* Returns null if all accounts are exhausted/unavailable.
*/
getActiveAccount() {
const current = this.accounts[this.currentIndex];
if (this._isAvailable(current)) {
return current;
const available = this.accounts.filter(a => this._isAvailable(a));
if (available.length > 0) {
available.sort((a, b) => (a.priority - b.priority) || (a.index - b.index));
const best = available[0];
if (best.index !== this.currentIndex) {
this.currentIndex = best.index;
console.log(`[TeamClaude] Using account "${best.name}" (priority ${best.priority})`);
}
return best;
}
return this._selectNext();
return this._selectExhaustedFallback();
}

_isAvailable(account) {
Expand Down Expand Up @@ -114,21 +126,9 @@ export class AccountManager {
return false;
}

_selectNext() {
const startIndex = this.currentIndex;

for (let i = 1; i <= this.accounts.length; i++) {
const idx = (startIndex + i) % this.accounts.length;
const account = this.accounts[idx];

if (this._isAvailable(account)) {
this.currentIndex = idx;
console.log(`[TeamClaude] Switched to account "${account.name}"`);
return account;
}
}

// All accounts unavailable — find the one that resets soonest
// All accounts unavailable — return the one that resets soonest (only if its
// reset time has already passed; otherwise null so the caller returns 429).
_selectExhaustedFallback() {
let soonestAccount = null;
let soonestTime = Infinity;

Expand Down Expand Up @@ -157,8 +157,10 @@ export class AccountManager {

/**
* Update an account's quota tracking from upstream response headers.
* Set countRequest=false for background probes so they don't inflate the
* per-account request/usage totals (which should reflect real client traffic).
*/
updateQuota(accountIndex, headers) {
updateQuota(accountIndex, headers, { countRequest = true } = {}) {
const account = this.accounts[accountIndex];
if (!account) return;

Expand Down Expand Up @@ -192,8 +194,11 @@ export class AccountManager {
if (tokensReset) account.quota.resetsAt = tokensReset;
else if (requestsReset) account.quota.resetsAt = requestsReset;

account.usage.totalRequests++;
account.usage.lastUsed = new Date().toISOString();
account.lastQuotaAt = Date.now();
if (countRequest) {
account.usage.totalRequests++;
account.usage.lastUsed = new Date().toISOString();
}

// Log when approaching quota
if (this._isNearQuota(account)) {
Expand All @@ -206,6 +211,28 @@ export class AccountManager {
}
}

/**
* Populate quota from Anthropic's OAuth usage endpoint (fetchUsage).
* Maps the five_hour/seven_day windows onto the unified quota fields.
* Endpoint utilization is a percentage (0-100); unified* fields are 0-1.
*/
applyUsageData(accountIndex, usage) {
const account = this.accounts[accountIndex];
if (!account || !usage) return;
const q = account.quota;

const apply = (win, utilKey, resetKey) => {
if (win && typeof win.utilization === 'number') {
q[utilKey] = win.utilization / 100;
q[resetKey] = win.resets_at ? new Date(win.resets_at).getTime() : null;
}
};
apply(usage.five_hour, 'unified5h', 'unified5hReset');
apply(usage.seven_day, 'unified7d', 'unified7dReset');

account.lastQuotaAt = Date.now();
}

/**
* Update cumulative token usage from response body data.
*/
Expand Down Expand Up @@ -248,6 +275,11 @@ export class AccountManager {
account.credential = newTokens.accessToken;
account.refreshToken = newTokens.refreshToken;
account.expiresAt = newTokens.expiresAt;
// A successful refresh clears a prior error — the account is usable again.
if (account.status === 'error') {
account.status = 'active';
console.log(`[TeamClaude] Account "${account.name}" recovered from error after refresh`);
}
console.log(`[TeamClaude] Token refreshed for account "${account.name}"`);
this._onTokenRefresh?.(accountIndex, newTokens);
} catch (err) {
Expand Down Expand Up @@ -301,11 +333,15 @@ export class AccountManager {
name: acctData.name,
type: acctData.type,
accountUuid: acctData.accountUuid || null,
orgUuid: acctData.orgUuid || null,
orgName: acctData.orgName || null,
priority: Number.isFinite(acctData.priority) ? acctData.priority : 0,
credential: acctData.accessToken || acctData.apiKey,
refreshToken: acctData.refreshToken || null,
expiresAt: acctData.expiresAt || null,
status: 'active',
quota: emptyQuota(),
lastQuotaAt: null,
usage: { totalInputTokens: 0, totalOutputTokens: 0, totalRequests: 0, lastUsed: null },
rateLimitedUntil: null,
});
Expand All @@ -326,6 +362,33 @@ export class AccountManager {
}
}

/**
* Compute remaining quota from a quota object. Fractions are 0-1 (1 = full).
* For Claude Max (unified) only utilization is reported, so remaining is a
* fraction; for API-key accounts absolute token/request counts are available.
*/
_remainingSummary(q) {
const r = {
session: null, // fraction 0-1 (Claude Max 5h)
weekly: null, // fraction 0-1 (Claude Max 7d)
tokens: null, // absolute tokens remaining (API key)
tokensFraction: null,
requests: null, // absolute requests remaining (API key)
requestsFraction: null,
};
if (q.unified5h != null) r.session = Math.max(0, 1 - q.unified5h);
if (q.unified7d != null) r.weekly = Math.max(0, 1 - q.unified7d);
if (q.tokensLimit != null && q.tokensRemaining != null) {
r.tokens = q.tokensRemaining;
r.tokensFraction = q.tokensLimit > 0 ? q.tokensRemaining / q.tokensLimit : null;
}
if (q.requestsLimit != null && q.requestsRemaining != null) {
r.requests = q.requestsRemaining;
r.requestsFraction = q.requestsLimit > 0 ? q.requestsRemaining / q.requestsLimit : null;
}
return r;
}

/**
* Return a status summary of all accounts (safe to expose, no credentials).
*/
Expand All @@ -336,8 +399,12 @@ export class AccountManager {
accounts: this.accounts.map(a => ({
name: a.name,
type: a.type,
orgName: a.orgName || null,
priority: a.priority,
status: a.status,
quota: { ...a.quota },
remaining: this._remainingSummary(a.quota),
lastQuotaAt: a.lastQuotaAt,
usage: { ...a.usage },
rateLimitedUntil: a.rateLimitedUntil
? new Date(a.rateLimitedUntil).toISOString()
Expand Down
1 change: 1 addition & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export function createDefaultConfig() {
},
upstream: 'https://api.anthropic.com',
switchThreshold: 0.98,
pokeIntervalMs: 600000,
accounts: [],
};
}
Expand Down
37 changes: 37 additions & 0 deletions src/identity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Account identity helpers.
//
// An account is identified by its Anthropic account UUID (the *person*) plus the
// organization it's scoped to. The same email/person can belong to multiple
// orgs, each with its own OAuth token and quota, so the org must be part of the
// identity — otherwise multi-org logins collide, removals mis-match, and token
// rotation persists onto the wrong entry.
//
// The org discriminator prefers the org UUID but falls back to the org name
// (which the profile endpoint has always returned), so identity still works even
// when only the name is known. Accounts with neither (legacy entries, API keys,
// or pre-profile imports) fall back to matching by name.

/** Stable-ish org discriminator for an account: org UUID, else org name, else null. */
export function orgKey(acct) {
return acct?.orgUuid || acct?.orgName || null;
}

/**
* Whether two account records refer to the same account+org.
*
* - Both have an accountUuid: same person requires equal UUID; if both org keys
* are known they must match, but if either side's org is still unknown we
* treat them as the same (so a freshly-profiled login backfills a legacy
* entry instead of duplicating it).
* - Otherwise (API key / no UUID yet): fall back to matching by name.
*/
export function sameIdentity(a, b) {
if (a?.accountUuid && b?.accountUuid) {
if (a.accountUuid !== b.accountUuid) return false;
const ka = orgKey(a);
const kb = orgKey(b);
if (ka && kb) return ka === kb;
return true;
}
return a?.name === b?.name;
}
Loading