diff --git a/README.md b/README.md index 3008336..6ebc6a7 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) @@ -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 diff --git a/src/account-manager.js b/src/account-manager.js index 9ea4d78..9b7aa22 100644 --- a/src/account-manager.js +++ b/src/account-manager.js @@ -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, }; @@ -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()) { @@ -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. */ diff --git a/src/index.js b/src/index.js index 415276c..64f7325 100755 --- a/src/index.js +++ b/src/index.js @@ -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); @@ -125,6 +125,7 @@ async function serverCommand() { let tui = null; let hooks = {}; + let usageRefreshTimer = null; if (useTUI) { tui = new TUI({ @@ -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 ────────────────────────────────────────────────── @@ -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) + '%' : '-'; @@ -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; diff --git a/src/oauth.js b/src/oauth.js index 3131246..b432a3d 100644 --- a/src/oauth.js +++ b/src/oauth.js @@ -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. @@ -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'; diff --git a/src/tui.js b/src/tui.js index 503820c..5dec02c 100644 --- a/src/tui.js +++ b/src/tui.js @@ -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)); } } @@ -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; @@ -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'; @@ -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() {