mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 16:23:54 +00:00
Merge pull request #223 from kaitranntt/dev
feat(cliproxy): account quota integration with enhanced error log display
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.9.0",
|
||||
"version": "7.9.0-dev.1",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -112,6 +112,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,525 @@
|
||||
/**
|
||||
* 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;
|
||||
/** True if token is expired and needs re-auth */
|
||||
isExpired?: boolean;
|
||||
/** ISO timestamp when token expires/expired */
|
||||
expiresAt?: string;
|
||||
/** True if account hasn't been activated in official Antigravity app */
|
||||
isUnprovisioned?: boolean;
|
||||
}
|
||||
|
||||
/** Google Cloud Code API endpoints */
|
||||
const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
|
||||
const ANTIGRAVITY_API_VERSION = 'v1internal';
|
||||
|
||||
/** Google OAuth token endpoint */
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
/** Antigravity OAuth credentials (from CLIProxyAPIPlus - public in open-source code) */
|
||||
const ANTIGRAVITY_CLIENT_ID =
|
||||
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const ANTIGRAVITY_CLIENT_SECRET = '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';
|
||||
|
||||
/** Headers for loadCodeAssist (matches CLIProxyAPI antigravity.go) */
|
||||
const LOADCODEASSIST_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'google-api-nodejs-client/9.15.1',
|
||||
'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
|
||||
'Client-Metadata':
|
||||
'{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}',
|
||||
};
|
||||
|
||||
/** Headers for fetchAvailableModels (matches CLIProxyAPI antigravity_executor.go) */
|
||||
const FETCHMODELS_HEADERS = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'antigravity/1.104.0 darwin/arm64',
|
||||
};
|
||||
|
||||
/** Auth file structure */
|
||||
interface AntigravityAuthFile {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
email?: string;
|
||||
expired?: string;
|
||||
expires_in?: number;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
/** Auth data returned from file */
|
||||
interface AuthData {
|
||||
accessToken: string;
|
||||
refreshToken: string | null;
|
||||
projectId: string | null;
|
||||
isExpired: boolean;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
/** Token refresh response */
|
||||
interface TokenRefreshResponse {
|
||||
access_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
error?: string;
|
||||
error_description?: 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize email to match CLIProxyAPI auth file naming convention
|
||||
* Replaces @ and . with underscores (matches Go sanitizeAntigravityFileName)
|
||||
*/
|
||||
function sanitizeEmail(email: string): string {
|
||||
return email.replace(/@/g, '_').replace(/\./g, '_');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired based on the expired timestamp
|
||||
*/
|
||||
function isTokenExpired(expiredStr?: string): boolean {
|
||||
if (!expiredStr) return false;
|
||||
try {
|
||||
const expiredDate = new Date(expiredStr);
|
||||
return expiredDate.getTime() < Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh_token via Google OAuth
|
||||
* This allows CCS to get fresh tokens independently of CLIProxyAPI
|
||||
*/
|
||||
async function refreshAccessToken(
|
||||
refreshToken: string
|
||||
): Promise<{ accessToken: string | null; error?: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: ANTIGRAVITY_CLIENT_ID,
|
||||
client_secret: ANTIGRAVITY_CLIENT_SECRET,
|
||||
}).toString(),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const data = (await response.json()) as TokenRefreshResponse;
|
||||
|
||||
if (!response.ok || data.error) {
|
||||
return {
|
||||
accessToken: null,
|
||||
error: data.error_description || data.error || `OAuth error: ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.access_token) {
|
||||
return { accessToken: null, error: 'No access_token in response' };
|
||||
}
|
||||
|
||||
return { accessToken: data.access_token };
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { accessToken: null, error: 'Token refresh timeout' };
|
||||
}
|
||||
return { accessToken: null, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read auth data from auth file (access token, project_id, expiry status)
|
||||
*/
|
||||
function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | null {
|
||||
const authDir = getAuthDir();
|
||||
|
||||
// Check if auth directory exists
|
||||
if (!fs.existsSync(authDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sanitize accountId (email) to match auth file naming: @ and . → _
|
||||
const sanitizedId = sanitizeEmail(accountId);
|
||||
const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`;
|
||||
const expectedFile = `${prefix}${sanitizedId}.json`;
|
||||
const filePath = path.join(authDir, expectedFile);
|
||||
|
||||
// Direct file access (most common case)
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
if (!data.access_token) return null;
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan directory for matching email in file content
|
||||
const files = fs.readdirSync(authDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith(prefix) && file.endsWith('.json')) {
|
||||
const candidatePath = path.join(authDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const data = JSON.parse(content) as AntigravityAuthFile;
|
||||
// Match by email field inside the auth file
|
||||
if (data.email === accountId && data.access_token) {
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token || null,
|
||||
projectId: data.project_id || null,
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project ID via loadCodeAssist endpoint
|
||||
*/
|
||||
async function getProjectId(
|
||||
accessToken: string
|
||||
): Promise<{ projectId: string | null; error?: string; isUnprovisioned?: boolean }> {
|
||||
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: {
|
||||
...LOADCODEASSIST_HEADERS,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: 'IDE_UNSPECIFIED',
|
||||
platform: 'PLATFORM_UNSPECIFIED',
|
||||
pluginType: 'GEMINI',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
// Return specific error based on status
|
||||
if (response.status === 401) {
|
||||
return { projectId: null, error: 'Token expired or invalid' };
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return { projectId: null, error: 'Access forbidden' };
|
||||
}
|
||||
return { projectId: null, error: `API error: ${response.status}` };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (!projectId?.trim()) {
|
||||
// Account authenticated but not provisioned - user needs to sign in via Antigravity app
|
||||
return {
|
||||
projectId: null,
|
||||
error: 'Sign in to Antigravity app to activate quota.',
|
||||
isUnprovisioned: true,
|
||||
};
|
||||
}
|
||||
|
||||
return { projectId: projectId.trim() };
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { projectId: null, error: 'Request timeout' };
|
||||
}
|
||||
return { projectId: null, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models with quota info
|
||||
* Note: projectId is kept for potential future use but not sent in body
|
||||
* (CLIProxyAPI sends empty {} body for this endpoint)
|
||||
*/
|
||||
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 {
|
||||
// Match CLIProxyAPI exactly: empty body, minimal headers
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
...FETCHMODELS_HEADERS,
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// Skip invalid values (NaN, Infinity, non-numbers)
|
||||
if (typeof remaining !== 'number' || !isFinite(remaining)) continue;
|
||||
|
||||
// Convert to percentage (0-100) and clamp to valid range
|
||||
const percentage = Math.max(0, Math.min(100, 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)
|
||||
* @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 auth data from auth file
|
||||
const authData = readAuthData(provider, accountId);
|
||||
if (!authData) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: 'Auth file not found for account',
|
||||
};
|
||||
}
|
||||
|
||||
// Determine which access token to use
|
||||
// File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist)
|
||||
// Proactive refresh: refresh 5 minutes before expiry (matches CLIProxyAPIPlus behavior)
|
||||
let accessToken = authData.accessToken;
|
||||
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (authData.refreshToken) {
|
||||
const shouldRefresh =
|
||||
authData.isExpired || // Already expired
|
||||
!authData.expiresAt || // No expiry info - refresh to be safe
|
||||
new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; // Expiring soon
|
||||
|
||||
if (shouldRefresh) {
|
||||
const refreshResult = await refreshAccessToken(authData.refreshToken);
|
||||
if (refreshResult.accessToken) {
|
||||
accessToken = refreshResult.accessToken;
|
||||
}
|
||||
// If refresh fails, fall back to existing token (might still work)
|
||||
}
|
||||
}
|
||||
|
||||
// Get project ID - prefer stored value, fallback to API call
|
||||
let projectId = authData.projectId;
|
||||
if (!projectId) {
|
||||
let lastProjectResult = await getProjectId(accessToken);
|
||||
if (!lastProjectResult.projectId) {
|
||||
// If project ID fetch fails, it might be token issue - try refresh if we haven't
|
||||
if (authData.refreshToken && accessToken === authData.accessToken) {
|
||||
const refreshResult = await refreshAccessToken(authData.refreshToken);
|
||||
if (refreshResult.accessToken) {
|
||||
accessToken = refreshResult.accessToken;
|
||||
lastProjectResult = await getProjectId(accessToken);
|
||||
}
|
||||
}
|
||||
if (!lastProjectResult.projectId) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: lastProjectResult.error || 'Failed to retrieve project ID',
|
||||
isUnprovisioned: lastProjectResult.isUnprovisioned,
|
||||
};
|
||||
}
|
||||
}
|
||||
projectId = lastProjectResult.projectId;
|
||||
}
|
||||
|
||||
// Fetch models with quota
|
||||
const result = await fetchAvailableModels(accessToken, projectId);
|
||||
|
||||
// If quota fetch fails with auth error and we haven't refreshed yet, try refresh
|
||||
if (!result.success && result.error?.includes('expired') && authData.refreshToken) {
|
||||
const refreshResult = await refreshAccessToken(authData.refreshToken);
|
||||
if (refreshResult.accessToken) {
|
||||
return fetchAvailableModels(refreshResult.accessToken, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -297,6 +297,10 @@ export interface CliproxyErrorLog {
|
||||
modified: number;
|
||||
/** Absolute path to the log file (injected by backend) */
|
||||
absolutePath?: string;
|
||||
/** HTTP status code extracted from log (injected by backend) */
|
||||
statusCode?: number;
|
||||
/** Model name extracted from request body (injected by backend) */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Response from /v0/management/request-error-logs endpoint */
|
||||
|
||||
@@ -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,
|
||||
getCliproxyConfigPath,
|
||||
@@ -23,6 +25,48 @@ import { checkCliproxyUpdate } from '../../cliproxy/binary-manager';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Extract status code and model from error log file (lightweight parsing)
|
||||
* Reads first 4KB for model, last 2KB for status code
|
||||
*/
|
||||
async function extractErrorLogMetadata(
|
||||
filePath: string
|
||||
): Promise<{ statusCode?: number; model?: string }> {
|
||||
try {
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
const stat = fs.fstatSync(fd);
|
||||
const fileSize = stat.size;
|
||||
|
||||
// Read first 4KB for model (in request body)
|
||||
const startBuffer = Buffer.alloc(Math.min(4096, fileSize));
|
||||
fs.readSync(fd, startBuffer, 0, startBuffer.length, 0);
|
||||
const startContent = startBuffer.toString('utf-8');
|
||||
|
||||
// Extract model from request body JSON: "model":"gemini-3-flash-preview"
|
||||
const modelMatch = startContent.match(/"model"\s*:\s*"([^"]+)"/);
|
||||
const model = modelMatch ? modelMatch[1] : undefined;
|
||||
|
||||
// Read last 2KB for status code (in response section at end)
|
||||
let statusCode: number | undefined;
|
||||
if (fileSize > 2048) {
|
||||
const endBuffer = Buffer.alloc(2048);
|
||||
fs.readSync(fd, endBuffer, 0, 2048, fileSize - 2048);
|
||||
const endContent = endBuffer.toString('utf-8');
|
||||
const statusMatch = endContent.match(/Status:\s*(\d{3})/);
|
||||
statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
|
||||
} else {
|
||||
// Small file - check start content for status
|
||||
const statusMatch = startContent.match(/Status:\s*(\d{3})/);
|
||||
statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
|
||||
}
|
||||
|
||||
fs.closeSync(fd);
|
||||
return { statusCode, model };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared handler for stats/usage endpoint
|
||||
*/
|
||||
@@ -212,14 +256,22 @@ router.get('/error-logs', async (_req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject absolute paths into each file entry
|
||||
// Inject absolute paths and extract metadata from each file
|
||||
const logsDir = path.join(getCliproxyWritablePath(), 'logs');
|
||||
const filesWithPaths = files.map((file) => ({
|
||||
...file,
|
||||
absolutePath: path.join(logsDir, file.name),
|
||||
}));
|
||||
const filesWithMetadata = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const absolutePath = path.join(logsDir, file.name);
|
||||
const metadata = await extractErrorLogMetadata(absolutePath);
|
||||
return {
|
||||
...file,
|
||||
absolutePath,
|
||||
statusCode: metadata.statusCode,
|
||||
model: metadata.model,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json({ files: filesWithPaths });
|
||||
res.json({ files: filesWithMetadata });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
@@ -430,4 +482,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;
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* Account Card Component for Flow Visualization
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { GripVertical } from 'lucide-react';
|
||||
import { GripVertical, Loader2, Clock } from 'lucide-react';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import type { AccountData, DragOffset } from './types';
|
||||
import { cleanEmail } from './utils';
|
||||
@@ -74,6 +76,18 @@ export function AccountCard({
|
||||
const borderColor = getBorderColorStyle(zone, account.color);
|
||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
||||
|
||||
// Quota for AGY accounts
|
||||
const isAgy = account.provider === 'agy';
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
account.provider,
|
||||
account.id,
|
||||
isAgy
|
||||
);
|
||||
const avgQuota =
|
||||
quota?.success && quota.models.length > 0
|
||||
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-account-index={originalIndex}
|
||||
@@ -114,6 +128,82 @@ export function AccountCard({
|
||||
failure={account.failureCount}
|
||||
showDetails={showDetails}
|
||||
/>
|
||||
{/* Quota bar for AGY accounts */}
|
||||
{isAgy && (
|
||||
<div className="mt-2 px-0.5">
|
||||
{quotaLoading ? (
|
||||
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
<span>Quota...</span>
|
||||
</div>
|
||||
) : avgQuota !== null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="space-y-0.5 cursor-help">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||
Quota
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
avgQuota > 50
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: avgQuota > 20
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{avgQuota}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
avgQuota > 50
|
||||
? 'bg-emerald-500'
|
||||
: avgQuota > 20
|
||||
? 'bg-amber-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${avgQuota}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium">Model Quotas:</p>
|
||||
{sortModelsByPriority(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>
|
||||
))}
|
||||
{(() => {
|
||||
const resetTime = getEarliestResetTime(quota?.models || []);
|
||||
return resetTime ? (
|
||||
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.error ? (
|
||||
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
|
||||
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react';
|
||||
import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw, Clock } from 'lucide-react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
|
||||
@@ -19,7 +20,7 @@ interface CredentialRowProps {
|
||||
status: CredentialStatus;
|
||||
statusMessage: string;
|
||||
email?: string;
|
||||
expiresAt?: string;
|
||||
lastUsedAt?: string;
|
||||
onRefresh?: () => void;
|
||||
privacyMode?: boolean;
|
||||
}
|
||||
@@ -30,7 +31,7 @@ function CredentialRow({
|
||||
status,
|
||||
statusMessage,
|
||||
email,
|
||||
expiresAt,
|
||||
lastUsedAt,
|
||||
onRefresh,
|
||||
privacyMode,
|
||||
}: CredentialRowProps) {
|
||||
@@ -60,16 +61,23 @@ function CredentialRow({
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
const formatExpiry = (date?: string) => {
|
||||
if (!date) return 'Never';
|
||||
const expiry = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = expiry.getTime() - now.getTime();
|
||||
if (diff < 0) return 'Expired';
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours < 1) return 'Soon';
|
||||
if (hours < 24) return `${hours}h`;
|
||||
return `${Math.floor(hours / 24)}d`;
|
||||
const formatLastUsed = (date?: string) => {
|
||||
if (!date) return 'Never used';
|
||||
try {
|
||||
const lastUsed = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - lastUsed.getTime();
|
||||
if (diff < 0) return 'Just now';
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'Just now';
|
||||
} catch {
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -96,8 +104,9 @@ function CredentialRow({
|
||||
>
|
||||
{statusMessage}
|
||||
</Badge>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
Expires: {formatExpiry(expiresAt)}
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5 justify-end">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatLastUsed(lastUsedAt)}
|
||||
</div>
|
||||
</div>
|
||||
{status === 'warning' && onRefresh && (
|
||||
@@ -129,23 +138,28 @@ function CredentialHealthSkeleton() {
|
||||
|
||||
export function CredentialHealthList() {
|
||||
const { data: authData, isLoading } = useCliproxyAuth();
|
||||
const { data: stats } = useCliproxyStats(true);
|
||||
const { privacyMode } = usePrivacy();
|
||||
|
||||
if (isLoading) {
|
||||
return <CredentialHealthSkeleton />;
|
||||
}
|
||||
|
||||
// Flatten accounts from all providers
|
||||
// Flatten accounts from all providers with runtime lastUsedAt
|
||||
const credentials =
|
||||
authData?.authStatus.flatMap((status) =>
|
||||
(status.accounts ?? []).map((account) => ({
|
||||
name: account.id,
|
||||
provider: status.provider,
|
||||
status: (account as { status?: CredentialStatus }).status ?? 'ready',
|
||||
statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready',
|
||||
email: account.email,
|
||||
expiresAt: (account as { expiresAt?: string }).expiresAt,
|
||||
}))
|
||||
(status.accounts ?? []).map((account) => {
|
||||
const accountKey = account.email || account.id;
|
||||
const runtimeLastUsed = stats?.accountStats?.[accountKey]?.lastUsedAt;
|
||||
return {
|
||||
name: account.id,
|
||||
provider: status.provider,
|
||||
status: (account as { status?: CredentialStatus }).status ?? 'ready',
|
||||
statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready',
|
||||
email: account.email,
|
||||
lastUsedAt: runtimeLastUsed || account.lastUsedAt,
|
||||
};
|
||||
})
|
||||
) ?? [];
|
||||
|
||||
if (credentials.length === 0) {
|
||||
|
||||
@@ -1,88 +1,249 @@
|
||||
/**
|
||||
* 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 { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
User,
|
||||
Star,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import type { AccountItemProps } from './types';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "5m ago", "2h ago")
|
||||
*/
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 0) return 'just now';
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account was used recently (within last hour = token likely refreshed)
|
||||
*/
|
||||
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
|
||||
if (!lastUsedAt) return false;
|
||||
try {
|
||||
const lastUsed = new Date(lastUsedAt);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - lastUsed.getTime();
|
||||
return diff < 60 * 60 * 1000; // Within last hour
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
}: AccountItemProps) {
|
||||
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
|
||||
const { data: stats } = useCliproxyStats(showQuota && account.provider === 'agy');
|
||||
|
||||
// Fetch quota for 'agy' provider accounts
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
account.provider,
|
||||
account.id,
|
||||
showQuota && account.provider === 'agy'
|
||||
);
|
||||
|
||||
// Get last used time from runtime stats (more accurate than file)
|
||||
const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt;
|
||||
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
||||
|
||||
// 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 ? getEarliestResetTime(quota.models) : 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 ? (
|
||||
<div className="space-y-1.5">
|
||||
{/* Status indicator based on runtime usage, not file state */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{wasRecentlyUsed ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
Active · {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : runtimeLastUsed ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Last used {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HelpCircle className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Not used yet</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Quota bar */}
|
||||
<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>
|
||||
{sortModelsByPriority(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 && (
|
||||
<div className="flex items-center gap-1.5 mt-2 pt-2 border-t border-border/50">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
Resets {formatResetTime(nextReset)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : quota?.error ? (
|
||||
<div className="text-xs text-muted-foreground">{quota.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ interface AccountsSectionProps {
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Show quota bars for accounts (only applicable for 'agy' provider) */
|
||||
showQuota?: boolean;
|
||||
/** Kiro-specific: show "use normal browser" toggle */
|
||||
isKiro?: boolean;
|
||||
kiroNoIncognito?: boolean;
|
||||
@@ -31,6 +33,7 @@ export function AccountsSection({
|
||||
onRemoveAccount,
|
||||
isRemovingAccount,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
isKiro,
|
||||
kiroNoIncognito,
|
||||
onKiroNoIncognitoChange,
|
||||
@@ -64,6 +67,7 @@ export function AccountsSection({
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
isRemoving={isRemovingAccount}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={showQuota}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,7 @@ export function ModelConfigTab({
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={provider === 'agy'}
|
||||
isKiro={isKiro}
|
||||
kiroNoIncognito={kiroNoIncognito}
|
||||
onKiroNoIncognitoChange={saveKiroNoIncognito}
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface AccountItemProps {
|
||||
onRemove: () => void;
|
||||
isRemoving?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Show quota bar (only for 'agy' provider) */
|
||||
showQuota?: boolean;
|
||||
}
|
||||
|
||||
export interface ModelMappingValues {
|
||||
|
||||
@@ -1,71 +1,86 @@
|
||||
/**
|
||||
* Error Log Item Component
|
||||
* Individual log entry in the list view
|
||||
* Individual log entry in the list view - shows status code, model, endpoint, time
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Clock, FileText } from 'lucide-react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||
import { parseFilename, formatRelativeTime, formatBytes } from '@/lib/error-log-parser';
|
||||
import { parseFilename, formatRelativeTime } from '@/lib/error-log-parser';
|
||||
import type { ErrorLogItemProps } from './types';
|
||||
|
||||
export function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) {
|
||||
/** Get status badge color based on HTTP status code */
|
||||
function getStatusColor(code?: number): string {
|
||||
if (!code) return 'bg-gray-100 text-gray-600';
|
||||
if (code === 429) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
||||
if (code >= 500) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
if (code >= 400)
|
||||
return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
|
||||
/** Format model name for display (full name) */
|
||||
function formatModel(model?: string): string {
|
||||
if (!model) return '';
|
||||
return model;
|
||||
}
|
||||
|
||||
export function ErrorLogItem({
|
||||
name,
|
||||
size: _size,
|
||||
modified,
|
||||
isSelected,
|
||||
onClick,
|
||||
statusCode,
|
||||
model,
|
||||
}: ErrorLogItemProps) {
|
||||
const parsed = useMemo(() => parseFilename(name), [name]);
|
||||
const displayModel = useMemo(() => formatModel(model), [model]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'w-full px-3 py-2.5 flex items-start gap-3 text-left transition-colors',
|
||||
'w-full px-2.5 py-2 flex items-center gap-2 text-left transition-colors',
|
||||
'hover:bg-muted/40 border-l-[3px]',
|
||||
isSelected ? 'bg-muted/50 border-l-amber-500' : 'border-l-transparent'
|
||||
isSelected ? 'bg-muted/50 border-l-red-500' : 'border-l-transparent'
|
||||
)}
|
||||
>
|
||||
{/* Status Badge - prominent */}
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded min-w-[32px] text-center',
|
||||
getStatusColor(statusCode)
|
||||
)}
|
||||
>
|
||||
{statusCode || '???'}
|
||||
</span>
|
||||
|
||||
{/* Provider Icon */}
|
||||
<ProviderIcon
|
||||
provider={parsed.provider}
|
||||
size={24}
|
||||
size={18}
|
||||
withBackground={true}
|
||||
className="shrink-0 mt-0.5"
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
{/* Provider / Endpoint */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-foreground truncate">
|
||||
{parsed.provider}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[9px] px-1 rounded border',
|
||||
isSelected
|
||||
? 'bg-amber-500/10 text-amber-600 border-amber-500/20'
|
||||
: 'bg-muted border-border text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
LOG
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] text-muted-foreground truncate font-medium"
|
||||
title={parsed.endpoint}
|
||||
>
|
||||
{parsed.endpoint}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Meta row: time + size */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground/80 mt-1">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatRelativeTime(modified)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText className="w-3 h-3" />
|
||||
{formatBytes(size)}
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
{/* Row 1: Endpoint */}
|
||||
<span className="text-[11px] font-medium text-foreground truncate">
|
||||
{parsed.endpoint || 'unknown'}
|
||||
</span>
|
||||
{/* Row 2: Model (full name) */}
|
||||
{displayModel && (
|
||||
<span className="text-[10px] text-muted-foreground truncate" title={displayModel}>
|
||||
{displayModel}
|
||||
</span>
|
||||
)}
|
||||
{/* Row 3: Time */}
|
||||
<div className="flex items-center gap-1 text-[9px] text-muted-foreground/60">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
<span>{formatRelativeTime(modified)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -168,6 +168,8 @@ export function ErrorLogsMonitor() {
|
||||
modified={log.modified}
|
||||
isSelected={effectiveSelection === log.name}
|
||||
onClick={() => setSelectedLog(log.name)}
|
||||
statusCode={log.statusCode}
|
||||
model={log.model}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,79 +4,151 @@
|
||||
*/
|
||||
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getErrorTypeLabel, type ParsedErrorLog } from '@/lib/error-log-parser';
|
||||
import {
|
||||
getErrorTypeLabel,
|
||||
formatQuotaResetDelay,
|
||||
formatQuotaResetTimestamp,
|
||||
type ParsedErrorLog,
|
||||
} from '@/lib/error-log-parser';
|
||||
import { StatusBadge } from './ui-primitives';
|
||||
|
||||
/** Overview tab content */
|
||||
export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
|
||||
const quotaResetDisplay =
|
||||
formatQuotaResetDelay(parsed.quotaResetDelay) ||
|
||||
formatQuotaResetTimestamp(parsed.quotaResetTimestamp);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Status row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge code={parsed.statusCode} />
|
||||
<span className="text-sm font-medium">{parsed.statusText}</span>
|
||||
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
|
||||
{getErrorTypeLabel(parsed.errorType)}
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Status row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge code={parsed.statusCode} />
|
||||
<span className="text-sm font-medium">{parsed.statusText}</span>
|
||||
<span className="text-xs text-muted-foreground px-2 py-0.5 rounded bg-muted/50">
|
||||
{getErrorTypeLabel(parsed.errorType)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Key metrics grid */}
|
||||
<div className="grid grid-cols-4 gap-3 text-xs">
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Method</div>
|
||||
<div className="font-medium">{parsed.method || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Provider</div>
|
||||
<div className="font-medium">{parsed.provider || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Version</div>
|
||||
<div className="font-medium">{parsed.version || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Endpoint</div>
|
||||
<div className="font-medium truncate" title={parsed.endpoint}>
|
||||
{parsed.endpoint || 'N/A'}
|
||||
{/* Model info - prominent display */}
|
||||
{parsed.model && (
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-violet-500/10 border border-violet-500/20">
|
||||
<Cpu className="w-4 h-4 text-violet-500 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Model: </span>
|
||||
<span className="font-semibold text-violet-600 dark:text-violet-400">
|
||||
{parsed.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quota reset info for 429 errors */}
|
||||
{parsed.errorType === 'rate_limit' && quotaResetDisplay && (
|
||||
<div className="flex items-center gap-2.5 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<Clock className="w-4 h-4 text-amber-500 shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">Quota resets in </span>
|
||||
<span className="font-semibold text-amber-600 dark:text-amber-400">
|
||||
{quotaResetDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key metrics grid */}
|
||||
<div className="grid grid-cols-4 gap-3 text-xs">
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Method</div>
|
||||
<div className="font-medium">{parsed.method || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Provider</div>
|
||||
<div className="font-medium">{parsed.provider || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Version</div>
|
||||
<div className="font-medium">{parsed.version || 'N/A'}</div>
|
||||
</div>
|
||||
<div className="p-2.5 rounded bg-muted/30 border border-border/50">
|
||||
<div className="text-muted-foreground mb-1">Endpoint</div>
|
||||
<div className="font-medium truncate" title={parsed.endpoint}>
|
||||
{parsed.endpoint || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL */}
|
||||
<div className="text-xs">
|
||||
<div className="text-muted-foreground mb-1.5">URL</div>
|
||||
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
|
||||
{parsed.url || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs">
|
||||
<div className="text-muted-foreground mb-1.5">Timestamp</div>
|
||||
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestion based on error type */}
|
||||
{parsed.errorType !== 'unknown' && (
|
||||
<div className="flex items-start gap-3 p-3 rounded bg-blue-500/10 border border-blue-500/20 text-xs">
|
||||
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
|
||||
<div className="text-blue-500/90 leading-relaxed">
|
||||
{parsed.errorType === 'rate_limit' &&
|
||||
'Rate limited. Consider using multiple accounts or reducing request frequency.'}
|
||||
{parsed.errorType === 'auth' &&
|
||||
'Authentication failed. Check credentials or re-authenticate with the provider.'}
|
||||
{parsed.errorType === 'not_found' &&
|
||||
'Endpoint not found. This endpoint may not exist on this provider.'}
|
||||
{parsed.errorType === 'server' &&
|
||||
'Server error from upstream. Retry or check provider status.'}
|
||||
{parsed.errorType === 'timeout' &&
|
||||
'Request timed out. Check network or increase timeout settings.'}
|
||||
{/* URL */}
|
||||
<div className="text-xs">
|
||||
<div className="text-muted-foreground mb-1.5">URL</div>
|
||||
<div className="font-mono p-2.5 rounded bg-muted/30 border border-border/50 break-all leading-relaxed">
|
||||
{parsed.url || 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs">
|
||||
<div className="text-muted-foreground mb-1.5">Timestamp</div>
|
||||
<div className="font-mono">{parsed.timestamp || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
{/* Actionable suggestion based on error type */}
|
||||
{parsed.errorType !== 'unknown' && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 p-3 rounded text-xs',
|
||||
parsed.errorType === 'rate_limit'
|
||||
? 'bg-amber-500/10 border border-amber-500/20'
|
||||
: parsed.errorType === 'auth'
|
||||
? 'bg-red-500/10 border border-red-500/20'
|
||||
: 'bg-blue-500/10 border border-blue-500/20'
|
||||
)}
|
||||
>
|
||||
{parsed.errorType === 'rate_limit' ? (
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
|
||||
) : parsed.errorType === 'auth' ? (
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 text-red-500 shrink-0" />
|
||||
) : (
|
||||
<Info className="w-4 h-4 mt-0.5 text-blue-500 shrink-0" />
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'leading-relaxed',
|
||||
parsed.errorType === 'rate_limit'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: parsed.errorType === 'auth'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{parsed.errorType === 'rate_limit' && (
|
||||
<>
|
||||
<strong>Rate Limited.</strong> Switch to a different account or wait for quota
|
||||
reset.
|
||||
{parsed.model && (
|
||||
<>
|
||||
{' '}
|
||||
Model <code className="font-mono text-[11px]">{parsed.model}</code> has
|
||||
exhausted quota.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{parsed.errorType === 'auth' &&
|
||||
'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.'}
|
||||
{parsed.errorType === 'not_found' &&
|
||||
'Endpoint not found. This endpoint may not exist on this provider.'}
|
||||
{parsed.errorType === 'server' &&
|
||||
'Server error from upstream. Retry later or check provider status page.'}
|
||||
{parsed.errorType === 'timeout' &&
|
||||
'Request timed out. Check network connection or increase timeout settings.'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ export interface ErrorLogItemProps {
|
||||
modified: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
/** HTTP status code from pre-parsed metadata */
|
||||
statusCode?: number;
|
||||
/** Model name from pre-parsed metadata */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface LogContentPanelProps {
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* OAuth Auth Flow Hook for CLIProxy
|
||||
* Manages popup-based OAuth authentication flows
|
||||
* Triggers backend-managed OAuth authentication flows
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||
@@ -13,19 +13,7 @@ interface AuthFlowState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const AUTH_ENDPOINTS: Record<string, string> = {
|
||||
claude: '/anthropic-auth-url',
|
||||
gemini: '/gemini-cli-auth-url',
|
||||
codex: '/codex-auth-url',
|
||||
agy: '/antigravity-auth-url',
|
||||
qwen: '/qwen-auth-url',
|
||||
iflow: '/iflow-auth-url',
|
||||
kiro: '/kiro-auth-url',
|
||||
ghcp: '/ghcp-auth-url',
|
||||
};
|
||||
|
||||
const AUTH_TIMEOUT_MS = 300000; // 5 minutes
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const VALID_PROVIDERS = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp'];
|
||||
|
||||
export function useCliproxyAuthFlow() {
|
||||
const [state, setState] = useState<AuthFlowState>({
|
||||
@@ -34,36 +22,19 @@ export function useCliproxyAuthFlow() {
|
||||
error: null,
|
||||
});
|
||||
|
||||
const popupRef = useRef<Window | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
clearInterval(pollIntervalRef.current);
|
||||
pollIntervalRef.current = null;
|
||||
}
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
if (popupRef.current && !popupRef.current.closed) {
|
||||
popupRef.current.close();
|
||||
}
|
||||
popupRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => cleanup();
|
||||
}, [cleanup]);
|
||||
return () => {
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startAuth = useCallback(
|
||||
async (provider: string) => {
|
||||
const endpoint = AUTH_ENDPOINTS[provider];
|
||||
if (!endpoint) {
|
||||
if (!VALID_PROVIDERS.includes(provider)) {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
@@ -72,117 +43,48 @@ export function useCliproxyAuthFlow() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any in-progress auth
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setState({ provider, isAuthenticating: true, error: null });
|
||||
|
||||
try {
|
||||
// Get auth URL from API
|
||||
const response = await fetch(`/api/cliproxy${endpoint}?is_webui=true`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get auth URL: ${response.statusText}`);
|
||||
}
|
||||
// POST to CCS auth endpoint - backend opens browser and waits
|
||||
const response = await fetch(`/api/cliproxy/auth/${provider}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const { url, state: authState } = data;
|
||||
|
||||
if (!url) {
|
||||
throw new Error('No auth URL returned from server');
|
||||
if (response.ok && data.success) {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['account-quota'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
} else {
|
||||
throw new Error(data.error || 'Authentication failed');
|
||||
}
|
||||
|
||||
// Open popup
|
||||
const popup = window.open(url, `${provider}_auth`, 'width=600,height=700,popup=yes');
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('Popup blocked. Please allow popups for this site.');
|
||||
}
|
||||
|
||||
popupRef.current = popup;
|
||||
|
||||
// Poll for completion
|
||||
pollIntervalRef.current = setInterval(async () => {
|
||||
// Check if popup was closed by user
|
||||
if (popup.closed) {
|
||||
cleanup();
|
||||
// Check final status
|
||||
try {
|
||||
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
if (statusData.status === 'ok') {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
} else if (statusData.status === 'error') {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: statusData.error || 'Authentication failed',
|
||||
});
|
||||
} else {
|
||||
// User closed popup before completing
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Authentication cancelled',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Failed to check auth status',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll status while popup is open
|
||||
try {
|
||||
const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`);
|
||||
const statusData = await statusRes.json();
|
||||
|
||||
if (statusData.status === 'ok') {
|
||||
cleanup();
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success(`${provider} authentication successful`);
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
} else if (statusData.status === 'error') {
|
||||
cleanup();
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: statusData.error || 'Authentication failed',
|
||||
});
|
||||
}
|
||||
// 'wait' status means keep polling
|
||||
} catch {
|
||||
// Silently ignore polling errors, will retry
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
// Timeout after 5 minutes
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
cleanup();
|
||||
toast.error('Authentication timed out');
|
||||
setState({
|
||||
provider: null,
|
||||
isAuthenticating: false,
|
||||
error: 'Authentication timed out',
|
||||
});
|
||||
}, AUTH_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
cleanup();
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Authentication failed';
|
||||
toast.error(message);
|
||||
setState({ provider: null, isAuthenticating: false, error: message });
|
||||
}
|
||||
},
|
||||
[cleanup, queryClient]
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const cancelAuth = useCallback(() => {
|
||||
cleanup();
|
||||
abortControllerRef.current?.abort();
|
||||
setState({ provider: null, isAuthenticating: false, error: null });
|
||||
}, [cleanup]);
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -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 {
|
||||
@@ -138,6 +139,10 @@ export interface CliproxyErrorLog {
|
||||
modified: number;
|
||||
/** Absolute path to the log file (injected by backend) */
|
||||
absolutePath?: string;
|
||||
/** HTTP status code extracted from log (injected by backend) */
|
||||
statusCode?: number;
|
||||
/** Model name extracted from request body (injected by backend) */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,3 +194,39 @@ 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) {
|
||||
let message = 'Failed to fetch quota';
|
||||
try {
|
||||
const error = await response.json();
|
||||
message = error.message || message;
|
||||
} catch {
|
||||
// Use default message if response isn't JSON
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
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: 30000, // Consider stale after 30s (tokens can refresh anytime)
|
||||
refetchInterval: 60000, // Refresh every 1 minute
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,6 +109,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[]>;
|
||||
|
||||
@@ -414,4 +440,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)}`),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,11 @@ export interface ParsedErrorLog {
|
||||
isClientError: boolean;
|
||||
isServerError: boolean;
|
||||
errorType: 'rate_limit' | 'auth' | 'not_found' | 'server' | 'timeout' | 'unknown';
|
||||
|
||||
// Extracted from request/response bodies
|
||||
model: string | null;
|
||||
quotaResetDelay: number | null; // seconds until reset
|
||||
quotaResetTimestamp: string | null; // ISO timestamp when quota resets
|
||||
}
|
||||
|
||||
/** Parsed filename metadata */
|
||||
@@ -57,9 +62,10 @@ export function parseFilename(name: string): ParsedFilename {
|
||||
result.provider = providerMatch[1];
|
||||
}
|
||||
|
||||
// Extract endpoint from after provider: ...-api-{ENDPOINT}-{timestamp}
|
||||
// Example: error-api-provider-agy-api-event_logging-batch-2025-12-18T185041-...
|
||||
const endpointMatch = name.match(/-api-([a-z_]+(?:-[a-z_]+)*)-\d{4}-\d{2}-\d{2}T/i);
|
||||
// Extract endpoint: after provider, before timestamp
|
||||
// Format: error-api-provider-{provider}-{endpoint}-{timestamp}-{id}.log
|
||||
// Example: error-api-provider-agy-v1-messages-2025-12-29T105823-a12b73f8.log
|
||||
const endpointMatch = name.match(/error-api-provider-[^-]+-(.+?)-\d{4}-\d{2}-\d{2}T/);
|
||||
if (endpointMatch) {
|
||||
result.endpoint = endpointMatch[1].replace(/-/g, '/');
|
||||
}
|
||||
@@ -96,6 +102,9 @@ export function parseErrorLog(content: string): ParsedErrorLog {
|
||||
isClientError: false,
|
||||
isServerError: false,
|
||||
errorType: 'unknown',
|
||||
model: null,
|
||||
quotaResetDelay: null,
|
||||
quotaResetTimestamp: null,
|
||||
};
|
||||
|
||||
// Split into sections
|
||||
@@ -114,6 +123,10 @@ export function parseErrorLog(content: string): ParsedErrorLog {
|
||||
} else if (part === 'REQUEST BODY') {
|
||||
currentSection = 'request_body';
|
||||
continue;
|
||||
} else if (part === 'API RESPONSE') {
|
||||
// Skip API RESPONSE section - we parse the actual RESPONSE section instead
|
||||
currentSection = '';
|
||||
continue;
|
||||
} else if (part === 'RESPONSE') {
|
||||
currentSection = 'response';
|
||||
continue;
|
||||
@@ -229,8 +242,9 @@ function computeDerivedFields(result: ParsedErrorLog): void {
|
||||
result.provider = providerMatch[1];
|
||||
}
|
||||
|
||||
// Extract endpoint from URL
|
||||
const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/api\/(.+)/);
|
||||
// Extract endpoint from URL: /api/provider/{provider}/{version}/{endpoint}
|
||||
// e.g., /api/provider/agy/v1/messages?beta=true → v1/messages
|
||||
const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/(.+?)(?:\?|$)/);
|
||||
if (endpointMatch) {
|
||||
result.endpoint = endpointMatch[1];
|
||||
}
|
||||
@@ -251,6 +265,113 @@ function computeDerivedFields(result: ParsedErrorLog): void {
|
||||
} else if (result.statusCode === 408 || result.statusCode === 504) {
|
||||
result.errorType = 'timeout';
|
||||
}
|
||||
|
||||
// Extract model from request body
|
||||
extractModelFromRequestBody(result);
|
||||
|
||||
// Extract quota reset info from response body (for 429 errors)
|
||||
if (result.statusCode === 429) {
|
||||
extractQuotaResetInfo(result);
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract model name from request body JSON */
|
||||
function extractModelFromRequestBody(result: ParsedErrorLog): void {
|
||||
if (!result.requestBody) return;
|
||||
try {
|
||||
const body = JSON.parse(result.requestBody);
|
||||
if (typeof body.model === 'string') {
|
||||
result.model = body.model;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract quota reset info from 429 response body */
|
||||
function extractQuotaResetInfo(result: ParsedErrorLog): void {
|
||||
if (!result.responseBody) return;
|
||||
try {
|
||||
const body = JSON.parse(result.responseBody);
|
||||
// Look for quotaResetDelay in various response formats
|
||||
// Format 1: { error: { details: [{ quotaResetDelay: "123s" }] } }
|
||||
// Format 2: { error: { quotaResetDelay: 123 } }
|
||||
// Format 3: { quotaResetDelay: 123, quotaResetTimeStamp: "..." }
|
||||
const delay = findQuotaResetDelay(body);
|
||||
if (delay !== null) {
|
||||
result.quotaResetDelay = delay;
|
||||
}
|
||||
const timestamp = findQuotaResetTimestamp(body);
|
||||
if (timestamp) {
|
||||
result.quotaResetTimestamp = timestamp;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively find quotaResetDelay in response object */
|
||||
function findQuotaResetDelay(obj: unknown): number | null {
|
||||
if (typeof obj !== 'object' || obj === null) return null;
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
// Check direct properties
|
||||
if ('quotaResetDelay' in record) {
|
||||
const val = record.quotaResetDelay;
|
||||
if (typeof val === 'number') return val;
|
||||
if (typeof val === 'string') {
|
||||
// Handle "123s" format
|
||||
const match = val.match(/^(\d+)s?$/);
|
||||
if (match) return parseInt(match[1], 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested error object
|
||||
if ('error' in record && typeof record.error === 'object') {
|
||||
const found = findQuotaResetDelay(record.error);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
|
||||
// Check details array
|
||||
if ('details' in record && Array.isArray(record.details)) {
|
||||
for (const detail of record.details) {
|
||||
const found = findQuotaResetDelay(detail);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Recursively find quotaResetTimeStamp in response object */
|
||||
function findQuotaResetTimestamp(obj: unknown): string | null {
|
||||
if (typeof obj !== 'object' || obj === null) return null;
|
||||
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
// Check direct properties (various casing)
|
||||
for (const key of ['quotaResetTimeStamp', 'quotaResetTimestamp', 'resetTime', 'reset_time']) {
|
||||
if (key in record && typeof record[key] === 'string') {
|
||||
return record[key] as string;
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested error object
|
||||
if ('error' in record && typeof record.error === 'object') {
|
||||
const found = findQuotaResetTimestamp(record.error);
|
||||
if (found) return found;
|
||||
}
|
||||
|
||||
// Check details array
|
||||
if ('details' in record && Array.isArray(record.details)) {
|
||||
for (const detail of record.details) {
|
||||
const found = findQuotaResetTimestamp(detail);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Get status text for common codes */
|
||||
@@ -326,3 +447,44 @@ export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string {
|
||||
};
|
||||
return labels[type] || 'Error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format quota reset delay as human-readable string
|
||||
*/
|
||||
export function formatQuotaResetDelay(seconds: number | null): string | null {
|
||||
if (seconds === null || seconds <= 0) return null;
|
||||
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
return `${mins}m`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format quota reset timestamp as relative time
|
||||
*/
|
||||
export function formatQuotaResetTimestamp(timestamp: string | null): string | null {
|
||||
if (!timestamp) return null;
|
||||
try {
|
||||
const resetDate = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = resetDate.getTime() - now.getTime();
|
||||
if (diff <= 0) return 'now';
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
return `${mins}m`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,3 +54,65 @@ export function getProviderColor(provider: string): string {
|
||||
const normalized = provider.toLowerCase();
|
||||
return PROVIDER_COLORS[normalized] || getModelColor(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort models with Claude models first, then alphabetically
|
||||
* Prioritizes: Claude > Gemini > GPT > Other (alphabetically)
|
||||
*/
|
||||
export function sortModelsByPriority<T extends { name: string; displayName?: string }>(
|
||||
models: T[]
|
||||
): T[] {
|
||||
const getPriority = (model: T): number => {
|
||||
const name = (model.displayName || model.name).toLowerCase();
|
||||
if (name.includes('claude')) return 0;
|
||||
if (name.includes('gemini')) return 1;
|
||||
if (name.includes('gpt')) return 2;
|
||||
return 3;
|
||||
};
|
||||
|
||||
return [...models].sort((a, b) => {
|
||||
const priorityDiff = getPriority(a) - getPriority(b);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
// Same priority: sort alphabetically by display name
|
||||
const nameA = (a.displayName || a.name).toLowerCase();
|
||||
const nameB = (b.displayName || b.name).toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as relative time (e.g., "in 2h 30m")
|
||||
*/
|
||||
export 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 earliest reset time from models array
|
||||
*/
|
||||
export function getEarliestResetTime<T extends { resetTime: string | null }>(
|
||||
models: T[]
|
||||
): string | null {
|
||||
return 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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user