Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Sits transparently between Claude Code and the Anthropic API, managing multiple

- **Automatic account rotation** — switches to the next account when session (5h) or weekly (7d) quota reaches the configured threshold (default 98%)
- **Auto-retry on 429** — waits the `retry-after` duration and retries the same account; switches to the next on persistent errors
- **Interactive TUI** — real-time dashboard with color-coded quota bars, reset countdowns, activity log, and keyboard controls
- **Interactive TUI** — real-time dashboard with color-coded quota bars, reset countdowns, activity log, and keyboard controls, including Sonnet weekly usage when available
- **OAuth token management** — automatically refreshes tokens nearing expiry and persists them to config; client token refreshes pass through untouched
- **Hot-reload accounts** — add accounts via `import` or `login` while the server is running, press **R** to pick them up
- **Account deduplication** — detects duplicate accounts by UUID and keeps the most recent
Expand Down Expand Up @@ -91,7 +91,7 @@ teamclaude server
```

When running from a TTY, shows an interactive TUI with:
- Account table with session/weekly quota progress bars and reset countdowns
- Account table with session/weekly quota progress bars and reset countdowns, plus a Sonnet weekly bar for OAuth accounts when available
- Real-time activity log with request tracking
- Keyboard shortcuts (see below)

Expand Down Expand Up @@ -186,13 +186,18 @@ TEAMCLAUDE_CONFIG=./my-config.json teamclaude server
1. Claude Code connects to the local proxy instead of `api.anthropic.com`
2. The proxy selects the active account and forwards requests with that account's credentials
3. OAuth tokens expiring within 5 minutes are automatically refreshed and persisted to config
4. Rate limit headers from the API (`anthropic-ratelimit-unified-*`) track session (5h) and weekly (7d) quota utilization
4. Rate limit headers from the API (`anthropic-ratelimit-unified-*`) track session (5h) and weekly (7d) quota utilization, and OAuth accounts can also refresh extra usage buckets from `/api/oauth/usage`
5. When usage reaches the threshold, the proxy switches to the next available account via round-robin
6. On 429 responses, the proxy waits the `retry-after` duration and retries; on persistent errors, it switches accounts
7. Transient network errors (connection reset, timeout) drop the connection so the client can retry
8. If all accounts are exhausted, returns 429 with the soonest reset time
9. Client token refresh requests (`/v1/oauth/token`) are relayed to upstream untouched — the proxy and client manage their own token lifecycles independently

For OAuth accounts, TeamClaude may display multiple rolling subscription buckets:
- `Ses` — 5-hour session usage
- `Wk` — all-model 7-day usage
- `S7` — Sonnet-specific 7-day usage when exposed by the OAuth usage endpoint

## License

MIT
29 changes: 29 additions & 0 deletions src/account-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ function emptyQuota() {
// Unified rate limits (Claude Max accounts)
unified5h: null, // utilization 0-1
unified7d: null, // utilization 0-1
unified7dSonnet: null, // utilization 0-1
unified5hReset: null, // ms timestamp
unified7dReset: null, // ms timestamp
unified7dSonnetReset: null, // ms timestamp
unifiedStatus: null, // allowed | allowed_warning | rejected
resetsAt: null,
};
Expand Down Expand Up @@ -86,6 +88,11 @@ export class AccountManager {
q.unified7dReset = null;
q.unifiedStatus = null;
}
if (q.unified7dSonnet != null && q.unified7dSonnetReset && now >= q.unified7dSonnetReset) {
console.log(`[TeamClaude] Account "${account.name}" Sonnet weekly quota reset`);
q.unified7dSonnet = null;
q.unified7dSonnetReset = null;
}

// Clear expired standard quotas
if (q.resetsAt && now >= new Date(q.resetsAt).getTime()) {
Expand Down Expand Up @@ -206,6 +213,28 @@ export class AccountManager {
}
}

/**
* Update normalized OAuth usage buckets from the usage endpoint.
*/
updateOAuthUsage(accountIndex, usage) {
const account = this.accounts[accountIndex];
if (!account || !usage) return;

const q = account.quota;
if (usage.fiveHour) {
if (usage.fiveHour.utilization != null) q.unified5h = usage.fiveHour.utilization;
if (usage.fiveHour.resetAt != null) q.unified5hReset = usage.fiveHour.resetAt;
}
if (usage.sevenDay) {
if (usage.sevenDay.utilization != null) q.unified7d = usage.sevenDay.utilization;
if (usage.sevenDay.resetAt != null) q.unified7dReset = usage.sevenDay.resetAt;
}
if (usage.sevenDaySonnet) {
if (usage.sevenDaySonnet.utilization != null) q.unified7dSonnet = usage.sevenDaySonnet.utilization;
if (usage.sevenDaySonnet.resetAt != null) q.unified7dSonnetReset = usage.sevenDaySonnet.resetAt;
}
}

/**
* Update cumulative token usage from response body data.
*/
Expand Down
50 changes: 47 additions & 3 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createInterface } from 'node:readline';
import { loadOrCreateConfig, loadConfig, saveConfig, atomicConfigUpdate, getConfigPath } from './config.js';
import { AccountManager } from './account-manager.js';
import { createProxyServer } from './server.js';
import { importCredentials, loginOAuth, fetchProfile, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
import { importCredentials, loginOAuth, fetchProfile, fetchUsage, refreshAccessToken, isTokenExpiringSoon } from './oauth.js';
import { TUI } from './tui.js';

const args = process.argv.slice(2);
Expand Down Expand Up @@ -125,6 +125,7 @@ async function serverCommand() {

let tui = null;
let hooks = {};
let usageRefreshTimer = null;

if (useTUI) {
tui = new TUI({
Expand Down Expand Up @@ -191,14 +192,33 @@ async function serverCommand() {

if (!tui) {
process.on('SIGINT', () => {
if (usageRefreshTimer) clearInterval(usageRefreshTimer);
console.log('\n[TeamClaude] Shutting down...');
server.close(() => process.exit(0));
});
process.on('SIGTERM', () => {
if (usageRefreshTimer) clearInterval(usageRefreshTimer);
console.log('\n[TeamClaude] Shutting down...');
server.close(() => process.exit(0));
});
}

const refreshOAuthUsageSafe = async () => {
try {
await refreshOAuthUsage(accountManager);
if (tui) tui.render();
} catch (err) {
console.error(`[TeamClaude] OAuth usage refresh failed: ${err.message}`);
}
};

setTimeout(() => {
refreshOAuthUsageSafe();
}, 1000).unref?.();
usageRefreshTimer = setInterval(() => {
refreshOAuthUsageSafe();
}, 60_000);
usageRefreshTimer.unref?.();
}

// ── import ──────────────────────────────────────────────────
Expand Down Expand Up @@ -361,10 +381,11 @@ async function statusCommand() {
console.log(` ${acct.name} (${acct.type})${current}`);
console.log(` Status: ${acct.status}`);

if (q.unified5h != null || q.unified7d != null) {
if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null) {
const ses = q.unified5h != null ? (q.unified5h * 100).toFixed(1) + '%' : '-';
const wk = q.unified7d != null ? (q.unified7d * 100).toFixed(1) + '%' : '-';
console.log(` Session: ${ses} used Weekly: ${wk} used`);
const sonnet = q.unified7dSonnet != null ? (q.unified7dSonnet * 100).toFixed(1) + '%' : '-';
console.log(` Session: ${ses} used Weekly: ${wk} used Sonnet7d: ${sonnet} used`);
} else {
const tok = q.tokensLimit ? ((1 - q.tokensRemaining / q.tokensLimit) * 100).toFixed(1) + '%' : '-';
const req = q.requestsLimit ? ((1 - q.requestsRemaining / q.requestsLimit) * 100).toFixed(1) + '%' : '-';
Expand Down Expand Up @@ -737,6 +758,29 @@ async function resolveAccounts(config) {
return accounts;
}

async function refreshOAuthUsage(accountManager) {
for (const account of accountManager.accounts) {
if (account.type !== 'oauth' || !account.credential) continue;

await accountManager.ensureTokenFresh(account.index);
if (account.status === 'error' || !account.credential) continue;

let usage = await fetchUsage(account.credential);
if (usage?.status === 401) {
await accountManager.ensureTokenFresh(account.index, true);
if (account.status === 'error' || !account.credential) continue;
usage = await fetchUsage(account.credential);
}

if (usage?.error) {
console.error(`[TeamClaude] Usage fetch failed for "${account.name}": ${usage.error}`);
continue;
}

accountManager.updateOAuthUsage(account.index, usage);
}
}

function argValue(flag) {
const i = args.indexOf(flag);
return (i >= 0 && args[i + 1]) ? args[i + 1] : null;
Expand Down
67 changes: 67 additions & 0 deletions src/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ export async function importCredentials(filePath) {
}

const PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
const DEFAULT_TOKEN_ENDPOINT = 'https://platform.claude.com/v1/oauth/token';
const DEFAULT_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const OAUTH_USAGE_BETA = 'oauth-2025-04-20';

/**
* Refresh an expired OAuth access token using the refresh token.
Expand Down Expand Up @@ -139,6 +141,71 @@ export async function fetchProfile(accessToken) {
}
}

function normalizeUsageBucket(bucket) {
if (!bucket || typeof bucket !== 'object') return null;

const rawPct = bucket.used_percentage ?? bucket.utilization ?? bucket.usedPercentage;
const parsedPct = typeof rawPct === 'number' ? rawPct : parseFloat(rawPct);
const utilization = Number.isFinite(parsedPct)
? (parsedPct > 1 ? parsedPct / 100 : parsedPct)
: null;

const rawReset = bucket.resets_at ?? bucket.resetsAt ?? bucket.reset_at ?? bucket.resetAt;
let resetAt = null;
if (typeof rawReset === 'number') {
resetAt = rawReset < 1e12 ? rawReset * 1000 : rawReset;
} else if (typeof rawReset === 'string') {
const asNum = Number(rawReset);
if (Number.isFinite(asNum) && rawReset.trim() !== '') {
resetAt = asNum < 1e12 ? asNum * 1000 : asNum;
} else {
const parsed = Date.parse(rawReset);
if (Number.isFinite(parsed)) resetAt = parsed;
}
}

return {
utilization,
resetAt,
};
}

/**
* Fetch OAuth subscription usage buckets.
* Returns normalized buckets or { error, status } on failure.
*/
export async function fetchUsage(accessToken) {
try {
const res = await fetch(USAGE_URL, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'anthropic-beta': OAUTH_USAGE_BETA,
'Accept': 'application/json',
},
});

if (!res.ok) {
let detail = '';
try {
const body = await res.json();
detail = body?.error?.message || JSON.stringify(body).slice(0, 200);
} catch {
detail = await res.text().catch(() => '');
}
return { error: `HTTP ${res.status}${detail ? ': ' + detail : ''}`, status: res.status };
}

const data = await res.json();
return {
fiveHour: normalizeUsageBucket(data?.five_hour),
sevenDay: normalizeUsageBucket(data?.seven_day),
sevenDaySonnet: normalizeUsageBucket(data?.seven_day_sonnet),
};
} catch (err) {
return { error: err.message || String(err), status: null };
}
}

// OAuth config (extracted from Claude Code)
const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
const OAUTH_AUTHORIZE = 'https://claude.ai/oauth/authorize';
Expand Down
35 changes: 27 additions & 8 deletions src/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ export class TUI {
: Math.max(5, Math.min(20, W - 45));

for (let i = 0; i < this.am.accounts.length; i++) {
lines.push(this._renderAcct(i, bw, showBoth));
lines.push(...this._renderAcct(i, bw, showBoth, W));
}
}

Expand Down Expand Up @@ -447,7 +447,7 @@ export class TUI {
process.stdout.write(buf);
}

_renderAcct(idx, bw, showBoth) {
_renderAcct(idx, bw, showBoth, width) {
const a = this.am.accounts[idx];
const isCur = idx === this.am.currentIndex;
const isSel = this.mode === 'select' && idx === this.selIdx;
Expand Down Expand Up @@ -476,13 +476,19 @@ export class TUI {

// Quota ratios — prefer unified (Claude Max), fall back to standard (API key)
const q = a.quota;
let r1 = null, r2 = null, l1 = 'Ses', l2 = 'Wk ', t1 = null, t2 = null;
let r1 = null, r2 = null, r3 = null;
let l1 = 'Ses', l2 = 'Wk ', l3 = 'S7 ';
let t1 = null, t2 = null, t3 = null;
let isOAuthQuota = false;

if (q.unified5h != null || q.unified7d != null) {
if (q.unified5h != null || q.unified7d != null || q.unified7dSonnet != null) {
isOAuthQuota = true;
r1 = q.unified5h;
r2 = q.unified7d;
r3 = q.unified7dSonnet;
t1 = q.unified5hReset;
t2 = q.unified7dReset;
t3 = q.unified7dSonnetReset;
} else {
l1 = 'Tok';
l2 = 'Req';
Expand All @@ -494,11 +500,24 @@ export class TUI {
t2 = t1;
}

let line = ` ${sel}${cur} ${name} ${type} ${status} ${l1} ${bar(r1, bw, t1)}`;
if (showBoth) {
line += ` ${l2} ${bar(r2, bw, t2)}`;
const head = ` ${sel}${cur} ${name} ${type} ${status}`;
const primary = `${head} ${l1} ${bar(r1, bw, t1)}`;
const secondary = `${' '.repeat(vw(head) + 1)}${l2} ${bar(r2, bw, t2)}`;
const tertiary = `${' '.repeat(vw(head) + 1)}${l3} ${bar(r3, bw, t3)}`;

if (!showBoth) {
const lines = [primary];
if (r2 != null || !isOAuthQuota) lines.push(secondary);
if (isOAuthQuota && r3 != null) lines.push(tertiary);
return lines;
}
return line;

let firstLine = `${head} ${l1} ${bar(r1, bw, t1)} ${l2} ${bar(r2, bw, t2)}`;
if (!isOAuthQuota) return [firstLine];
if (r3 == null) return [firstLine];
const combined = `${firstLine} ${l3} ${bar(r3, bw, t3)}`;
if (vw(combined) <= width) return [combined];
return [firstLine, tertiary];
}

_renderFooter() {
Expand Down