diff --git a/CLAUDE.md b/CLAUDE.md index 4eff876..a3b8a44 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,7 @@ public/ - **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) and mounting WebUI - **src/webui/index.js**: WebUI backend handling API routes (`/api/*`) for config, accounts, and logs - **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support + - `model-api.js`: Model listing, quota retrieval (`getModelQuotas()`), and subscription tier detection (`getSubscriptionTier()`) - **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown - **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules - **src/format/**: Format conversion between Anthropic and Google Generative AI formats @@ -148,6 +149,17 @@ public/ - Session ID derived from first user message hash for cache continuity - Account state persisted to `~/.config/antigravity-proxy/accounts.json` +**Account Data Model:** +Each account object in `accounts.json` contains: +- **Basic Info**: `email`, `source` (oauth/manual/database), `enabled`, `lastUsed` +- **Credentials**: `refreshToken` (OAuth) or `apiKey` (manual) +- **Subscription**: `{ tier, projectId, detectedAt }` - automatically detected via `loadCodeAssist` API + - `tier`: 'free' | 'pro' | 'ultra' (detected from `paidTier` or `currentTier`) +- **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache + - `models[modelId]`: `{ remainingFraction, resetTime }` from `fetchAvailableModels` API +- **Rate Limits**: `modelRateLimits[modelId]` - temporary rate limit state (in-memory during runtime) +- **Validity**: `isInvalid`, `invalidReason` - tracks accounts needing re-authentication + **Prompt Caching:** - Cache is organization-scoped (requires same account + session ID) - Session ID is SHA256 hash of first user message content (stable across turns) @@ -183,11 +195,13 @@ public/ - **Architecture**: Single Page Application (SPA) with dynamic view loading - **State Management**: Alpine.store for global state (accounts, settings, logs) - **Features**: - - Real-time dashboard with Chart.js visualization + - Real-time dashboard with Chart.js visualization and subscription tier distribution + - Account list with tier badges (Ultra/Pro/Free) and quota progress bars - OAuth flow handling via popup window - Live log streaming via Server-Sent Events (SSE) - Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`) - **Security**: Optional password protection via `WEBUI_PASSWORD` env var +- **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden) ## Testing Notes @@ -223,6 +237,12 @@ public/ - `sleep(ms)` - Promise-based delay - `isNetworkError(error)` - Check if error is a transient network error +**Data Persistence:** +- Subscription and quota data are automatically fetched when `/account-limits` is called +- Updated data is saved to `accounts.json` asynchronously (non-blocking) +- On server restart, accounts load with last known subscription/quota state +- Quota is refreshed on each WebUI poll (default: 30s with jitter) + **Logger:** Structured logging via `src/utils/logger.js`: - `logger.info(msg)` - Standard info (blue) - `logger.success(msg)` - Success messages (green) @@ -239,6 +259,9 @@ public/ - `/api/claude/config` - Claude CLI settings - `/api/logs/stream` - SSE endpoint for real-time logs - `/api/auth/url` - Generate Google OAuth URL +- `/account-limits` - Fetch account quotas and subscription data + - Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }` + - Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats) ## Maintenance diff --git a/README.md b/README.md index 11d0101..0207373 100644 --- a/README.md +++ b/README.md @@ -280,10 +280,10 @@ When you add multiple accounts, the proxy automatically: - **Invalid account detection**: Accounts needing re-authentication are marked and skipped - **Prompt caching support**: Stable session IDs enable cache hits across conversation turns -Check account status anytime: +Check account status, subscription tiers, and quota anytime: ```bash -# Web UI: http://localhost:8080/ (Accounts tab) +# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress) # CLI Table: curl "http://localhost:8080/account-limits?format=table" ``` @@ -313,9 +313,9 @@ The proxy includes a built-in, modern web interface for real-time monitoring and ### Key Features -- **Real-time Dashboard**: Monitor request volume, active accounts, and model health. -- **Visual Model Quota**: Track per-model usage and next reset times. -- **Account Management**: Add/remove Google accounts via OAuth without using the CLI. +- **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution. +- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators. +- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra) and quota status at a glance. - **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser. - **Live Logs**: Stream server logs with level-based filtering and search. - **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly. diff --git a/public/js/app-init.js b/public/js/app-init.js index 1a5da04..c2b4593 100644 --- a/public/js/app-init.js +++ b/public/js/app-init.js @@ -39,6 +39,7 @@ document.addEventListener('alpine:init', () => { }, refreshTimer: null, + isTabVisible: true, fetchData() { Alpine.store('data').fetchData(); @@ -46,9 +47,39 @@ document.addEventListener('alpine:init', () => { startAutoRefresh() { if (this.refreshTimer) clearInterval(this.refreshTimer); - const interval = parseInt(Alpine.store('settings').refreshInterval); - if (interval > 0) { - this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000); + const baseInterval = parseInt(Alpine.store('settings').refreshInterval); + if (baseInterval > 0) { + // Setup visibility change listener (only once) + if (!this._visibilitySetup) { + this._visibilitySetup = true; + document.addEventListener('visibilitychange', () => { + this.isTabVisible = !document.hidden; + if (this.isTabVisible) { + // Tab became visible - fetch immediately and restart timer + Alpine.store('data').fetchData(); + this.startAutoRefresh(); + } + }); + } + + // Schedule next refresh with jitter + const scheduleNext = () => { + // Add ±20% random jitter to prevent synchronized requests + const jitter = (Math.random() - 0.5) * 0.4; // -0.2 to +0.2 + const interval = baseInterval * (1 + jitter); + + // Slow down when tab is hidden (reduce frequency by 3x) + const actualInterval = this.isTabVisible + ? interval + : interval * 3; + + this.refreshTimer = setTimeout(() => { + Alpine.store('data').fetchData(); + scheduleNext(); // Reschedule with new jitter + }, actualInterval * 1000); + }; + + scheduleNext(); } }, diff --git a/public/js/components/account-manager.js b/public/js/components/account-manager.js index fd5c043..8324b9a 100644 --- a/public/js/components/account-manager.js +++ b/public/js/components/account-manager.js @@ -163,5 +163,40 @@ window.Components.accountManager = () => ({ } catch (e) { store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error'); } + }, + + /** + * Get main model quota for display + * Prioritizes flagship models (Opus > Sonnet > Flash) + * @param {Object} account - Account object with limits + * @returns {Object} { percent: number|null, model: string } + */ + getMainModelQuota(account) { + const limits = account.limits || {}; + const modelIds = Object.keys(limits); + + if (modelIds.length === 0) { + return { percent: null, model: '-' }; + } + + // Priority: opus > sonnet > flash > others + const priorityModels = [ + modelIds.find(m => m.toLowerCase().includes('opus')), + modelIds.find(m => m.toLowerCase().includes('sonnet')), + modelIds.find(m => m.toLowerCase().includes('flash')), + modelIds[0] // Fallback to first model + ]; + + const selectedModel = priorityModels.find(m => m) || modelIds[0]; + const quota = limits[selectedModel]; + + if (!quota || quota.remainingFraction === null) { + return { percent: null, model: selectedModel }; + } + + return { + percent: Math.round(quota.remainingFraction * 100), + model: selectedModel + }; } }); diff --git a/public/js/components/dashboard/stats.js b/public/js/components/dashboard/stats.js index 967804c..53ad22a 100644 --- a/public/js/components/dashboard/stats.js +++ b/public/js/components/dashboard/stats.js @@ -40,4 +40,18 @@ window.DashboardStats.updateStats = function(component) { component.stats.total = enabledAccounts.length; component.stats.active = active; component.stats.limited = limited; + + // Calculate subscription tier distribution + const subscription = { ultra: 0, pro: 0, free: 0 }; + enabledAccounts.forEach(acc => { + const tier = acc.subscription?.tier || 'free'; + if (tier === 'ultra') { + subscription.ultra++; + } else if (tier === 'pro') { + subscription.pro++; + } else { + subscription.free++; + } + }); + component.stats.subscription = subscription; }; diff --git a/public/views/accounts.html b/public/views/accounts.html index b9af5ec..b34a451 100644 --- a/public/views/accounts.html +++ b/public/views/accounts.html @@ -50,6 +50,8 @@