From ee6d222e4dbc959f6cab4d1856c7436850177f8f Mon Sep 17 00:00:00 2001 From: Wha1eChai Date: Sat, 10 Jan 2026 06:04:51 +0800 Subject: [PATCH] feat(webui): add subscription tier and quota visualization Backend: --- CLAUDE.md | 25 ++++++++- README.md | 10 ++-- public/js/app-init.js | 37 +++++++++++-- public/js/components/account-manager.js | 35 ++++++++++++ public/js/components/dashboard/stats.js | 14 +++++ public/views/accounts.html | 34 ++++++++++++ public/views/dashboard.html | 22 +++++++- src/account-manager/storage.js | 10 +++- src/cloudcode/index.js | 7 +-- src/cloudcode/model-api.js | 72 +++++++++++++++++++++++++ src/server.js | 29 +++++++++- 11 files changed, 277 insertions(+), 18 deletions(-) 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 @@ Enabled Account (Email) Source + Tier + Quota Health Operations @@ -112,6 +114,38 @@ x-text="acc.source || 'oauth'"> + + + + + +
+ + +
+
+ +
+ + + +
- +
-
diff --git a/src/account-manager/storage.js b/src/account-manager/storage.js index 1c96329..eaf77bc 100644 --- a/src/account-manager/storage.js +++ b/src/account-manager/storage.js @@ -31,7 +31,10 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) { // Reset invalid flag on startup - give accounts a fresh chance to refresh isInvalid: false, invalidReason: null, - modelRateLimits: acc.modelRateLimits || {} + modelRateLimits: acc.modelRateLimits || {}, + // New fields for subscription and quota tracking + subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null }, + quota: acc.quota || { models: {}, lastChecked: null } })); const settings = config.settings || {}; @@ -117,7 +120,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex) isInvalid: acc.isInvalid || false, invalidReason: acc.invalidReason || null, modelRateLimits: acc.modelRateLimits || {}, - lastUsed: acc.lastUsed + lastUsed: acc.lastUsed, + // Persist subscription and quota data + subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null }, + quota: acc.quota || { models: {}, lastChecked: null } })), settings: settings, activeIndex: activeIndex diff --git a/src/cloudcode/index.js b/src/cloudcode/index.js index 57898fb..806f402 100644 --- a/src/cloudcode/index.js +++ b/src/cloudcode/index.js @@ -12,17 +12,18 @@ // Re-export public API export { sendMessage } from './message-handler.js'; export { sendMessageStream } from './streaming-handler.js'; -export { listModels, fetchAvailableModels, getModelQuotas } from './model-api.js'; +export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js'; // Default export for backwards compatibility import { sendMessage } from './message-handler.js'; import { sendMessageStream } from './streaming-handler.js'; -import { listModels, fetchAvailableModels, getModelQuotas } from './model-api.js'; +import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js'; export default { sendMessage, sendMessageStream, listModels, fetchAvailableModels, - getModelQuotas + getModelQuotas, + getSubscriptionTier }; diff --git a/src/cloudcode/model-api.js b/src/cloudcode/model-api.js index e8bd9e3..e87c240 100644 --- a/src/cloudcode/model-api.js +++ b/src/cloudcode/model-api.js @@ -110,3 +110,75 @@ export async function getModelQuotas(token) { return quotas; } + +/** + * Get subscription tier for an account + * Calls loadCodeAssist API to discover project ID and subscription tier + * + * @param {string} token - OAuth access token + * @returns {Promise<{tier: string, projectId: string|null}>} Subscription tier (free/pro/ultra) and project ID + */ +export async function getSubscriptionTier(token) { + const headers = { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...ANTIGRAVITY_HEADERS + }; + + for (const endpoint of ANTIGRAVITY_ENDPOINT_FALLBACKS) { + try { + const url = `${endpoint}/v1internal:loadCodeAssist`; + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + }) + }); + + if (!response.ok) { + logger.warn(`[CloudCode] loadCodeAssist error at ${endpoint}: ${response.status}`); + continue; + } + + const data = await response.json(); + + // Extract project ID + let projectId = null; + if (typeof data.cloudaicompanionProject === 'string') { + projectId = data.cloudaicompanionProject; + } else if (data.cloudaicompanionProject?.id) { + projectId = data.cloudaicompanionProject.id; + } + + // Extract subscription tier (priority: paidTier > currentTier) + let tier = 'free'; + const tierId = data.paidTier?.id || data.currentTier?.id; + + if (tierId) { + const lowerTier = tierId.toLowerCase(); + if (lowerTier.includes('ultra')) { + tier = 'ultra'; + } else if (lowerTier.includes('pro')) { + tier = 'pro'; + } else { + tier = 'free'; + } + } + + logger.debug(`[CloudCode] Subscription detected: ${tier}, Project: ${projectId}`); + + return { tier, projectId }; + } catch (error) { + logger.warn(`[CloudCode] loadCodeAssist failed at ${endpoint}:`, error.message); + } + } + + // Fallback: return default values if all endpoints fail + logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.'); + return { tier: 'free', projectId: null }; +} diff --git a/src/server.js b/src/server.js index 194d027..0178d7d 100644 --- a/src/server.js +++ b/src/server.js @@ -8,7 +8,7 @@ import express from 'express'; import cors from 'cors'; import path from 'path'; import { fileURLToPath } from 'url'; -import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js'; +import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js'; import { mountWebUI } from './webui/index.js'; import { config } from './config.js'; @@ -266,11 +266,33 @@ app.get('/account-limits', async (req, res) => { try { const token = await accountManager.getTokenForAccount(account); - const quotas = await getModelQuotas(token); + + // Fetch both quotas and subscription tier in parallel + const [quotas, subscription] = await Promise.all([ + getModelQuotas(token), + getSubscriptionTier(token) + ]); + + // Update account object with fresh data + account.subscription = { + tier: subscription.tier, + projectId: subscription.projectId, + detectedAt: Date.now() + }; + account.quota = { + models: quotas, + lastChecked: Date.now() + }; + + // Save updated account data to disk (async, don't wait) + accountManager.saveToDisk().catch(err => { + logger.error('[Server] Failed to save account data:', err); + }); return { email: account.email, status: 'ok', + subscription: account.subscription, models: quotas }; } catch (error) { @@ -278,6 +300,7 @@ app.get('/account-limits', async (req, res) => { email: account.email, status: 'error', error: error.message, + subscription: account.subscription || { tier: 'unknown', projectId: null }, models: {} }; } @@ -451,6 +474,8 @@ app.get('/account-limits', async (req, res) => { invalidReason: metadata.invalidReason || null, lastUsed: metadata.lastUsed || null, modelRateLimits: metadata.modelRateLimits || {}, + // Subscription data (new) + subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null }, // Quota limits limits: Object.fromEntries( sortedModels.map(modelId => {