mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +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 type { CliproxyStats } from './stats-fetcher';
|
||||||
export { fetchCliproxyStats, isCliproxyRunning } 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
|
// OpenAI compatibility layer
|
||||||
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';
|
export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager';
|
||||||
export {
|
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,
|
fetchCliproxyErrorLogs,
|
||||||
fetchCliproxyErrorLogContent,
|
fetchCliproxyErrorLogContent,
|
||||||
} from '../../cliproxy/stats-fetcher';
|
} from '../../cliproxy/stats-fetcher';
|
||||||
|
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
|
||||||
|
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
import {
|
import {
|
||||||
getCliproxyWritablePath,
|
getCliproxyWritablePath,
|
||||||
getConfigPath,
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -1,88 +1,195 @@
|
|||||||
/**
|
/**
|
||||||
* Account Item Component
|
* 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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { cn } from '@/lib/utils';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
|
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||||
import type { AccountItemProps } from './types';
|
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({
|
export function AccountItem({
|
||||||
account,
|
account,
|
||||||
onSetDefault,
|
onSetDefault,
|
||||||
onRemove,
|
onRemove,
|
||||||
isRemoving,
|
isRemoving,
|
||||||
privacyMode,
|
privacyMode,
|
||||||
|
showQuota,
|
||||||
}: AccountItemProps) {
|
}: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div
|
<div className="flex items-center gap-3">
|
||||||
className={cn(
|
<div
|
||||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
className={cn(
|
||||||
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
'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>
|
<User className="w-4 h-4" />
|
||||||
<div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div>
|
||||||
<span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
|
<div className="flex items-center gap-2">
|
||||||
{account.email || account.id}
|
<span className={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||||
</span>
|
{account.email || account.id}
|
||||||
{account.isDefault && (
|
</span>
|
||||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
{account.isDefault && (
|
||||||
<Star className="w-2.5 h-2.5 fill-current" />
|
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||||
Default
|
<Star className="w-2.5 h-2.5 fill-current" />
|
||||||
</Badge>
|
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>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
{/* Quota bar - only for 'agy' provider */}
|
||||||
<DropdownMenuTrigger asChild>
|
{showQuota && account.provider === 'agy' && (
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<div className="pl-11">
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
{quotaLoading ? (
|
||||||
</Button>
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
</DropdownMenuTrigger>
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
<DropdownMenuContent align="end">
|
<span>Loading quota...</span>
|
||||||
{!account.isDefault && (
|
</div>
|
||||||
<DropdownMenuItem onClick={onSetDefault}>
|
) : avgQuota !== null ? (
|
||||||
<Star className="w-4 h-4 mr-2" />
|
<TooltipProvider>
|
||||||
Set as default
|
<Tooltip>
|
||||||
</DropdownMenuItem>
|
<TooltipTrigger asChild>
|
||||||
)}
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenuItem
|
<Progress
|
||||||
className="text-destructive focus:text-destructive"
|
value={avgQuota}
|
||||||
onClick={onRemove}
|
className="h-2 flex-1"
|
||||||
disabled={isRemoving}
|
indicatorClassName={getQuotaColor(avgQuota)}
|
||||||
>
|
/>
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
|
||||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
</div>
|
||||||
</DropdownMenuItem>
|
</TooltipTrigger>
|
||||||
</DropdownMenuContent>
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
</DropdownMenu>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ interface AccountsSectionProps {
|
|||||||
onRemoveAccount: (accountId: string) => void;
|
onRemoveAccount: (accountId: string) => void;
|
||||||
isRemovingAccount?: boolean;
|
isRemovingAccount?: boolean;
|
||||||
privacyMode?: boolean;
|
privacyMode?: boolean;
|
||||||
|
/** Show quota bars for accounts (only applicable for 'agy' provider) */
|
||||||
|
showQuota?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountsSection({
|
export function AccountsSection({
|
||||||
@@ -25,6 +27,7 @@ export function AccountsSection({
|
|||||||
onRemoveAccount,
|
onRemoveAccount,
|
||||||
isRemovingAccount,
|
isRemovingAccount,
|
||||||
privacyMode,
|
privacyMode,
|
||||||
|
showQuota,
|
||||||
}: AccountsSectionProps) {
|
}: AccountsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -54,6 +57,7 @@ export function AccountsSection({
|
|||||||
onRemove={() => onRemoveAccount(account.id)}
|
onRemove={() => onRemoveAccount(account.id)}
|
||||||
isRemoving={isRemovingAccount}
|
isRemoving={isRemovingAccount}
|
||||||
privacyMode={privacyMode}
|
privacyMode={privacyMode}
|
||||||
|
showQuota={showQuota}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export function ProviderEditor({
|
|||||||
onRemoveAccount={onRemoveAccount}
|
onRemoveAccount={onRemoveAccount}
|
||||||
isRemovingAccount={isRemovingAccount}
|
isRemovingAccount={isRemovingAccount}
|
||||||
privacyMode={privacyMode}
|
privacyMode={privacyMode}
|
||||||
|
provider={provider}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent
|
<TabsContent
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ interface ModelConfigTabProps {
|
|||||||
onRemoveAccount: (accountId: string) => void;
|
onRemoveAccount: (accountId: string) => void;
|
||||||
isRemovingAccount?: boolean;
|
isRemovingAccount?: boolean;
|
||||||
privacyMode?: boolean;
|
privacyMode?: boolean;
|
||||||
|
/** Provider name for quota display */
|
||||||
|
provider?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelConfigTab({
|
export function ModelConfigTab({
|
||||||
@@ -56,6 +58,7 @@ export function ModelConfigTab({
|
|||||||
onRemoveAccount,
|
onRemoveAccount,
|
||||||
isRemovingAccount,
|
isRemovingAccount,
|
||||||
privacyMode,
|
privacyMode,
|
||||||
|
provider,
|
||||||
}: ModelConfigTabProps) {
|
}: ModelConfigTabProps) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
@@ -82,6 +85,7 @@ export function ModelConfigTab({
|
|||||||
onRemoveAccount={onRemoveAccount}
|
onRemoveAccount={onRemoveAccount}
|
||||||
isRemovingAccount={isRemovingAccount}
|
isRemovingAccount={isRemovingAccount}
|
||||||
privacyMode={privacyMode}
|
privacyMode={privacyMode}
|
||||||
|
showQuota={provider === 'agy'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export interface AccountItemProps {
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
isRemoving?: boolean;
|
isRemoving?: boolean;
|
||||||
privacyMode?: boolean;
|
privacyMode?: boolean;
|
||||||
|
/** Show quota bar (only for 'agy' provider) */
|
||||||
|
showQuota?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelMappingValues {
|
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 { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { ModelQuota, QuotaResult } from '@/lib/api-client';
|
||||||
|
|
||||||
/** Per-account usage statistics */
|
/** Per-account usage statistics */
|
||||||
export interface AccountUsageStats {
|
export interface AccountUsageStats {
|
||||||
@@ -189,3 +190,33 @@ export function useCliproxyErrorLogContent(name: string | null) {
|
|||||||
staleTime: 60000, // Cache log content for 1 minute
|
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;
|
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 */
|
/** Provider accounts summary */
|
||||||
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||||
|
|
||||||
@@ -404,4 +430,10 @@ export const api = {
|
|||||||
body: JSON.stringify(params),
|
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