feat(webui): add subscription tier and quota visualization
Backend:
This commit is contained in:
25
CLAUDE.md
25
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/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/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
|
- **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/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/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
|
- **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
|
- Session ID derived from first user message hash for cache continuity
|
||||||
- Account state persisted to `~/.config/antigravity-proxy/accounts.json`
|
- 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:**
|
**Prompt Caching:**
|
||||||
- Cache is organization-scoped (requires same account + session ID)
|
- Cache is organization-scoped (requires same account + session ID)
|
||||||
- Session ID is SHA256 hash of first user message content (stable across turns)
|
- 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
|
- **Architecture**: Single Page Application (SPA) with dynamic view loading
|
||||||
- **State Management**: Alpine.store for global state (accounts, settings, logs)
|
- **State Management**: Alpine.store for global state (accounts, settings, logs)
|
||||||
- **Features**:
|
- **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
|
- OAuth flow handling via popup window
|
||||||
- Live log streaming via Server-Sent Events (SSE)
|
- Live log streaming via Server-Sent Events (SSE)
|
||||||
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
|
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
|
||||||
- **Security**: Optional password protection via `WEBUI_PASSWORD` env var
|
- **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
|
## Testing Notes
|
||||||
|
|
||||||
@@ -223,6 +237,12 @@ public/
|
|||||||
- `sleep(ms)` - Promise-based delay
|
- `sleep(ms)` - Promise-based delay
|
||||||
- `isNetworkError(error)` - Check if error is a transient network error
|
- `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:** Structured logging via `src/utils/logger.js`:
|
||||||
- `logger.info(msg)` - Standard info (blue)
|
- `logger.info(msg)` - Standard info (blue)
|
||||||
- `logger.success(msg)` - Success messages (green)
|
- `logger.success(msg)` - Success messages (green)
|
||||||
@@ -239,6 +259,9 @@ public/
|
|||||||
- `/api/claude/config` - Claude CLI settings
|
- `/api/claude/config` - Claude CLI settings
|
||||||
- `/api/logs/stream` - SSE endpoint for real-time logs
|
- `/api/logs/stream` - SSE endpoint for real-time logs
|
||||||
- `/api/auth/url` - Generate Google OAuth URL
|
- `/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
|
## Maintenance
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
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
|
- **Invalid account detection**: Accounts needing re-authentication are marked and skipped
|
||||||
- **Prompt caching support**: Stable session IDs enable cache hits across conversation turns
|
- **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
|
```bash
|
||||||
# Web UI: http://localhost:8080/ (Accounts tab)
|
# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress)
|
||||||
# CLI Table:
|
# CLI Table:
|
||||||
curl "http://localhost:8080/account-limits?format=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
|
### Key Features
|
||||||
|
|
||||||
- **Real-time Dashboard**: Monitor request volume, active accounts, and model health.
|
- **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.
|
- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators.
|
||||||
- **Account Management**: Add/remove Google accounts via OAuth without using the CLI.
|
- **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.
|
- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
|
||||||
- **Live Logs**: Stream server logs with level-based filtering and search.
|
- **Live Logs**: Stream server logs with level-based filtering and search.
|
||||||
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
|
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
refreshTimer: null,
|
refreshTimer: null,
|
||||||
|
isTabVisible: true,
|
||||||
|
|
||||||
fetchData() {
|
fetchData() {
|
||||||
Alpine.store('data').fetchData();
|
Alpine.store('data').fetchData();
|
||||||
@@ -46,9 +47,39 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
startAutoRefresh() {
|
startAutoRefresh() {
|
||||||
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
if (this.refreshTimer) clearInterval(this.refreshTimer);
|
||||||
const interval = parseInt(Alpine.store('settings').refreshInterval);
|
const baseInterval = parseInt(Alpine.store('settings').refreshInterval);
|
||||||
if (interval > 0) {
|
if (baseInterval > 0) {
|
||||||
this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000);
|
// 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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -163,5 +163,40 @@ window.Components.accountManager = () => ({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
store.showToast(store.t('reloadFailed') + ': ' + e.message, 'error');
|
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
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,4 +40,18 @@ window.DashboardStats.updateStats = function(component) {
|
|||||||
component.stats.total = enabledAccounts.length;
|
component.stats.total = enabledAccounts.length;
|
||||||
component.stats.active = active;
|
component.stats.active = active;
|
||||||
component.stats.limited = limited;
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,6 +50,8 @@
|
|||||||
<th class="pl-6 py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16" x-text="$store.global.t('enabled')">Enabled</th>
|
<th class="pl-6 py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16" x-text="$store.global.t('enabled')">Enabled</th>
|
||||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider flex-1 min-w-[200px]" x-text="$store.global.t('accountEmail')">Account (Email)</th>
|
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider flex-1 min-w-[200px]" x-text="$store.global.t('accountEmail')">Account (Email)</th>
|
||||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-20" x-text="$store.global.t('source')">Source</th>
|
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-20" x-text="$store.global.t('source')">Source</th>
|
||||||
|
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-16">Tier</th>
|
||||||
|
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32">Quota</th>
|
||||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('health')">Health</th>
|
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('health')">Health</th>
|
||||||
<th class="py-3 pr-6 text-right text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('operations')">Operations</th>
|
<th class="py-3 pr-6 text-right text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('operations')">Operations</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -112,6 +114,38 @@
|
|||||||
x-text="acc.source || 'oauth'">
|
x-text="acc.source || 'oauth'">
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="py-4">
|
||||||
|
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
|
||||||
|
:class="{
|
||||||
|
'bg-yellow-500/10 text-yellow-400 border border-yellow-500/30': acc.subscription?.tier === 'ultra',
|
||||||
|
'bg-blue-500/10 text-blue-400 border border-blue-500/30': acc.subscription?.tier === 'pro',
|
||||||
|
'bg-gray-500/10 text-gray-400 border border-gray-500/30': !acc.subscription || acc.subscription.tier === 'free' || acc.subscription.tier === 'unknown'
|
||||||
|
}"
|
||||||
|
x-text="(acc.subscription?.tier || 'free').toUpperCase()">
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="py-4">
|
||||||
|
<div x-data="{ quota: getMainModelQuota(acc) }">
|
||||||
|
<template x-if="quota.percent !== null">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-16 bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||||
|
<div class="h-full rounded-full transition-all"
|
||||||
|
:class="{
|
||||||
|
'bg-neon-green': quota.percent > 50,
|
||||||
|
'bg-yellow-500': quota.percent > 20 && quota.percent <= 50,
|
||||||
|
'bg-red-500': quota.percent <= 20
|
||||||
|
}"
|
||||||
|
:style="`width: ${quota.percent}%`">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-mono text-gray-400" x-text="quota.percent + '%'"></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="quota.percent === null">
|
||||||
|
<span class="text-xs text-gray-600">-</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="py-4">
|
<td class="py-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-2 h-2 rounded-full flex-shrink-0"
|
<div class="w-2 h-2 rounded-full flex-shrink-0"
|
||||||
|
|||||||
@@ -50,6 +50,24 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Subscription Tier Distribution -->
|
||||||
|
<div class="flex items-center gap-2 mt-2 text-[10px] font-mono" x-show="stats.subscription">
|
||||||
|
<template x-if="stats.subscription?.ultra > 0">
|
||||||
|
<span class="px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/30">
|
||||||
|
<span x-text="stats.subscription.ultra"></span> Ultra
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="stats.subscription?.pro > 0">
|
||||||
|
<span class="px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/30">
|
||||||
|
<span x-text="stats.subscription.pro"></span> Pro
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template x-if="stats.subscription?.free > 0">
|
||||||
|
<span class="px-1.5 py-0.5 rounded bg-gray-500/10 text-gray-400 border border-gray-500/30">
|
||||||
|
<span x-text="stats.subscription.free"></span> Free
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -377,7 +395,7 @@
|
|||||||
<!-- Chart -->
|
<!-- Chart -->
|
||||||
<div class="h-48 w-full relative">
|
<div class="h-48 w-full relative">
|
||||||
<canvas id="usageTrendChart"></canvas>
|
<canvas id="usageTrendChart"></canvas>
|
||||||
|
|
||||||
<!-- Overall Loading State -->
|
<!-- Overall Loading State -->
|
||||||
<div x-show="!stats.hasTrendData"
|
<div x-show="!stats.hasTrendData"
|
||||||
class="absolute inset-0 flex items-center justify-center bg-space-900/50 backdrop-blur-sm z-10"
|
class="absolute inset-0 flex items-center justify-center bg-space-900/50 backdrop-blur-sm z-10"
|
||||||
@@ -395,7 +413,7 @@
|
|||||||
<div class="flex flex-col items-center gap-4 animate-fade-in">
|
<div class="flex flex-col items-center gap-4 animate-fade-in">
|
||||||
<div class="w-12 h-12 rounded-full bg-space-850 flex items-center justify-center text-gray-600 border border-space-border/50">
|
<div class="w-12 h-12 rounded-full bg-space-850 flex items-center justify-center text-gray-600 border border-space-border/50">
|
||||||
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
|
|||||||
// Reset invalid flag on startup - give accounts a fresh chance to refresh
|
// Reset invalid flag on startup - give accounts a fresh chance to refresh
|
||||||
isInvalid: false,
|
isInvalid: false,
|
||||||
invalidReason: null,
|
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 || {};
|
const settings = config.settings || {};
|
||||||
@@ -117,7 +120,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
|
|||||||
isInvalid: acc.isInvalid || false,
|
isInvalid: acc.isInvalid || false,
|
||||||
invalidReason: acc.invalidReason || null,
|
invalidReason: acc.invalidReason || null,
|
||||||
modelRateLimits: acc.modelRateLimits || {},
|
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,
|
settings: settings,
|
||||||
activeIndex: activeIndex
|
activeIndex: activeIndex
|
||||||
|
|||||||
@@ -12,17 +12,18 @@
|
|||||||
// Re-export public API
|
// Re-export public API
|
||||||
export { sendMessage } from './message-handler.js';
|
export { sendMessage } from './message-handler.js';
|
||||||
export { sendMessageStream } from './streaming-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
|
// Default export for backwards compatibility
|
||||||
import { sendMessage } from './message-handler.js';
|
import { sendMessage } from './message-handler.js';
|
||||||
import { sendMessageStream } from './streaming-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 {
|
export default {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
sendMessageStream,
|
sendMessageStream,
|
||||||
listModels,
|
listModels,
|
||||||
fetchAvailableModels,
|
fetchAvailableModels,
|
||||||
getModelQuotas
|
getModelQuotas,
|
||||||
|
getSubscriptionTier
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -110,3 +110,75 @@ export async function getModelQuotas(token) {
|
|||||||
|
|
||||||
return quotas;
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import express from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
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 { mountWebUI } from './webui/index.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
|
|
||||||
@@ -266,11 +266,33 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const token = await accountManager.getTokenForAccount(account);
|
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 {
|
return {
|
||||||
email: account.email,
|
email: account.email,
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
|
subscription: account.subscription,
|
||||||
models: quotas
|
models: quotas
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -278,6 +300,7 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
email: account.email,
|
email: account.email,
|
||||||
status: 'error',
|
status: 'error',
|
||||||
error: error.message,
|
error: error.message,
|
||||||
|
subscription: account.subscription || { tier: 'unknown', projectId: null },
|
||||||
models: {}
|
models: {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -451,6 +474,8 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
invalidReason: metadata.invalidReason || null,
|
invalidReason: metadata.invalidReason || null,
|
||||||
lastUsed: metadata.lastUsed || null,
|
lastUsed: metadata.lastUsed || null,
|
||||||
modelRateLimits: metadata.modelRateLimits || {},
|
modelRateLimits: metadata.modelRateLimits || {},
|
||||||
|
// Subscription data (new)
|
||||||
|
subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
|
||||||
// Quota limits
|
// Quota limits
|
||||||
limits: Object.fromEntries(
|
limits: Object.fromEntries(
|
||||||
sortedModels.map(modelId => {
|
sortedModels.map(modelId => {
|
||||||
|
|||||||
Reference in New Issue
Block a user