mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
feat(cliproxy): add account quota display for Antigravity provider
- Add quota-fetcher.ts for Google Cloud Code API integration - Add REST endpoint GET /api/cliproxy/quota/:provider/:accountId - Add useAccountQuota hook with React Query caching - Display quota bar in AccountItem with per-model tooltip - Create reusable Progress component - Consolidate quota types in api-client.ts
This commit is contained in:
@@ -111,6 +111,10 @@ export {
|
||||
export type { CliproxyStats } from './stats-fetcher';
|
||||
export { fetchCliproxyStats, isCliproxyRunning } from './stats-fetcher';
|
||||
|
||||
// Quota fetcher
|
||||
export type { ModelQuota, QuotaResult } from './quota-fetcher';
|
||||
export { fetchAccountQuota } from './quota-fetcher';
|
||||
|
||||
// OpenAI compatibility layer
|
||||
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Quota Fetcher for Antigravity Accounts
|
||||
*
|
||||
* Fetches quota information from Google Cloud Code internal API.
|
||||
* Used for displaying remaining quota percentages and reset times.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getAuthDir } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
|
||||
/** Individual model quota info */
|
||||
export interface ModelQuota {
|
||||
/** Model name, e.g., "gemini-3-pro-high" */
|
||||
name: string;
|
||||
/** Display name from API, e.g., "Gemini 3 Pro" */
|
||||
displayName?: string;
|
||||
/** Remaining quota as percentage (0-100) */
|
||||
percentage: number;
|
||||
/** ISO timestamp when quota resets, null if unknown */
|
||||
resetTime: string | null;
|
||||
}
|
||||
|
||||
/** Quota fetch result */
|
||||
export interface QuotaResult {
|
||||
/** Whether fetch succeeded */
|
||||
success: boolean;
|
||||
/** Quota for each available model */
|
||||
models: ModelQuota[];
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** True if account lacks quota access (403) */
|
||||
isForbidden?: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Google Cloud Code API endpoints */
|
||||
const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
||||
const ANTIGRAVITY_API_VERSION = 'v1internal';
|
||||
|
||||
/** API client headers */
|
||||
const ANTIGRAVITY_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.11.5 linux/amd64',
|
||||
'X-Goog-Api-Client': 'gl-node/20.9.0',
|
||||
};
|
||||
|
||||
/** Auth file structure */
|
||||
interface AntigravityAuthFile {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
email?: string;
|
||||
expired?: string;
|
||||
expires_in?: number;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/** loadCodeAssist response */
|
||||
interface LoadCodeAssistResponse {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
}
|
||||
|
||||
/** fetchAvailableModels response model */
|
||||
interface AvailableModel {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number;
|
||||
remaining_fraction?: number;
|
||||
remaining?: number;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
quota_info?: {
|
||||
remainingFraction?: number;
|
||||
remaining_fraction?: number;
|
||||
remaining?: number;
|
||||
resetTime?: string;
|
||||
reset_time?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** fetchAvailableModels response */
|
||||
interface FetchAvailableModelsResponse {
|
||||
models?: Record<string, AvailableModel>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read access token from auth file
|
||||
*/
|
||||
function readAccessToken(provider: CLIProxyProvider, accountId: string): string | null {
|
||||
const authDir = getAuthDir();
|
||||
|
||||
// Account ID format: email with @ and . replaced by _
|
||||
// Try to find matching token file
|
||||
const files = fs.readdirSync(authDir);
|
||||
const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.startsWith(prefix) && file.endsWith('.json')) {
|
||||
// Check if this file matches the account ID
|
||||
const baseName = file.replace(prefix, '').replace('.json', '');
|
||||
if (baseName === accountId || file === accountId || file === `${accountId}.json`) {
|
||||
const filePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
return data.access_token || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project ID via loadCodeAssist endpoint
|
||||
*/
|
||||
async function getProjectId(accessToken: string): Promise<string | null> {
|
||||
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...ANTIGRAVITY_HEADERS,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as LoadCodeAssistResponse;
|
||||
|
||||
// Extract project ID from response
|
||||
let projectId: string | undefined;
|
||||
if (typeof data.cloudaicompanionProject === 'string') {
|
||||
projectId = data.cloudaicompanionProject;
|
||||
} else if (typeof data.cloudaicompanionProject === 'object') {
|
||||
projectId = data.cloudaicompanionProject?.id;
|
||||
}
|
||||
|
||||
return projectId?.trim() || null;
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models with quota info
|
||||
*/
|
||||
async function fetchAvailableModels(accessToken: string, projectId: string): Promise<QuotaResult> {
|
||||
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...ANTIGRAVITY_HEADERS,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
project: projectId,
|
||||
}),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.status === 403) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
isForbidden: true,
|
||||
error: 'Quota access forbidden for this account',
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Access token expired or invalid',
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: `API error: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as FetchAvailableModelsResponse;
|
||||
const models: ModelQuota[] = [];
|
||||
|
||||
if (data.models && typeof data.models === 'object') {
|
||||
for (const [modelId, modelData] of Object.entries(data.models)) {
|
||||
const quotaInfo = modelData.quotaInfo || modelData.quota_info;
|
||||
if (!quotaInfo) continue;
|
||||
|
||||
// Extract remaining fraction (0-1 range)
|
||||
const remaining =
|
||||
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
|
||||
|
||||
if (typeof remaining !== 'number') continue;
|
||||
|
||||
// Convert to percentage (0-100)
|
||||
const percentage = Math.round(remaining * 100);
|
||||
|
||||
// Extract reset time
|
||||
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
|
||||
|
||||
models.push({
|
||||
name: modelId,
|
||||
displayName: modelData.displayName,
|
||||
percentage,
|
||||
resetTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
models,
|
||||
lastUpdated: Date.now(),
|
||||
};
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota for an Antigravity account
|
||||
*
|
||||
* @param provider - Provider name (only 'agy' supported)
|
||||
* @param accountId - Account identifier (email with _ replacing @ and .)
|
||||
* @returns Quota result with models and percentages
|
||||
*/
|
||||
export async function fetchAccountQuota(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string
|
||||
): Promise<QuotaResult> {
|
||||
// Only Antigravity supports quota fetching
|
||||
if (provider !== 'agy') {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: `Quota not supported for provider: ${provider}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Read access token from auth file
|
||||
const accessToken = readAccessToken(provider, accountId);
|
||||
if (!accessToken) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Access token not found for account',
|
||||
};
|
||||
}
|
||||
|
||||
// Get project ID first
|
||||
const projectId = await getProjectId(accessToken);
|
||||
if (!projectId) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Failed to retrieve project ID',
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch models with quota
|
||||
return fetchAvailableModels(accessToken, projectId);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
fetchCliproxyErrorLogs,
|
||||
fetchCliproxyErrorLogContent,
|
||||
} from '../../cliproxy/stats-fetcher';
|
||||
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import {
|
||||
getCliproxyWritablePath,
|
||||
getConfigPath,
|
||||
@@ -430,4 +432,50 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== Account Quota ====================
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account
|
||||
* Returns: QuotaResult with model quotas and reset times
|
||||
*/
|
||||
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
|
||||
const { provider, accountId } = req.params;
|
||||
|
||||
// Validate provider
|
||||
const validProviders: CLIProxyProvider[] = [
|
||||
'agy',
|
||||
'gemini',
|
||||
'codex',
|
||||
'qwen',
|
||||
'iflow',
|
||||
'kiro',
|
||||
'ghcp',
|
||||
];
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid provider',
|
||||
message: `Provider must be one of: ${validProviders.join(', ')}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate accountId - prevent path traversal
|
||||
if (
|
||||
!accountId ||
|
||||
accountId.includes('..') ||
|
||||
accountId.includes('/') ||
|
||||
accountId.includes('\\')
|
||||
) {
|
||||
res.status(400).json({ error: 'Invalid account ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchAccountQuota(provider as CLIProxyProvider, accountId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,88 +1,195 @@
|
||||
/**
|
||||
* Account Item Component
|
||||
* Displays a single OAuth account with actions
|
||||
* Displays a single OAuth account with actions and quota bar
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { User, Star, MoreHorizontal, Clock, Trash2 } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { User, Star, MoreHorizontal, Clock, Trash2, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
import type { AccountItemProps } from './types';
|
||||
|
||||
/**
|
||||
* Format reset time as relative time (e.g., "in 2 hours")
|
||||
*/
|
||||
function formatResetTime(resetTime: string | null): string | null {
|
||||
if (!resetTime) return null;
|
||||
try {
|
||||
const reset = new Date(resetTime);
|
||||
const now = new Date();
|
||||
const diff = reset.getTime() - now.getTime();
|
||||
if (diff <= 0) return 'soon';
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 0) return `in ${hours}h ${minutes}m`;
|
||||
return `in ${minutes}m`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class based on quota percentage
|
||||
*/
|
||||
function getQuotaColor(percentage: number): string {
|
||||
if (percentage <= 20) return 'bg-destructive';
|
||||
if (percentage <= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
export function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
}: AccountItemProps) {
|
||||
// Fetch quota for 'agy' provider accounts
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
account.provider,
|
||||
account.id,
|
||||
showQuota && account.provider === 'agy'
|
||||
);
|
||||
|
||||
// Calculate average quota across all models
|
||||
const avgQuota =
|
||||
quota?.success && quota.models.length > 0
|
||||
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
|
||||
: null;
|
||||
|
||||
// Get earliest reset time
|
||||
const nextReset =
|
||||
quota?.success && quota.models.length > 0
|
||||
? quota.models.reduce(
|
||||
(earliest, m) => {
|
||||
if (!m.resetTime) return earliest;
|
||||
if (!earliest) return m.resetTime;
|
||||
return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest;
|
||||
},
|
||||
null as string | null
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-3 rounded-lg border transition-colors',
|
||||
'flex flex-col gap-2 p-3 rounded-lg border transition-colors',
|
||||
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{account.email || account.id}
|
||||
</span>
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{account.email || account.id}
|
||||
</span>
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Quota bar - only for 'agy' provider */}
|
||||
{showQuota && account.provider === 'agy' && (
|
||||
<div className="pl-11">
|
||||
{quotaLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Loading quota...</span>
|
||||
</div>
|
||||
) : avgQuota !== null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={avgQuota}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(avgQuota)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{quota?.models.map((m) => (
|
||||
<div key={m.name} className="flex justify-between gap-4">
|
||||
<span className="truncate">{m.displayName || m.name}</span>
|
||||
<span className="font-mono">{m.percentage}%</span>
|
||||
</div>
|
||||
))}
|
||||
{nextReset && (
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.error ? (
|
||||
<div className="text-xs text-muted-foreground">{quota.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ interface AccountsSectionProps {
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Show quota bars for accounts (only applicable for 'agy' provider) */
|
||||
showQuota?: boolean;
|
||||
}
|
||||
|
||||
export function AccountsSection({
|
||||
@@ -25,6 +27,7 @@ export function AccountsSection({
|
||||
onRemoveAccount,
|
||||
isRemovingAccount,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
}: AccountsSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
@@ -54,6 +57,7 @@ export function AccountsSection({
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
isRemoving={isRemovingAccount}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={showQuota}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -177,6 +177,7 @@ export function ProviderEditor({
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
privacyMode={privacyMode}
|
||||
provider={provider}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
|
||||
@@ -35,6 +35,8 @@ interface ModelConfigTabProps {
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Provider name for quota display */
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export function ModelConfigTab({
|
||||
@@ -56,6 +58,7 @@ export function ModelConfigTab({
|
||||
onRemoveAccount,
|
||||
isRemovingAccount,
|
||||
privacyMode,
|
||||
provider,
|
||||
}: ModelConfigTabProps) {
|
||||
return (
|
||||
<ScrollArea className="flex-1">
|
||||
@@ -82,6 +85,7 @@ export function ModelConfigTab({
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={provider === 'agy'}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface AccountItemProps {
|
||||
onRemove: () => void;
|
||||
isRemoving?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Show quota bar (only for 'agy' provider) */
|
||||
showQuota?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelMappingValues {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Progress Component
|
||||
* Simple progress bar with customizable indicator color
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number;
|
||||
max?: number;
|
||||
indicatorClassName?: string;
|
||||
}
|
||||
|
||||
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, indicatorClassName, ...props }, ref) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={value}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-secondary', className)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn('h-full transition-all', indicatorClassName || 'bg-primary')}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Progress.displayName = 'Progress';
|
||||
|
||||
export { Progress };
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { ModelQuota, QuotaResult } from '@/lib/api-client';
|
||||
|
||||
/** Per-account usage statistics */
|
||||
export interface AccountUsageStats {
|
||||
@@ -189,3 +190,33 @@ export function useCliproxyErrorLogContent(name: string | null) {
|
||||
staleTime: 60000, // Cache log content for 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export for consumers
|
||||
export type { ModelQuota, QuotaResult };
|
||||
|
||||
/**
|
||||
* Fetch account quota from API
|
||||
*/
|
||||
async function fetchAccountQuota(provider: string, accountId: string): Promise<QuotaResult> {
|
||||
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to fetch quota');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get account quota
|
||||
* Only enabled for 'agy' provider (Antigravity) as it's the only one supporting quota
|
||||
*/
|
||||
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['account-quota', provider, accountId],
|
||||
queryFn: () => fetchAccountQuota(provider, accountId),
|
||||
enabled: enabled && provider === 'agy' && !!accountId,
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +107,32 @@ export interface CliproxyModelsResponse {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** Individual model quota info from Google Cloud Code API */
|
||||
export interface ModelQuota {
|
||||
/** Model name, e.g., "gemini-3-pro-high" */
|
||||
name: string;
|
||||
/** Display name from API, e.g., "Gemini 3 Pro" */
|
||||
displayName?: string;
|
||||
/** Remaining quota as percentage (0-100) */
|
||||
percentage: number;
|
||||
/** ISO timestamp when quota resets, null if unknown */
|
||||
resetTime: string | null;
|
||||
}
|
||||
|
||||
/** Quota fetch result */
|
||||
export interface QuotaResult {
|
||||
/** Whether fetch succeeded */
|
||||
success: boolean;
|
||||
/** Quota for each available model */
|
||||
models: ModelQuota[];
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** True if account lacks quota access (403) */
|
||||
isForbidden?: boolean;
|
||||
/** Error message if fetch failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Provider accounts summary */
|
||||
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||
|
||||
@@ -404,4 +430,10 @@ export const api = {
|
||||
body: JSON.stringify(params),
|
||||
}),
|
||||
},
|
||||
/** Account quota API */
|
||||
quota: {
|
||||
/** Fetch quota for a specific account */
|
||||
get: (provider: string, accountId: string) =>
|
||||
request<QuotaResult>(`/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user