feat(webui): add subscription tier and quota visualization

Backend:
This commit is contained in:
Wha1eChai
2026-01-10 06:04:51 +08:00
parent 369a66e8cf
commit ee6d222e4d
11 changed files with 277 additions and 18 deletions

View File

@@ -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

View File

@@ -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
};

View File

@@ -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 };
}

View File

@@ -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 => {