From 9b0b756e72c556eb398e1f45b3ebad2f51eb4aef Mon Sep 17 00:00:00 2001 From: Badri Narayanan S Date: Thu, 25 Dec 2025 21:55:03 +0530 Subject: [PATCH] feat: consolidate /accounts into /account-limits endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant /accounts endpoint - Enhance /account-limits table output with account status, last used time, and quota reset time - Filter model list to show only Claude models - Use local time format for timestamps - Update documentation (README.md, CLAUDE.md, index.js) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 2 +- README.md | 8 +- src/account-manager.js | 9 ++ src/cloudcode-client.js | 66 +++++++++++++- src/index.js | 2 +- src/server.js | 195 +++++++++++++++++++++++++++++++++++----- 6 files changed, 255 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3ebc857..b1ae463 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ Claude Code CLI → Express Server (server.js) → CloudCode Client → Antigrav **Key Modules:** -- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/accounts`) +- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) - **src/cloudcode-client.js**: Makes requests to Antigravity Cloud Code API with retry/failover logic, handles both streaming and non-streaming - **src/format-converter.js**: Bidirectional conversion between Anthropic and Google Generative AI formats, including thinking blocks and tool calls - **src/account-manager.js**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown diff --git a/README.md b/README.md index f325691..073e0d4 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ The server runs on `http://localhost:8080` by default. # Health check curl http://localhost:8080/health -# Check account status -curl http://localhost:8080/accounts +# Check account status and quota limits +curl "http://localhost:8080/account-limits?format=table" ``` --- @@ -149,7 +149,7 @@ When you add multiple accounts, the proxy automatically: Check account status anytime: ```bash -curl http://localhost:8080/accounts +curl "http://localhost:8080/account-limits?format=table" ``` --- @@ -159,7 +159,7 @@ curl http://localhost:8080/accounts | Endpoint | Method | Description | |----------|--------|-------------| | `/health` | GET | Health check | -| `/accounts` | GET | Account pool status | +| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) | | `/v1/messages` | POST | Anthropic Messages API | | `/v1/models` | GET | List available models | | `/refresh-token` | POST | Force token refresh | diff --git a/src/account-manager.js b/src/account-manager.js index e2758b2..67210df 100644 --- a/src/account-manager.js +++ b/src/account-manager.js @@ -619,6 +619,15 @@ export class AccountManager { getSettings() { return { ...this.#settings }; } + + /** + * Get all accounts (internal use for quota fetching) + * Returns the full account objects including credentials + * @returns {Array} Array of account objects + */ + getAllAccounts() { + return this.#accounts; + } } export default AccountManager; diff --git a/src/cloudcode-client.js b/src/cloudcode-client.js index 3018e7f..d00f840 100644 --- a/src/cloudcode-client.js +++ b/src/cloudcode-client.js @@ -947,8 +947,72 @@ export function listModels() { }; } +/** + * Fetch available models with quota info from Cloud Code API + * Returns model quotas including remaining fraction and reset time + * + * @param {string} token - OAuth access token + * @returns {Promise} Raw response from fetchAvailableModels API + */ +export async function fetchAvailableModels(token) { + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }; + + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const url = `${endpoint}/v1internal:fetchAvailableModels`; + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({}) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`[CloudCode] fetchAvailableModels error at ${endpoint}: ${response.status}`); + continue; + } + + return await response.json(); + } catch (error) { + console.log(`[CloudCode] fetchAvailableModels failed at ${endpoint}:`, error.message); + } + } + + throw new Error('Failed to fetch available models from all endpoints'); +} + +/** + * Get model quotas for an account + * Extracts quota info (remaining fraction and reset time) for each model + * + * @param {string} token - OAuth access token + * @returns {Promise} Map of modelId -> { remainingFraction, resetTime } + */ +export async function getModelQuotas(token) { + const data = await fetchAvailableModels(token); + if (!data || !data.models) return {}; + + const quotas = {}; + for (const [modelId, modelData] of Object.entries(data.models)) { + if (modelData.quotaInfo) { + quotas[modelId] = { + remainingFraction: modelData.quotaInfo.remainingFraction ?? null, + resetTime: modelData.quotaInfo.resetTime ?? null + }; + } + } + + return quotas; +} + export default { sendMessage, sendMessageStream, - listModels + listModels, + fetchAvailableModels, + getModelQuotas }; diff --git a/src/index.js b/src/index.js index 1571bc8..74a2be3 100644 --- a/src/index.js +++ b/src/index.js @@ -20,7 +20,7 @@ app.listen(PORT, () => { ║ POST /v1/messages - Anthropic Messages API ║ ║ GET /v1/models - List available models ║ ║ GET /health - Health check ║ -║ GET /accounts - Account pool status ║ +║ GET /account-limits - Account status & quotas ║ ║ POST /refresh-token - Force token refresh ║ ║ ║ ║ Usage with Claude Code: ║ diff --git a/src/server.js b/src/server.js index 25d70c0..39d50ed 100644 --- a/src/server.js +++ b/src/server.js @@ -6,7 +6,7 @@ import express from 'express'; import cors from 'cors'; -import { sendMessage, sendMessageStream, listModels } from './cloudcode-client.js'; +import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode-client.js'; import { forceRefresh } from './token-extractor.js'; import { REQUEST_BODY_LIMIT } from './constants.js'; import { AccountManager } from './account-manager.js'; @@ -127,30 +127,185 @@ app.get('/health', async (req, res) => { }); /** - * Account pool status endpoint + * Account limits endpoint - fetch quota/limits for all accounts × all models + * Returns a table showing remaining quota and reset time for each combination + * Use ?format=table for ASCII table output, default is JSON */ -app.get('/accounts', async (req, res) => { +app.get('/account-limits', async (req, res) => { try { await ensureInitialized(); - const status = accountManager.getStatus(); + const allAccounts = accountManager.getAllAccounts(); + const format = req.query.format || 'json'; + // Fetch quotas for each account in parallel + const results = await Promise.allSettled( + allAccounts.map(async (account) => { + // Skip invalid accounts + if (account.isInvalid) { + return { + email: account.email, + status: 'invalid', + error: account.invalidReason, + models: {} + }; + } + + try { + const token = await accountManager.getTokenForAccount(account); + const quotas = await getModelQuotas(token); + + return { + email: account.email, + status: 'ok', + models: quotas + }; + } catch (error) { + return { + email: account.email, + status: 'error', + error: error.message, + models: {} + }; + } + }) + ); + + // Process results + const accountLimits = results.map((result, index) => { + if (result.status === 'fulfilled') { + return result.value; + } else { + return { + email: allAccounts[index].email, + status: 'error', + error: result.reason?.message || 'Unknown error', + models: {} + }; + } + }); + + // Collect all unique model IDs + const allModelIds = new Set(); + for (const account of accountLimits) { + for (const modelId of Object.keys(account.models || {})) { + allModelIds.add(modelId); + } + } + + const sortedModels = Array.from(allModelIds).filter(m => m.includes('claude')).sort(); + + // Return ASCII table format + if (format === 'table') { + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + + // Build table + const lines = []; + const timestamp = new Date().toLocaleString(); + lines.push(`Account Limits (${timestamp})`); + + // Get account status info + const status = accountManager.getStatus(); + lines.push(`Accounts: ${status.total} total, ${status.available} available, ${status.rateLimited} rate-limited, ${status.invalid} invalid`); + lines.push(''); + + // Table 1: Account status + const accColWidth = 25; + const statusColWidth = 15; + const lastUsedColWidth = 25; + const resetColWidth = 25; + + let accHeader = 'Account'.padEnd(accColWidth) + 'Status'.padEnd(statusColWidth) + 'Last Used'.padEnd(lastUsedColWidth) + 'Quota Reset'; + lines.push(accHeader); + lines.push('─'.repeat(accColWidth + statusColWidth + lastUsedColWidth + resetColWidth)); + + for (const acc of status.accounts) { + const shortEmail = acc.email.split('@')[0].slice(0, 22); + const lastUsed = acc.lastUsed ? new Date(acc.lastUsed).toLocaleString() : 'never'; + + // Get status and error from accountLimits + const accLimit = accountLimits.find(a => a.email === acc.email); + const accStatus = acc.isInvalid ? 'invalid' : (acc.isRateLimited ? 'rate-limited' : (accLimit?.status || 'ok')); + + // Get reset time from quota API + const claudeModel = sortedModels.find(m => m.includes('claude')); + const quota = claudeModel && accLimit?.models?.[claudeModel]; + const resetTime = quota?.resetTime + ? new Date(quota.resetTime).toLocaleString() + : '-'; + + let row = shortEmail.padEnd(accColWidth) + accStatus.padEnd(statusColWidth) + lastUsed.padEnd(lastUsedColWidth) + resetTime; + + // Add error on next line if present + if (accLimit?.error) { + lines.push(row); + lines.push(' └─ ' + accLimit.error); + } else { + lines.push(row); + } + } + lines.push(''); + + // Calculate column widths + const modelColWidth = Math.max(25, ...sortedModels.map(m => m.length)) + 2; + const accountColWidth = 22; + + // Header row + let header = 'Model'.padEnd(modelColWidth); + for (const acc of accountLimits) { + const shortEmail = acc.email.split('@')[0].slice(0, 18); + header += shortEmail.padEnd(accountColWidth); + } + lines.push(header); + lines.push('─'.repeat(modelColWidth + accountLimits.length * accountColWidth)); + + // Data rows + for (const modelId of sortedModels) { + let row = modelId.padEnd(modelColWidth); + for (const acc of accountLimits) { + const quota = acc.models?.[modelId]; + let cell; + if (acc.status !== 'ok') { + cell = `[${acc.status}]`; + } else if (!quota) { + cell = '-'; + } else if (quota.remainingFraction === null) { + cell = '0% (exhausted)'; + } else { + const pct = Math.round(quota.remainingFraction * 100); + cell = `${pct}%`; + } + row += cell.padEnd(accountColWidth); + } + lines.push(row); + } + + return res.send(lines.join('\n')); + } + + // Default: JSON format res.json({ - total: status.total, - available: status.available, - rateLimited: status.rateLimited, - invalid: status.invalid, - accounts: status.accounts.map(a => ({ - email: a.email, - source: a.source, - isRateLimited: a.isRateLimited, - rateLimitResetTime: a.rateLimitResetTime - ? new Date(a.rateLimitResetTime).toISOString() - : null, - isInvalid: a.isInvalid, - invalidReason: a.invalidReason, - lastUsed: a.lastUsed - ? new Date(a.lastUsed).toISOString() - : null + timestamp: new Date().toLocaleString(), + totalAccounts: allAccounts.length, + models: sortedModels, + accounts: accountLimits.map(acc => ({ + email: acc.email, + status: acc.status, + error: acc.error || null, + limits: Object.fromEntries( + sortedModels.map(modelId => { + const quota = acc.models?.[modelId]; + if (!quota) { + return [modelId, null]; + } + return [modelId, { + remaining: quota.remainingFraction !== null + ? `${Math.round(quota.remainingFraction * 100)}%` + : 'N/A', + remainingFraction: quota.remainingFraction, + resetTime: quota.resetTime || null + }]; + }) + ) })) }); } catch (error) {