Merge pull request #860 from kaitranntt/kai/feat/codex-duplicate-email-account-support-refresh

feat: support duplicate-email Codex accounts
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-30 15:50:21 -04:00
commit b923b214eb
36 files changed
+2396 -909

No files matched your search

+13 -1
View File
@@ -1,6 +1,6 @@
# Provider Integration Flows
Last Updated: 2026-02-16
Last Updated: 2026-03-30
Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication.
@@ -76,6 +76,18 @@ CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus |
| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus |
### Codex Duplicate-Email Account Identity
Codex can legitimately produce multiple auth files for the same email when the user has both a team/business login and a personal/free login. CCS now treats those as separate accounts instead of collapsing them by email.
- Internal account IDs stay duplicate-aware for Codex only: `email#variant`
- Variant keys are derived from the auth filename, for example `kaidu.kd@gmail.com#04a0f049-team` and `kaidu.kd@gmail.com#free`
- Dashboard surfaces continue to show the canonical email, with a compact variant badge such as `Team` or `Free`
- Quota fetch resolves the exact registry `tokenFile` for the selected account instead of scanning by email and taking the first match
- Live usage/account monitor stats key by `provider + account identity`, so duplicate Codex emails no longer merge into one runtime bucket
This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model.
### Hardcoded Provider Detection
CCS detects hardcoded providers via `profile-detector.ts` and routes through `execClaudeWithCLIProxy()`.
@@ -0,0 +1,152 @@
import type { CLIProxyProvider } from '../types';
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
// separate because the browser bundle cannot import this server module directly.
function normalizeProvider(provider: CLIProxyProvider | string): string {
return provider.trim().toLowerCase();
}
function cleanVariantTokenPart(value: string): string {
return value
.trim()
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/-+/g, '-')
.toLowerCase();
}
function baseNicknameFromEmail(email?: string): string {
if (!email) return 'default';
return email.split('@')[0].replace(/\s+/g, '').slice(0, 50) || 'default';
}
function formatVariantPart(value: string): string {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return '';
}
switch (normalized) {
case 'team':
return 'Team';
case 'free':
return 'Free';
case 'plus':
return 'Plus';
case 'pro':
return 'Pro';
default:
return /^[a-f0-9]{8}$/i.test(normalized)
? normalized
: normalized
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part[0]?.toUpperCase() + part.slice(1))
.join(' ');
}
}
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
}
export function extractCanonicalEmailFromAccountId(accountId: string): string | null {
const canonical = accountId.split('#')[0]?.trim();
return canonical && canonical.includes('@') ? canonical : null;
}
export function extractEmailAccountVariantKey(
provider: CLIProxyProvider | string,
tokenFile: string,
email?: string
): string | null {
if (!email || !supportsDuplicateEmailAccounts(provider)) {
return null;
}
const normalizedProvider = normalizeProvider(provider);
const baseName = tokenFile.replace(/\.json$/i, '');
const providerPrefix = `${normalizedProvider}-`;
const candidate = baseName.toLowerCase().startsWith(providerPrefix)
? baseName.slice(providerPrefix.length)
: baseName;
const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase());
if (emailIndex === -1) {
const fallback = cleanVariantTokenPart(candidate);
return fallback && fallback !== cleanVariantTokenPart(email) ? fallback : null;
}
const before = cleanVariantTokenPart(candidate.slice(0, emailIndex));
const after = cleanVariantTokenPart(candidate.slice(emailIndex + email.length));
const parts = [before, after].filter(Boolean);
return parts.length > 0 ? parts.join('-') : null;
}
export function buildEmailBackedAccountId(
provider: CLIProxyProvider | string,
tokenFile: string,
email?: string,
duplicateEmailCount = 1
): string {
if (!email) {
return 'default';
}
if (!supportsDuplicateEmailAccounts(provider) || duplicateEmailCount <= 1) {
return email;
}
const variantKey = extractEmailAccountVariantKey(provider, tokenFile, email);
return variantKey ? `${email}#${variantKey}` : email;
}
export function buildEmailBackedNickname(
provider: CLIProxyProvider | string,
tokenFile: string,
email?: string,
duplicateEmailCount = 1
): string {
const base = baseNicknameFromEmail(email);
if (!supportsDuplicateEmailAccounts(provider) || duplicateEmailCount <= 1) {
return base;
}
const variantKey = extractEmailAccountVariantKey(provider, tokenFile, email);
if (!variantKey) {
return base;
}
return `${base}-${variantKey}`.slice(0, 50);
}
export function formatAccountVariantLabel(accountId: string, email?: string): string | null {
const variantKey =
extractCanonicalEmailFromAccountId(accountId) === email ? accountId.split('#')[1] : null;
if (!variantKey) {
return null;
}
const parts = variantKey.split('-').filter(Boolean);
if (parts.length === 0) {
return null;
}
const suffix = parts[parts.length - 1]?.toLowerCase();
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
.filter(Boolean)
.join(' · ');
}
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
}
export function formatAccountDisplayName(account: { id: string; email?: string }): string {
const base = account.email || account.id;
const variantLabel = formatAccountVariantLabel(account.id, account.email);
return variantLabel ? `${base} (${variantLabel})` : base;
}
+13 -11
View File
@@ -6,7 +6,7 @@
import { CLIProxyProvider } from '../types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { AccountInfo } from './types';
import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
import { hydrateRegistryFromTokenFiles, loadAccountsRegistry } from './registry';
/**
* Get all accounts for a provider
@@ -14,8 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
const registry = loadAccountsRegistry();
// Sync in-memory view with actual token files without mutating disk on read.
syncRegistryWithTokenFiles(registry);
// Hydrate the in-memory view from token files without mutating disk on read.
hydrateRegistryFromTokenFiles(registry);
const providerAccounts = registry.providers[provider];
@@ -55,14 +55,16 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
const accounts = getProviderAccounts(provider);
const lowerQuery = query.toLowerCase();
// Exact match first (id, email, nickname)
const exactMatch = accounts.find(
(a) =>
a.id === query ||
a.email?.toLowerCase() === lowerQuery ||
a.nickname?.toLowerCase() === lowerQuery
);
if (exactMatch) return exactMatch;
const exactIdMatch = accounts.find((a) => a.id === query);
if (exactIdMatch) return exactIdMatch;
const emailMatches = accounts.filter((a) => a.email?.toLowerCase() === lowerQuery);
if (emailMatches.length === 1) return emailMatches[0];
if (emailMatches.length > 1) return null;
const nicknameMatches = accounts.filter((a) => a.nickname?.toLowerCase() === lowerQuery);
if (nicknameMatches.length === 1) return nicknameMatches[0];
if (nicknameMatches.length > 1) return null;
// Partial match on nickname or email prefix
const partialMatches = accounts.filter(
+199 -55
View File
@@ -23,6 +23,7 @@ import {
deleteTokenFile,
listRecoverableTokenFiles,
} from './token-file-ops';
import { buildEmailBackedAccountId, buildEmailBackedNickname } from './email-account-identity';
/** Default registry structure */
function createDefaultRegistry(): AccountsRegistry {
@@ -90,6 +91,16 @@ interface RegistryPopulationIssue {
reason: string;
}
interface ParsedRecoverableTokenFile {
tokenFile: string;
filePath: string;
paused: boolean;
provider: CLIProxyProvider;
email?: string;
projectId: string | null;
stats: fs.Stats;
}
function describeRegistryPopulationIssue(issue: RegistryPopulationIssue): string {
const sourceDir = issue.paused ? 'auth-paused' : 'auth';
return `${sourceDir}/${issue.tokenFile} (${issue.reason})`;
@@ -105,10 +116,15 @@ function getRegistryPopulationIssueReason(error: unknown): string {
return 'unreadable token file';
}
function populateRegistryFromTokenFiles(
registry: AccountsRegistry,
options: { includePaused?: boolean } = {}
): RegistryPopulationIssue[] {
function buildProviderEmailCountKey(provider: CLIProxyProvider, email: string): string {
return `${provider}:${email.trim().toLowerCase()}`;
}
function readRecoverableTokenFiles(options: { includePaused?: boolean } = {}): {
tokens: ParsedRecoverableTokenFile[];
issues: RegistryPopulationIssue[];
} {
const tokens: ParsedRecoverableTokenFile[] = [];
const issues: RegistryPopulationIssue[] = [];
for (const token of listRecoverableTokenFiles(options)) {
@@ -133,69 +149,134 @@ function populateRegistryFromTokenFiles(
continue;
}
const providerAccounts = ensureProviderRegistry(registry, provider);
const projectId =
typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim()
: null;
const email =
typeof data.email === 'string' && data.email.trim()
? data.email.trim()
: inferEmailFromTokenFileName(token.tokenFile, provider);
const projectId =
typeof data.project_id === 'string' && data.project_id.trim()
? data.project_id.trim()
: null;
const existingEntry = Object.entries(providerAccounts.accounts).find(
([, account]) => account.tokenFile === token.tokenFile
);
if (existingEntry) {
existingEntry[1].paused = token.paused || undefined;
if (!token.paused) {
existingEntry[1].pausedAt = undefined;
}
if (provider === 'agy' && projectId) {
existingEntry[1].projectId = projectId;
}
continue;
}
const accountId =
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
? deriveNoEmailProviderAccountId(provider, token.tokenFile, providerAccounts.accounts)
: extractAccountIdFromTokenFile(token.tokenFile, email);
if (providerAccounts.accounts[accountId]) {
continue;
}
if (Object.keys(providerAccounts.accounts).length === 0) {
providerAccounts.default = accountId;
}
const stats = fs.statSync(token.filePath);
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email,
nickname: email ? generateNickname(email) : accountId,
tokens.push({
tokenFile: token.tokenFile,
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
lastUsedAt: (stats.mtime || stats.birthtime || new Date()).toISOString(),
};
if (token.paused) {
accountMeta.paused = true;
}
if (provider === 'agy' && projectId) {
accountMeta.projectId = projectId;
}
providerAccounts.accounts[accountId] = accountMeta;
filePath: token.filePath,
paused: token.paused,
provider,
email,
projectId,
stats: fs.statSync(token.filePath),
});
} catch (error) {
issues.push({
tokenFile: token.tokenFile,
paused: token.paused,
reason: getRegistryPopulationIssueReason(error),
});
}
}
return { tokens, issues };
}
function buildDuplicateEmailCounts(
tokens: ParsedRecoverableTokenFile[]
): ReadonlyMap<string, number> {
const counts = new Map<string, number>();
for (const token of tokens) {
if (!token.email) {
continue;
}
const key = buildProviderEmailCountKey(token.provider, token.email);
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return counts;
}
function populateRegistryFromTokenFiles(
registry: AccountsRegistry,
options: { includePaused?: boolean } = {}
): RegistryPopulationIssue[] {
const { tokens, issues } = readRecoverableTokenFiles(options);
const duplicateEmailCounts = buildDuplicateEmailCounts(tokens);
for (const token of tokens) {
const providerAccounts = ensureProviderRegistry(registry, token.provider);
const existingEntry = Object.entries(providerAccounts.accounts).find(
([, account]) => account.tokenFile === token.tokenFile
);
const existingAccountId = existingEntry?.[0];
const existingAccount = existingEntry?.[1];
const resolvedEmail = token.email ?? existingAccount?.email;
const duplicateEmailCount = resolvedEmail
? (duplicateEmailCounts.get(buildProviderEmailCountKey(token.provider, resolvedEmail)) ?? 1)
: 1;
const desiredAccountId =
PROVIDERS_WITHOUT_EMAIL.includes(token.provider) && !resolvedEmail
? deriveNoEmailProviderAccountId(token.provider, token.tokenFile, providerAccounts.accounts)
: !token.email && existingAccountId
? existingAccountId
: buildEmailBackedAccountId(
token.provider,
token.tokenFile,
resolvedEmail,
duplicateEmailCount
);
if (existingEntry && existingEntry[0] !== desiredAccountId) {
if (!providerAccounts.accounts[desiredAccountId]) {
providerAccounts.accounts[desiredAccountId] = existingEntry[1];
}
if (providerAccounts.default === existingEntry[0]) {
providerAccounts.default = desiredAccountId;
}
delete providerAccounts.accounts[existingEntry[0]];
}
if (Object.keys(providerAccounts.accounts).length === 0) {
providerAccounts.default = desiredAccountId;
}
const hydratedAccount = providerAccounts.accounts[desiredAccountId];
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
email: resolvedEmail,
nickname:
hydratedAccount?.nickname ||
(resolvedEmail
? buildEmailBackedNickname(
token.provider,
token.tokenFile,
resolvedEmail,
duplicateEmailCount
)
: desiredAccountId),
tokenFile: token.tokenFile,
createdAt:
hydratedAccount?.createdAt ||
token.stats.birthtime?.toISOString() ||
new Date().toISOString(),
lastUsedAt:
hydratedAccount?.lastUsedAt ||
(token.stats.mtime || token.stats.birthtime || new Date()).toISOString(),
};
if (token.paused) {
accountMeta.paused = true;
accountMeta.pausedAt = hydratedAccount?.pausedAt || new Date().toISOString();
} else {
accountMeta.paused = undefined;
accountMeta.pausedAt = undefined;
}
if (token.provider === 'agy') {
accountMeta.projectId = token.projectId || hydratedAccount?.projectId;
}
providerAccounts.accounts[desiredAccountId] = accountMeta;
}
return issues;
@@ -384,6 +465,17 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
return modified;
}
/**
* Build an in-memory view that includes both stale-entry cleanup and any token
* files not yet persisted into accounts.json. Used by read paths so duplicate
* email accounts stay visible without forcing a disk write.
*/
export function hydrateRegistryFromTokenFiles(registry: AccountsRegistry): boolean {
const removedStaleEntries = syncRegistryWithTokenFiles(registry);
const populationIssues = populateRegistryFromTokenFiles(registry);
return removedStaleEntries || populationIssues.length > 0;
}
/**
* Register a new account
* Called after successful OAuth to record the account
@@ -448,8 +540,60 @@ export function registerAccount(
accountNickname =
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
} else {
accountId = extractAccountIdFromTokenFile(tokenFile, email);
accountNickname = nickname || generateNickname(email);
const sameEmailEntries = email
? Object.entries(providerAccounts.accounts).filter(
([, account]) => account.email?.toLowerCase() === email.toLowerCase()
)
: [];
const duplicateEmailCount = email
? new Set([...sameEmailEntries.map(([, account]) => account.tokenFile), tokenFile]).size
: 1;
if (email && duplicateEmailCount > 1) {
for (const [existingId, existingMeta] of sameEmailEntries) {
const migratedId = buildEmailBackedAccountId(
provider,
existingMeta.tokenFile,
email,
duplicateEmailCount
);
if (migratedId === existingId || providerAccounts.accounts[migratedId]) {
continue;
}
providerAccounts.accounts[migratedId] = existingMeta;
if (providerAccounts.default === existingId) {
providerAccounts.default = migratedId;
}
delete providerAccounts.accounts[existingId];
}
}
accountId = buildEmailBackedAccountId(provider, tokenFile, email, duplicateEmailCount);
const existingAccount = providerAccounts.accounts[accountId];
if (nickname) {
const validationError = validateNickname(nickname);
if (validationError) {
throw new Error(validationError);
}
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
id,
nickname: account.nickname,
}));
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
throw new Error(
`An account with nickname "${nickname}" already exists for ${provider}. ` +
`Choose a different nickname.`
);
}
}
accountNickname =
nickname ||
existingAccount?.nickname ||
buildEmailBackedNickname(provider, tokenFile, email, duplicateEmailCount);
}
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
+53 -13
View File
@@ -12,6 +12,7 @@ import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
import { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
import {
AuthStatus,
PROVIDER_AUTH_PREFIXES,
@@ -215,6 +216,7 @@ export function registerAccountFromToken(
mtimeMs: number;
alreadyRegistered: boolean;
};
type RawTokenCandidate = Omit<TokenCandidate, 'accountId'>;
const { registerAccount } = require('../account-manager');
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
@@ -222,33 +224,71 @@ export function registerAccountFromToken(
const files = fs.readdirSync(tokenDir);
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
const existingAccounts = getProviderAccounts(provider);
const candidates: TokenCandidate[] = jsonFiles
.map((file): TokenCandidate | null => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) return null;
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
const filePath = path.join(tokenDir, file);
if (!isTokenFileForProvider(filePath, provider)) return [];
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const accountId = extractAccountIdFromTokenFile(file, email);
const stats = fs.statSync(filePath);
return {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content) as { email?: string; project_id?: string };
const email = data.email || undefined;
const projectId = data.project_id || undefined;
const stats = fs.statSync(filePath);
return [
{
file,
filePath,
email,
projectId,
accountId,
mtimeMs: stats.mtimeMs,
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
},
];
});
const duplicateEmailCounts = new Map<string, number>();
const duplicateEmailTokenSets = new Map<string, Set<string>>();
for (const account of existingAccounts) {
if (!account.email) continue;
const key = account.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(account.tokenFile);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const candidate of rawCandidates) {
if (!candidate.email) continue;
const key = candidate.email.toLowerCase();
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
tokenSet.add(candidate.file);
duplicateEmailTokenSets.set(key, tokenSet);
}
for (const [key, tokenSet] of duplicateEmailTokenSets) {
duplicateEmailCounts.set(key, tokenSet.size);
}
const candidates: TokenCandidate[] = rawCandidates
.map((rawCandidate) => {
const duplicateEmailCount = rawCandidate.email
? (duplicateEmailCounts.get(rawCandidate.email.toLowerCase()) ?? 1)
: 1;
const accountId = rawCandidate.email
? buildEmailBackedAccountId(
provider,
rawCandidate.file,
rawCandidate.email,
duplicateEmailCount
)
: extractAccountIdFromTokenFile(rawCandidate.file, rawCandidate.email);
return {
...rawCandidate,
accountId,
};
})
.filter((candidate): candidate is TokenCandidate => candidate !== null)
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (expectedAccountId) {
selectedCandidate =
candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
candidates.find((candidate) => candidate.file === expectedAccountId) ||
candidates.find((candidate) => {
const existingAccount = existingAccounts.find(
(account) => account.id === expectedAccountId
+9 -3
View File
@@ -51,6 +51,7 @@ import {
renameAccount,
getDefaultAccount,
} from '../account-manager';
import { formatAccountDisplayName } from '../accounts/email-account-identity';
import {
ensureMcpWebSearch,
installWebSearchHook,
@@ -422,7 +423,7 @@ export async function execClaudeWithCLIProxy(
for (const acct of accounts) {
const defaultMark = acct.isDefault ? ' (default)' : '';
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`);
}
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
}
@@ -438,14 +439,19 @@ export async function execClaudeWithCLIProxy(
if (accounts.length > 0) {
console.error(` Available accounts:`);
for (const acct of accounts) {
console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`);
const displayName = formatAccountDisplayName(acct);
const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName;
console.error(` - ${label}`);
}
}
process.exit(1);
}
setDefaultAccount(provider, account.id);
touchAccount(provider, account.id);
console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`));
const switchedLabel = account.nickname
? `${account.nickname} (${formatAccountDisplayName(account)})`
: formatAccountDisplayName(account);
console.log(ok(`Switched to account: ${switchedLabel}`));
}
// Handle --nickname (rename account)
+51 -17
View File
@@ -8,9 +8,10 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getAuthDir } from './config-generator';
import { getProviderAccounts, getPausedDir } from './account-manager';
import { getAccount, getProviderAccounts, getPausedDir } from './account-manager';
import { sanitizeEmail, isTokenExpired } from './auth-utils';
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
import { extractCanonicalEmailFromAccountId } from './accounts/email-account-identity';
/** ChatGPT backend API base URL */
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
@@ -174,9 +175,46 @@ export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCo
/**
* Read auth data from Codex auth file
*/
function readCodexAuthFile(filePath: string): CodexAuthData | null {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
if (!data.access_token) {
return null;
}
return {
accessToken: data.access_token,
accountId: data.account_id || data.accountId || '',
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
} catch {
return null;
}
}
function readCodexAuthData(accountId: string): CodexAuthData | null {
const authDirs = [getAuthDir(), getPausedDir()];
const sanitizedId = sanitizeEmail(accountId);
const registryAccount = getAccount('codex', accountId);
const canonicalEmail = extractCanonicalEmailFromAccountId(accountId);
const hasExplicitVariant = canonicalEmail !== null && canonicalEmail !== accountId;
if (registryAccount?.tokenFile) {
for (const authDir of authDirs) {
const filePath = path.join(authDir, registryAccount.tokenFile);
if (!fs.existsSync(filePath)) {
continue;
}
const authData = readCodexAuthFile(filePath);
if (authData) {
return authData;
}
}
}
const legacyEmail = canonicalEmail ?? accountId;
const sanitizedId = sanitizeEmail(legacyEmail);
const expectedFile = `codex-${sanitizedId}.json`;
for (const authDir of authDirs) {
@@ -184,23 +222,19 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
const filePath = path.join(authDir, expectedFile);
if (fs.existsSync(filePath)) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(content);
if (!data.access_token) continue;
return {
accessToken: data.access_token,
accountId: data.account_id || data.accountId || '',
isExpired: isTokenExpired(data.expired),
expiresAt: data.expired || null,
};
} catch {
continue;
const authData = readCodexAuthFile(filePath);
if (authData) {
return authData;
}
}
// Fallback: scan directory for matching email in file content
// Fallback is only safe for legacy email-only IDs. Variant-backed IDs must resolve
// through the registry-backed token file so duplicate-email accounts stay deterministic.
if (hasExplicitVariant) {
continue;
}
// Fallback: scan directory for matching email in file content.
const files = fs.readdirSync(authDir);
for (const file of files) {
if (file.startsWith('codex-') && file.endsWith('.json')) {
@@ -208,7 +242,7 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
try {
const content = fs.readFileSync(candidatePath, 'utf-8');
const data = JSON.parse(content);
if (data.email === accountId && data.access_token) {
if (data.email === legacyEmail && data.access_token) {
return {
accessToken: data.access_token,
accountId: data.account_id || data.accountId || '',
+38 -2
View File
@@ -1,5 +1,6 @@
import { buildQualifiedAccountStatsKey } from './account-stats-key';
import { mapExternalProviderName } from './provider-capabilities';
import { buildEmailBackedAccountId } from './accounts/email-account-identity';
import type {
AccountUsageStats,
CliproxyManagementAuthFile,
@@ -15,6 +16,9 @@ interface BuildCliproxyStatsOptions {
interface ResolvedAuthFile {
provider?: string;
source?: string;
email?: string;
name?: string;
duplicateEmailCount?: number;
}
function normalizeProvider(provider: string): string {
@@ -30,6 +34,16 @@ function buildAuthIndexLookup(
authFiles: CliproxyManagementAuthFile[] | undefined
): ReadonlyMap<string, ResolvedAuthFile> {
const lookup = new Map<string, ResolvedAuthFile>();
const duplicateEmailCounts = new Map<string, number>();
for (const authFile of authFiles ?? []) {
if (!authFile.provider || !authFile.email) {
continue;
}
const key = `${normalizeProvider(authFile.provider)}:${authFile.email.trim().toLowerCase()}`;
duplicateEmailCounts.set(key, (duplicateEmailCounts.get(key) ?? 0) + 1);
}
for (const authFile of authFiles ?? []) {
if (authFile.auth_index === undefined || authFile.auth_index === null) {
@@ -37,6 +51,8 @@ function buildAuthIndexLookup(
}
const provider = authFile.provider ? normalizeProvider(authFile.provider) : undefined;
const email = authFile.email?.trim() || undefined;
const name = authFile.name?.trim() || undefined;
const source = authFile.email?.trim() || authFile.name?.trim() || undefined;
if (!provider && !source) {
continue;
@@ -45,6 +61,12 @@ function buildAuthIndexLookup(
lookup.set(String(authFile.auth_index), {
provider,
source,
email,
name,
duplicateEmailCount:
provider && email
? (duplicateEmailCounts.get(`${provider}:${email.toLowerCase()}`) ?? 1)
: 1,
});
}
@@ -65,15 +87,29 @@ function resolveProviderForDetail(
}
function resolveSourceForDetail(
resolvedProvider: string,
detail: CliproxyRequestDetail,
authIndexLookup: ReadonlyMap<string, ResolvedAuthFile>
): string {
const resolvedAuthFile = authIndexLookup.get(String(detail.auth_index));
if (resolvedAuthFile?.email && resolvedAuthFile?.name) {
const derivedSource = buildEmailBackedAccountId(
resolvedProvider,
resolvedAuthFile.name,
resolvedAuthFile.email,
resolvedAuthFile.duplicateEmailCount ?? 1
);
if (derivedSource) {
return derivedSource;
}
}
const source = detail.source?.trim();
if (source) {
return source;
}
return authIndexLookup.get(String(detail.auth_index))?.source ?? 'unknown';
return resolvedAuthFile?.source ?? 'unknown';
}
export function buildCliproxyStatsFromUsageResponse(
@@ -110,8 +146,8 @@ export function buildCliproxyStatsFromUsageResponse(
for (const detail of modelData.details) {
sawAnyDetail = true;
sawProviderDetail = true;
const source = resolveSourceForDetail(detail, authIndexLookup);
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup);
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
requestsByProvider[resolvedProvider] = (requestsByProvider[resolvedProvider] ?? 0) + 1;
+26 -22
View File
@@ -35,6 +35,7 @@ import {
QUOTA_SUPPORTED_PROVIDER_IDS,
type QuotaSupportedProvider,
} from '../../cliproxy/provider-capabilities';
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
interface CliproxyProfileArgs {
@@ -97,6 +98,11 @@ function formatResetTimeISO(isoTime: string): string {
return formatResetTime(seconds);
}
function formatCliAccountLabel(account: { id: string; email?: string; nickname?: string }): string {
const displayName = formatAccountDisplayName(account);
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
}
interface QuotaFailureDisplayEntry {
tone: 'error' | 'info' | 'dim';
text: string;
@@ -360,13 +366,7 @@ function displayAntigravityQuotaSection(
const tier = account.tier || 'unknown';
const status = statusParts.join(', ');
rows.push([
defaultMark,
account.nickname || account.email || account.id,
tier,
avgQuota,
status,
]);
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
}
console.log(
@@ -384,10 +384,11 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('codex', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
@@ -406,7 +407,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
const coreUsageSummary = quota.coreUsage ?? {
fiveHour: fiveHourWindow
@@ -539,10 +540,11 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('claude', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
@@ -562,7 +564,7 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
const statusIcon =
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
console.log(` ${statusIcon}${account}${defaultMark}`);
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
const resetParts: string[] = [];
if (fiveHourWindow?.resetAt)
@@ -616,10 +618,11 @@ function displayGeminiCliQuotaSection(
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('gemini', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
@@ -631,7 +634,7 @@ function displayGeminiCliQuotaSection(
: 0;
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
console.log(` ${statusIcon}${account}${defaultMark}`);
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
if (quota.projectId) {
console.log(` Project: ${dim(quota.projectId)}`);
}
@@ -667,10 +670,11 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('ghcp', account);
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${fail(accountLabel)}${defaultMark}`);
displayQuotaFailure(quota);
console.log('');
continue;
@@ -685,7 +689,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
if (quota.quotaResetDate) {
console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`);
}
@@ -840,7 +844,7 @@ export async function handleDoctor(verbose = false): Promise<void> {
const quotaResult = await fetchAllProviderQuotas(provider, verbose);
for (const { account, quota } of quotaResult.accounts) {
const accountLabel = account.email || account.id || 'Unknown Account';
const accountLabel = formatCliAccountLabel(account);
const defaultBadge = account.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
@@ -933,7 +937,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
console.log('Available accounts:');
for (const acc of accounts) {
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
console.log(` - ${acc.email || acc.id}${badge}`);
console.log(` - ${formatCliAccountLabel(acc)}${badge}`);
}
} else {
console.log(`No accounts found for provider: ${provider}`);
@@ -945,7 +949,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
const success = setDefaultAccount(provider, account.id);
if (success) {
console.log(ok(`Default account set to: ${account.email || account.id}`));
console.log(ok(`Default account set to: ${formatCliAccountLabel(account)}`));
console.log(info(`Provider: ${provider}`));
} else {
console.log(fail('Failed to set default account'));
@@ -973,7 +977,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
}
if (account.paused) {
console.log(warn(`Account already paused: ${account.email || account.id}`));
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
return;
}
@@ -981,7 +985,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
const success = pauseAccount(provider, account.id);
if (success) {
console.log(ok(`Account paused: ${account.email || account.id}`));
console.log(ok(`Account paused: ${formatCliAccountLabel(account)}`));
console.log(info('Account will be skipped in quota rotation'));
} else {
console.log(fail('Failed to pause account'));
@@ -1009,14 +1013,14 @@ export async function handleResumeAccount(args: string[]): Promise<void> {
}
if (!account.paused) {
console.log(warn(`Account is not paused: ${account.email || account.id}`));
console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`));
return;
}
const success = resumeAccount(provider, account.id);
if (success) {
console.log(ok(`Account resumed: ${account.email || account.id}`));
console.log(ok(`Account resumed: ${formatCliAccountLabel(account)}`));
console.log(info('Account is now active in quota rotation'));
} else {
console.log(fail('Failed to resume account'));
+20 -6
View File
@@ -28,6 +28,7 @@ import {
} from '../../cliproxy/services';
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
interface CliproxyProfileArgs {
name?: string;
@@ -118,6 +119,15 @@ function getBackendLabel(backend: CLIProxyBackend): string {
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
}
function formatVariantAccountLabel(account: {
id: string;
email?: string;
nickname?: string;
}): string {
const displayName = formatAccountDisplayName(account);
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
}
/**
* Interactive prompt to select provider + model for a single tier.
* Returns a CompositeTierConfig, or null if user cancelled auth.
@@ -158,7 +168,7 @@ async function selectTierConfig(
console.log(fail('Authentication failed'));
process.exit(1);
}
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
}
// Select model
@@ -364,7 +374,7 @@ export async function handleCreate(
}
account = newAccount.id;
console.log('');
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
} else if (providerAccounts.length === 1) {
account = providerAccounts[0].id;
} else {
@@ -372,7 +382,7 @@ export async function handleCreate(
const accountOptions = [
...providerAccounts.map((acc) => ({
id: acc.id,
label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`,
label: `${formatVariantAccountLabel(acc)}${acc.isDefault ? ' (default)' : ''}`,
})),
{ id: ADD_NEW_ID, label: color('[+ Add new account...]', 'info') },
];
@@ -394,7 +404,7 @@ export async function handleCreate(
}
account = newAccount.id;
console.log('');
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
} else {
account = selectedAccount;
}
@@ -406,7 +416,7 @@ export async function handleCreate(
console.log('');
console.log('Available accounts:');
providerAccounts.forEach((a) =>
console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`)
console.log(` - ${formatVariantAccountLabel(a)}${a.isDefault ? ' (default)' : ''}`)
);
process.exit(1);
}
@@ -450,9 +460,13 @@ export async function handleCreate(
? '~/.ccs/config.yaml'
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
const selectedAccount =
account && provider
? getProviderAccounts(provider as CLIProxyProvider).find((acc) => acc.id === account)
: null;
console.log(
infoBox(
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${selectedAccount ? `Account: ${formatVariantAccountLabel(selectedAccount)}\n` : account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
configType
)
);
+2 -1
View File
@@ -18,6 +18,7 @@ import {
bulkResumeAccounts,
soloAccount,
} from '../../cliproxy/account-manager';
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
import {
DEFAULT_ACCOUNT_CONTINUITY_MODE,
@@ -122,7 +123,7 @@ router.get('/', (_req: Request, res: Response): void => {
continue;
}
// Use unique ID for key to prevent collisions between accounts with same nickname/email
const displayName = acct.nickname || acct.email || acct.id;
const displayName = acct.nickname || formatAccountDisplayName(acct);
const rawKey = `${provider}:${acct.id}`;
const key = buildCliproxyAccountKey(rawKey, merged);
if (!key) {
@@ -24,7 +24,6 @@ import {
pauseAccount as pauseAccountFn,
resumeAccount as resumeAccountFn,
touchAccount,
extractAccountIdFromTokenFile,
hasAccountNameConflict,
PROVIDERS_WITHOUT_EMAIL,
validateNickname,
@@ -932,7 +931,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
getProviderTokenDir(localProvider),
pendingAuth.nickname,
false,
extractAccountIdFromTokenFile(tokenSnapshot.file, tokenSnapshot.email)
tokenSnapshot.file
);
if (!account) {
@@ -116,4 +116,41 @@ describe('registerAccount optional nickname flow', () => {
expect(match).toBeNull();
});
it('treats duplicate Codex emails as distinct accounts and rejects bare-email lookups', async () => {
const result = await withIsolatedHome(async (homeDir) => {
writeTokenFile(homeDir, 'codex-04a0f049-kaidu.kd@gmail.com-team.json');
writeTokenFile(homeDir, 'codex-kaidu.kd@gmail.com-free.json');
const { registerAccount, getProviderAccounts, findAccountByQuery } = await loadAccountManager();
const team = registerAccount(
'codex',
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
'kaidu.kd@gmail.com'
);
const free = registerAccount(
'codex',
'codex-kaidu.kd@gmail.com-free.json',
'kaidu.kd@gmail.com'
);
return {
team,
free,
accounts: getProviderAccounts('codex'),
ambiguousLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com'),
teamLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com#04a0f049-team'),
freeLookup: findAccountByQuery('codex', free.id),
};
});
expect(result.team.id).toBe('kaidu.kd@gmail.com');
expect(result.free.id).toBe('kaidu.kd@gmail.com#free');
expect(result.accounts.map((account) => account.id).sort()).toEqual([
'kaidu.kd@gmail.com#04a0f049-team',
'kaidu.kd@gmail.com#free',
]);
expect(result.ambiguousLookup).toBeNull();
expect(result.teamLookup?.id).toBe('kaidu.kd@gmail.com#04a0f049-team');
expect(result.freeLookup?.id).toBe('kaidu.kd@gmail.com#free');
});
});
@@ -17,6 +17,7 @@ let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-c
let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary;
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
let registerAccount: typeof import('../../../src/cliproxy/account-manager').registerAccount;
function createCodexAccount(
accountId: string,
@@ -40,6 +41,7 @@ beforeEach(async () => {
);
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
({ registerAccount } = accountManager);
({
buildCodexQuotaWindows,
@@ -363,14 +365,18 @@ describe('Codex Quota Fetcher', () => {
});
describe('fetchCodexQuota failure mapping', () => {
function createValidCodexAccount(email: string, accountId = `workspace-${email}`): void {
function createValidCodexAccount(
email: string,
accountId = `workspace-${email}`,
tokenFile?: string
): void {
createCodexAccount(email, {
access_token: 'test-token',
account_id: accountId,
expired: '2099-01-01T00:00:00.000Z',
email,
type: 'codex',
});
}, tokenFile);
}
it('maps deactivated workspace 402 responses to structured metadata', async () => {
@@ -409,6 +415,92 @@ describe('Codex Quota Fetcher', () => {
expect(result.actionHint).toContain('ccs cliproxy auth codex');
});
it('uses the registry token file for duplicate-email Codex accounts', async () => {
createValidCodexAccount(
'kaidu.kd@gmail.com',
'workspace-team',
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
);
createValidCodexAccount(
'kaidu.kd@gmail.com',
'workspace-free',
'codex-kaidu.kd@gmail.com-free.json'
);
registerAccount(
'codex',
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
'kaidu.kd@gmail.com'
);
const freeAccount = registerAccount(
'codex',
'codex-kaidu.kd@gmail.com-free.json',
'kaidu.kd@gmail.com'
);
const fetchSpy = mock((input: RequestInfo | URL, init?: RequestInit) =>
Promise.resolve(
new Response(
JSON.stringify({
plan_type: 'free',
rate_limit: {
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
) as typeof fetch;
global.fetch = fetchSpy;
const result = await fetchCodexQuota(freeAccount.id);
const requestInit = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined;
const headers = new Headers(requestInit?.headers);
expect(result.success).toBe(true);
expect(headers.get('ChatGPT-Account-Id')).toBe('workspace-free');
});
it('does not guess a duplicate-email Codex auth file when the registry entry is missing', async () => {
createValidCodexAccount(
'kaidu.kd@gmail.com',
'workspace-team',
'codex-legacy-slot-a.json'
);
createValidCodexAccount(
'kaidu.kd@gmail.com',
'workspace-free',
'codex-legacy-slot-b.json'
);
const fetchSpy = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
plan_type: 'free',
rate_limit: {
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
)
) as typeof fetch;
global.fetch = fetchSpy;
const result = await fetchCodexQuota('kaidu.kd@gmail.com#04a0f049-team');
expect(result.success).toBe(false);
expect(result.errorCode).toBe('auth_file_missing');
expect(fetchSpy).not.toHaveBeenCalled();
});
it('maps 403 responses to forbidden metadata', async () => {
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
@@ -288,4 +288,45 @@ describe('buildCliproxyStatsFromUsageResponse', () => {
});
expect(stats.requestsByProvider).toEqual({ codex: 1, 'ccs-internal-managed': 2 });
});
it('derives duplicate-email Codex account ids from auth file metadata', () => {
const usage = createInternallyBucketedUsage([
createDetail({ auth_index: 'codex-team', source: 'kaidu.kd@gmail.com' }),
createDetail({
auth_index: 'codex-free',
source: 'kaidu.kd@gmail.com',
timestamp: '2025-03-26T10:01:00.000Z',
failed: true,
}),
]);
const authFiles: CliproxyManagementAuthFile[] = [
{
auth_index: 'codex-team',
provider: 'codex',
email: 'kaidu.kd@gmail.com',
name: 'codex-04a0f049-kaidu.kd@gmail.com-team.json',
},
{
auth_index: 'codex-free',
provider: 'codex',
email: 'kaidu.kd@gmail.com',
name: 'codex-kaidu.kd@gmail.com-free.json',
},
];
const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles });
expect(stats.accountStats['codex:kaidu.kd@gmail.com#04a0f049-team']).toMatchObject({
provider: 'codex',
source: 'kaidu.kd@gmail.com#04a0f049-team',
successCount: 1,
failureCount: 0,
});
expect(stats.accountStats['codex:kaidu.kd@gmail.com#free']).toMatchObject({
provider: 'codex',
source: 'kaidu.kd@gmail.com#free',
successCount: 0,
failureCount: 1,
});
});
});
@@ -2,38 +2,25 @@
* Account Card Component for Flow Visualization
*/
import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import {
GripVertical,
Loader2,
Pause,
Play,
KeyRound,
AlertTriangle,
AlertCircle,
} from 'lucide-react';
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { formatQuotaPercent, getProviderMinQuota, getQuotaFailureInfo, cn } from '@/lib/utils';
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
import {
useAccountQuota,
useAccountQuotas,
QUOTA_SUPPORTED_PROVIDERS,
} from '@/hooks/use-cliproxy-stats';
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { useTranslation } from 'react-i18next';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import type { AccountData, DragOffset } from './types';
import { cleanEmail } from './utils';
import { AccountCardStats } from './account-card-stats';
import { cleanEmail } from './utils';
type Zone = 'left' | 'right' | 'top' | 'bottom';
const QUOTA_PROVIDER_ALIASES = [
'antigravity',
'anthropic',
@@ -56,7 +43,7 @@ interface AccountCardProps {
onPointerDown: (e: React.PointerEvent) => void;
onPointerMove: (e: React.PointerEvent) => void;
onPointerUp: () => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
isPausingAccount?: boolean;
}
@@ -87,6 +74,40 @@ function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties {
}
}
function getCompactQuotaColor(percentage: number) {
if (percentage > 50) return 'bg-emerald-500';
if (percentage > 20) return 'bg-amber-500';
return 'bg-red-500';
}
function getVariantMarkerLabel(audience: string, fallbackLabel?: string | null) {
if (audience === 'business') return 'Biz';
if (audience === 'personal') return 'Pers';
const normalizedFallback = fallbackLabel?.trim();
return normalizedFallback?.[0]?.toUpperCase() ?? '?';
}
function getGroupedVariantSummaryLabel(
variants: Array<{ audience: string; audienceLabel?: string | null; detailLabel?: string | null }>
) {
const audiences = new Set(variants.map((variant) => variant.audience));
if (audiences.size === 2 && audiences.has('business') && audiences.has('personal')) {
return 'B|P';
}
if (variants.length === 1) {
const [variant] = variants;
return getVariantMarkerLabel(
variant.audience,
variant.audienceLabel ?? variant.detailLabel ?? null
);
}
return null;
}
export function AccountCard({
account,
zone,
@@ -108,86 +129,155 @@ export function AccountCard({
const borderSide = BORDER_SIDE_MAP[zone];
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
const normalizedProvider = account.provider.toLowerCase();
const isCliproxyProvider =
const showQuota =
QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
QUOTA_PROVIDER_ALIASES.includes(normalizedProvider);
const isCodexProvider = normalizedProvider === 'codex';
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
const hasGroupedVariants = (account.variants?.length ?? 0) > 1;
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
normalizedProvider,
account.id,
isCliproxyProvider
showQuota && !hasGroupedVariants
);
const variantQuotaQueries = useAccountQuotas(
(account.variants ?? []).map((variant) => ({
provider: account.provider,
accountId: variant.id,
})),
showQuota && hasGroupedVariants
);
const groupedHeaderVariants = hasGroupedVariants
? Array.from(
new Map(
(account.variants ?? [])
.slice()
.sort((left, right) => {
const order = { business: 0, personal: 1, unknown: 2 } as const;
return order[left.audience] - order[right.audience];
})
.map((variant) => [variant.audienceLabel ?? variant.detailLabel ?? variant.id, variant])
).values()
)
: [];
const groupedVariantSummaryLabel = getGroupedVariantSummaryLabel(groupedHeaderVariants);
// Use shared helper for provider-specific minimum quota
const minQuota = getProviderMinQuota(account.provider, quota);
const resetTime = getProviderResetTime(account.provider, quota);
const codexBreakdown =
isCodexProvider && quota && isCodexQuotaResult(quota)
? getCodexQuotaBreakdown(quota.windows)
const compactMetaBadges = hasGroupedVariants ? (
<>
<div
className="inline-flex shrink-0 items-center overflow-hidden rounded-md border border-border/60 bg-muted/60 shadow-sm shadow-black/5 dark:bg-zinc-900/80"
title={groupedHeaderVariants
.map((variant) => variant.audienceLabel ?? variant.detailLabel ?? 'Variant')
.join(' • ')}
>
{groupedVariantSummaryLabel ? (
<span className="inline-flex min-w-[2.2rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none text-foreground/80">
{groupedVariantSummaryLabel}
</span>
) : (
groupedHeaderVariants.map((variant, index) => (
<span
key={variant.id}
className={cn(
'inline-flex min-w-[1.9rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none',
index > 0 && 'border-l border-border/50',
variant.audience === 'business'
? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300'
: variant.audience === 'personal'
? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
: 'bg-muted text-muted-foreground'
)}
>
{getVariantMarkerLabel(
variant.audience,
variant.audienceLabel ?? variant.detailLabel ?? null
)}
</span>
))
)}
</div>
{account.paused && (
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
Paused
</span>
)}
</>
) : undefined;
const groupedQuotaRows =
hasGroupedVariants && showQuota
? (account.variants ?? []).map((variant, index) => {
const quotaQuery = variantQuotaQueries[index];
const minQuota = getProviderMinQuota(account.provider, quotaQuery?.data);
const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quotaQuery?.data);
const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email);
return (
<div key={variant.id} className="space-y-0.5">
<div className="flex items-center justify-between gap-2 text-[8px]">
<span className="text-muted-foreground/80 truncate">{label}</span>
<span className="font-mono text-foreground/80 shrink-0">
{quotaQuery?.isLoading
? t('accountCard.quotaLoading')
: quotaValue !== null
? `${quotaLabel}%`
: failureInfo?.label || t('accountCard.quotaUnavailable')}
</span>
</div>
{quotaValue !== null && (
<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',
getCompactQuotaColor(quotaValue)
)}
style={{ width: `${quotaValue}%` }}
/>
</div>
)}
</div>
);
})
: null;
const codexQuotaRows = [
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows =
isClaudeProvider && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: 'Wk',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
].filter((row): row is { label: string; value: number } => row.value !== null)
: [];
const compactQuotaRows = isCodexProvider
? codexQuotaRows
: isClaudeProvider
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const failureTextClass =
failureInfo?.tone === 'warning'
? 'text-amber-600 dark:text-amber-400'
: failureInfo?.tone === 'destructive'
? 'text-destructive'
: 'text-muted-foreground/70';
// Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge =
account.provider === 'agy' &&
account.tier &&
account.tier !== 'unknown' &&
account.tier !== 'free';
const headerEnd = (
<>
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'h-4 w-4 shrink-0 transition-all rounded-full',
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
)}
onClick={(e) => {
e.stopPropagation();
onPauseToggle(account.memberIds ?? [account.id], !account.paused);
}}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
) : account.paused ? (
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
) : (
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
</>
);
return (
<div
@@ -214,171 +304,35 @@ export function AccountCard({
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
{/* Header row: Email + Tier | Pause button | Drag handle */}
<div className="flex items-center gap-1.5 mb-1">
{/* Email with tier badge inline */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
{showTierBadge && (
<span
className={cn(
'text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0',
account.tier === 'ultra'
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
)}
>
{account.tier}
</span>
)}
</div>
{/* Pause/Resume button */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn(
'h-4 w-4 shrink-0',
'transition-all rounded-full',
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
)}
onClick={(e) => {
e.stopPropagation();
onPauseToggle(account.id, !account.paused);
}}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-2.5 h-2.5 animate-spin" />
) : account.paused ? (
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
) : (
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Drag handle */}
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
</div>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
showDetails={showDetails}
<AccountSurfaceCard
mode="compact"
provider={account.provider}
accountId={account.id}
email={account.email}
displayEmail={cleanEmail(account.email)}
tokenFile={account.tokenFile}
tier={account.tier}
isDefault={account.isDefault}
paused={account.paused}
privacyMode={privacyMode}
showQuota={showQuota && !hasGroupedVariants}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={account.lastUsedAt}
headerEnd={headerEnd}
compactMetaBadges={compactMetaBadges}
footerSlot={
<>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
showDetails={showDetails}
/>
{groupedQuotaRows && <div className="mt-2 px-0.5 space-y-1">{groupedQuotaRows}</div>}
</>
}
/>
{/* Quota bar for CLIProxy accounts */}
{isCliproxyProvider && (
<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>{t('accountCard.quotaLoading')}</span>
</div>
) : minQuotaValue !== 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">
{t('accountCard.quota')}
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
minQuotaValue > 50
? 'text-emerald-600 dark:text-emerald-400'
: minQuotaValue > 20
? 'text-amber-500'
: 'text-red-500'
)}
>
{minQuotaLabel}%
</span>
</div>
{compactQuotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{compactQuotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</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',
minQuotaValue > 50
? 'bg-emerald-500'
: minQuotaValue > 20
? 'bg-amber-500'
: 'bg-red-500'
)}
style={{ width: `${minQuotaValue}%` }}
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.success ? (
<div className="text-[8px] text-muted-foreground/60">
{t('accountCard.quotaUnavailable')}
</div>
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn('flex items-center gap-1 text-[8px]', failureTextClass)}>
<FailureIcon className="w-2.5 h-2.5" />
<span>{failureInfo.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[220px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)}
<div
className={cn(
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
+9 -1
View File
@@ -2,6 +2,8 @@
* Type definitions for Account Flow Visualization
*/
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
@@ -14,7 +16,9 @@ export interface DragOffset {
export interface AccountData {
id: string;
email: string;
tokenFile?: string;
provider: string;
isDefault?: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
@@ -22,6 +26,10 @@ export interface AccountData {
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
/** Raw member IDs when one visual card represents multiple underlying auth records */
memberIds?: string[];
/** Raw variant details shown inside grouped visual cards */
variants?: AccountVisualVariant[];
}
export interface ProviderData {
@@ -34,7 +42,7 @@ export interface ProviderData {
export interface AccountFlowVizProps {
providerData: ProviderData;
onBack?: () => void;
onPauseToggle?: (accountId: string, paused: boolean) => void;
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
isPausingAccount?: boolean;
}
@@ -0,0 +1,330 @@
import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
getQuotaFailureInfo,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
AlertCircle,
AlertTriangle,
CheckCircle2,
Clock,
HelpCircle,
KeyRound,
Loader2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
type AccountSurfaceMode = 'compact' | 'detailed';
interface AccountQuotaPanelProps {
provider: string;
quota?: UnifiedQuotaResult;
quotaLoading?: boolean;
runtimeLastUsed?: string;
mode: AccountSurfaceMode;
className?: string;
}
function getQuotaColor(percentage: number): string {
const clamped = Math.max(0, Math.min(100, percentage));
if (clamped <= 20) return 'bg-destructive';
if (clamped <= 50) return 'bg-yellow-500';
return 'bg-green-500';
}
function formatRelativeTime(dateStr: string | undefined): string {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
const diff = Date.now() - 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 '';
}
}
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
if (!lastUsedAt) return false;
try {
return Date.now() - new Date(lastUsedAt).getTime() < 60 * 60 * 1000;
} catch {
return false;
}
}
export function AccountQuotaPanel({
provider,
quota,
quotaLoading,
runtimeLastUsed,
mode,
className,
}: AccountQuotaPanelProps) {
const { t } = useTranslation();
const normalizedProvider = provider.toLowerCase();
const isCodexProvider = normalizedProvider === 'codex';
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
const minQuota = getProviderMinQuota(provider, quota);
const resetTime = getProviderResetTime(provider, quota);
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const codexBreakdown =
isCodexProvider && quota && isCodexQuotaResult(quota)
? getCodexQuotaBreakdown(quota.windows)
: null;
const compactQuotaRows = isCodexProvider
? [
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{
label: mode === 'compact' ? 'Wk' : 'Weekly',
value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null,
},
]
: isClaudeProvider && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: mode === 'compact' ? 'Wk' : 'Weekly',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
]
: [];
const quotaRows = compactQuotaRows.filter(
(row): row is { label: string; value: number } => row.value !== null
);
if (quotaLoading) {
return (
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
<Loader2 className="w-3 h-3 animate-spin" />
<span>{mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'}</span>
</div>
);
}
if (minQuotaValue !== null) {
return (
<div className={cn(mode === 'compact' ? 'px-0.5' : '', className)}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{mode === 'compact' ? (
<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">
{t('accountCard.quota')}
</span>
<span
className={cn(
'text-[10px] font-mono font-bold',
minQuotaValue > 50
? 'text-emerald-600 dark:text-emerald-400'
: minQuotaValue > 20
? 'text-amber-500'
: 'text-red-500'
)}
>
{minQuotaLabel}%
</span>
</div>
{quotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{quotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</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',
minQuotaValue > 50
? 'bg-emerald-500'
: minQuotaValue > 20
? 'bg-amber-500'
: 'bg-red-500'
)}
style={{ width: `${minQuotaValue}%` }}
/>
</div>
</div>
) : (
<div className="space-y-1.5 cursor-help">
<div className="flex items-center gap-1.5 text-xs">
{isRecentlyUsed(runtimeLastUsed) ? (
<>
<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>
{quotaRows.length > 0 ? (
<div className="space-y-1.5">
{quotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
</span>
<Progress
value={Math.max(0, Math.min(100, row.value))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(row.value)}
/>
<span className="text-xs font-medium w-10 text-right">{row.value}%</span>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2">
<Progress
value={Math.max(0, Math.min(100, minQuotaValue))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuotaValue)}
/>
<span className="text-xs font-medium w-10 text-right">{minQuotaLabel}%</span>
</div>
)}
</div>
)}
</TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
if (quota?.success) {
return mode === 'compact' ? (
<div className={cn('text-[8px] text-muted-foreground/60', className)}>
{t('accountCard.quotaUnavailable')}
</div>
) : (
<div className={className}>
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<HelpCircle className="w-3 h-3" />
{t('accountCard.quotaUnavailable')}
</Badge>
</div>
);
}
if (!failureInfo) {
return null;
}
const failureClass =
failureInfo.tone === 'warning'
? 'text-amber-600 dark:text-amber-400 border-amber-500/50'
: failureInfo.tone === 'destructive'
? 'text-destructive border-destructive/50'
: 'text-muted-foreground/70 border-muted-foreground/50';
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{mode === 'compact' ? (
<div className={cn('flex items-center gap-1 text-[8px]', failureClass, className)}>
<FailureIcon className="w-2.5 h-2.5" />
<span>{failureInfo.label}</span>
</div>
) : (
<div className={className}>
<Badge variant="outline" className={cn('text-[10px] h-5 px-2 gap-1', failureClass)}>
<FailureIcon className="w-3 h-3" />
{failureInfo.label}
</Badge>
</div>
)}
</TooltipTrigger>
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,231 @@
import type { ReactNode } from 'react';
import { Badge } from '@/components/ui/badge';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
import { getAccountIdentityPresentation } from '@/lib/account-identity';
import { cn } from '@/lib/utils';
import { Pause, Star, User } from 'lucide-react';
import { AccountQuotaPanel } from './account-quota-panel';
type AccountSurfaceMode = 'compact' | 'detailed';
type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
interface AccountSurfaceCardProps {
mode: AccountSurfaceMode;
provider: string;
accountId: string;
email?: string;
displayEmail?: string;
tokenFile?: string;
tier?: AccountTier;
isDefault?: boolean;
paused?: boolean;
privacyMode?: boolean;
showQuota?: boolean;
quota?: UnifiedQuotaResult;
quotaLoading?: boolean;
runtimeLastUsed?: string;
beforeIdentity?: ReactNode;
headerEnd?: ReactNode;
compactMetaBadges?: ReactNode;
bodySlot?: ReactNode;
footerSlot?: ReactNode;
quotaInsetClassName?: string;
className?: string;
}
function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') {
if (audience === 'business') {
return 'bg-sky-500/12 text-sky-700 dark:text-sky-300';
}
if (audience === 'personal') {
return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300';
}
return 'bg-muted text-muted-foreground';
}
function getTierBadgeClass(tier: AccountTier | undefined) {
return tier === 'ultra'
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
}
function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unknown') {
if (audience === 'business') return 'Biz';
if (audience === 'personal') return 'Pers';
return '?';
}
export function AccountSurfaceCard({
mode,
provider,
accountId,
email,
displayEmail,
tokenFile,
tier,
isDefault,
paused,
privacyMode,
showQuota,
quota,
quotaLoading,
runtimeLastUsed,
beforeIdentity,
headerEnd,
compactMetaBadges,
bodySlot,
footerSlot,
quotaInsetClassName,
className,
}: AccountSurfaceCardProps) {
const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
const title = displayEmail || identity.email || accountId;
const normalizedProvider = provider.toLowerCase();
const showTierBadge =
(normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
tier &&
tier !== 'unknown' &&
tier !== 'free';
const isCompact = mode === 'compact';
const defaultCompactMetaBadges = (
<>
{showTierBadge && (
<span
className={cn(
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
getTierBadgeClass(tier)
)}
>
{tier}
</span>
)}
{identity.audienceLabel && (
<span
title={identity.audienceLabel}
className={cn(
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
identity.audience === 'business'
? 'bg-sky-500/15 text-sky-700 dark:bg-sky-500/25 dark:text-sky-300'
: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/25 dark:text-emerald-300'
)}
>
{getCompactAudienceBadgeLabel(identity.audience)}
</span>
)}
{paused && (
<span className="text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
Paused
</span>
)}
</>
);
return (
<div className={cn('flex flex-col gap-2', className)}>
<div className="flex items-start justify-between gap-2">
<div className={cn('flex min-w-0 flex-1', isCompact ? 'gap-2' : 'gap-3')}>
{beforeIdentity}
{!isCompact && (
<div className="relative shrink-0">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
{showTierBadge && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded ring-1 ring-background',
tier === 'ultra'
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
)}
>
{tier === 'ultra' ? 'U' : 'P'}
</span>
)}
</div>
)}
<div className="min-w-0 flex-1">
<div
className={cn('flex items-center min-w-0', isCompact ? 'gap-1.5' : 'gap-2 flex-wrap')}
>
<span
title={title}
className={cn(
isCompact
? 'flex-1 min-w-0 text-xs font-semibold tracking-tight truncate leading-none'
: 'font-medium text-sm truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{title}
</span>
{isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
{!isCompact && identity.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
getAudienceBadgeClass(identity.audience)
)}
>
{identity.audienceLabel}
</Badge>
)}
{!isCompact && identity.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{identity.detailLabel}
</Badge>
)}
{!isCompact && 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>
)}
{!isCompact && paused && (
<Badge
variant="outline"
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
>
<Pause className="w-2 h-2 mr-0.5" />
Paused
</Badge>
)}
</div>
{bodySlot && <div className="mt-1">{bodySlot}</div>}
</div>
</div>
{headerEnd && (
<div className={cn('flex items-center shrink-0', isCompact ? 'gap-0.5' : 'gap-1')}>
{headerEnd}
</div>
)}
</div>
{footerSlot}
{showQuota && (
<AccountQuotaPanel
provider={provider}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={runtimeLastUsed}
mode={mode}
className={quotaInsetClassName}
/>
)}
</div>
);
}
@@ -18,6 +18,7 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
import { usePrivacy } from '@/contexts/privacy-context';
import { formatAccountDisplayName } from '@/lib/account-identity';
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
import { isDeniedAgyModelId } from '@/lib/utils';
@@ -220,7 +221,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
{providerAccounts.map((acc) => (
<option key={acc.id} value={acc.id}>
{privacyMode ? '••••••' : acc.email || acc.id}
{privacyMode
? '••••••'
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)}
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
</option>
))}
@@ -3,9 +3,8 @@
* Displays a single OAuth account with actions and quota bar
*/
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,87 +12,69 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { getAccountStats } from '@/lib/cliproxy-account-stats';
import { cn } from '@/lib/utils';
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import {
User,
Star,
MoreHorizontal,
Clock,
Trash2,
AlertTriangle,
Check,
FolderCode,
Loader2,
CheckCircle2,
HelpCircle,
MoreHorizontal,
Pause,
Play,
AlertCircle,
AlertTriangle,
FolderCode,
Check,
KeyRound,
Star,
Trash2,
} from 'lucide-react';
import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { getAccountStats } from '@/lib/cliproxy-account-stats';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import { useTranslation } from 'react-i18next';
import type { AccountItemProps } from './types';
/**
* Get color class based on quota percentage
*/
function getQuotaColor(percentage: number): string {
const clamped = Math.max(0, Math.min(100, percentage));
if (clamped <= 20) return 'bg-destructive';
if (clamped <= 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 '';
function renderProjectId(projectId: string | undefined, privacyMode: boolean | undefined) {
if (projectId) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<FolderCode className="w-3 h-3" aria-hidden="true" />
<span
className={cn(
'font-mono max-w-[180px] truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
title={projectId}
>
{projectId}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">GCP Project ID (read-only)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
}
/**
* 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;
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
<span>Project ID: N/A</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[250px]">
<div className="text-xs space-y-1">
<p className="font-medium text-amber-600">Missing Project ID</p>
<p>This may cause errors. Remove the account and re-add it to fetch the project ID.</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function AccountItem({
@@ -109,390 +90,119 @@ export function AccountItem({
selected,
onSelectChange,
}: AccountItemProps) {
const { t } = useTranslation();
const normalizedProvider = account.provider.toLowerCase();
const isCodexProvider = normalizedProvider === 'codex';
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
const { data: stats } = useCliproxyStats(showQuota);
// Fetch quota for all provider accounts
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
normalizedProvider,
account.id,
showQuota
);
// Get last used time from runtime stats (more accurate than file)
const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt;
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
// Use shared utility functions for provider-specific quota handling
const minQuota = getProviderMinQuota(account.provider, quota);
const nextReset = getProviderResetTime(account.provider, quota);
const codexBreakdown =
isCodexProvider && quota && isCodexQuotaResult(quota)
? getCodexQuotaBreakdown(quota.windows)
: null;
const codexQuotaRows = [
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows =
isClaudeProvider && quota && isClaudeQuotaResult(quota)
? [
{
label: '5h',
value:
quota.coreUsage?.fiveHour?.remainingPercent ??
quota.windows.find((window) => window.rateLimitType === 'five_hour')
?.remainingPercent ??
null,
},
{
label: 'Weekly',
value:
quota.coreUsage?.weekly?.remainingPercent ??
quota.windows.find((window) =>
[
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'seven_day_cowork',
].includes(window.rateLimitType)
)?.remainingPercent ??
null,
},
].filter((row): row is { label: string; value: number } => row.value !== null)
: [];
const dualWindowQuotaRows = isCodexProvider
? codexQuotaRows
: isClaudeProvider
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const failureBadgeClass =
failureInfo?.tone === 'warning'
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
: failureInfo?.tone === 'destructive'
? 'border-destructive/50 text-destructive'
: 'border-muted-foreground/50 text-muted-foreground';
const beforeIdentity =
selectable || onPauseToggle ? (
<div className="flex items-center gap-2 shrink-0">
{selectable && (
<button
type="button"
onClick={() => onSelectChange?.(!selected)}
className={cn(
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/30 hover:border-primary/50'
)}
aria-label={selected ? 'Deselect account' : 'Select account'}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : account.paused ? (
<Play className="w-4 h-4 text-emerald-500" />
) : (
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
) : undefined;
const headerEnd = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<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>
);
return (
<div
className={cn(
'flex flex-col gap-2 p-3 rounded-lg border transition-colors overflow-hidden',
'rounded-lg border p-3 transition-colors overflow-hidden',
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
account.paused && 'opacity-75',
selected && 'ring-2 ring-primary/50 bg-primary/5'
)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Selection checkbox for bulk actions */}
{selectable && (
<button
type="button"
onClick={() => onSelectChange?.(!selected)}
className={cn(
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
selected
? 'bg-primary border-primary text-primary-foreground'
: 'border-muted-foreground/30 hover:border-primary/50'
)}
aria-label={selected ? 'Deselect account' : 'Select account'}
>
{selected && <Check className="w-3 h-3" />}
</button>
)}
{/* Pause/Resume toggle button - visible left of avatar */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => onPauseToggle(!account.paused)}
disabled={isPausingAccount}
>
{isPausingAccount ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : account.paused ? (
<Play className="w-4 h-4 text-emerald-500" />
) : (
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
{account.paused ? 'Resume account' : 'Pause account'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Avatar with tier badge overlay */}
<div className="relative shrink-0">
<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>
{/* Tier badge - fixed position on avatar */}
{account.tier && account.tier !== 'unknown' && account.tier !== 'free' && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded',
'ring-1 ring-background',
account.tier === 'ultra'
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
)}
>
{account.tier === 'ultra' ? 'U' : 'P'}
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn('font-medium text-sm truncate', 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>
)}
{account.paused && (
<Badge
variant="outline"
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
>
<Pause className="w-2 h-2 mr-0.5" />
Paused
</Badge>
)}
</div>
{/* Project ID for Antigravity accounts - read-only */}
{account.provider === 'agy' && (
<div className="flex items-center gap-1.5 mt-1">
{account.projectId ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<FolderCode className="w-3 h-3" aria-hidden="true" />
<span
className={cn(
'font-mono max-w-[180px] truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
title={account.projectId}
>
{account.projectId}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">GCP Project ID (read-only)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
<span>Project ID: N/A</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[250px]">
<div className="text-xs space-y-1">
<p className="font-medium text-amber-600">Missing Project ID</p>
<p>
This may cause errors. Remove the account and re-add it to fetch the
project ID.
</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</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 shrink-0">
<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>
{/* Quota bar - supports all providers with quota API */}
{showQuota && (
<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>
) : minQuotaValue !== 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>
{dualWindowQuotaRows.length > 0 ? (
<div className="space-y-1.5">
{dualWindowQuotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
</span>
<Progress
value={Math.max(0, Math.min(100, row.value))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(row.value)}
/>
<span className="text-xs font-medium w-10 text-right">
{row.value}%
</span>
</div>
))}
</div>
) : (
<div className="flex items-center gap-2">
<Progress
value={Math.max(0, Math.min(100, minQuotaValue))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuotaValue)}
/>
<span className="text-xs font-medium w-10 text-right">
{minQuotaLabel}%
</span>
</div>
)}
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : quota?.success ? (
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<HelpCircle className="w-3 h-3" />
{t('accountCard.quotaUnavailable')}
</Badge>
</div>
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className={cn('text-[10px] h-5 px-2 gap-1', failureBadgeClass)}
>
<FailureIcon className="w-3 h-3" />
{failureInfo.label}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[260px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
</div>
)}
<AccountSurfaceCard
mode="detailed"
provider={account.provider}
accountId={account.id}
email={account.email}
displayEmail={account.email || account.id}
tokenFile={account.tokenFile}
tier={account.tier}
isDefault={account.isDefault}
paused={account.paused}
privacyMode={privacyMode}
showQuota={showQuota}
quota={quota}
quotaLoading={quotaLoading}
runtimeLastUsed={runtimeLastUsed}
beforeIdentity={beforeIdentity}
headerEnd={headerEnd}
bodySlot={
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode) : null
}
quotaInsetClassName="pl-11"
/>
</div>
);
}
@@ -4,12 +4,13 @@
import type React from 'react';
import { ChevronRight, AlertTriangle } from 'lucide-react';
import { formatAccountDisplayName } from '@/lib/account-identity';
import { cn, STATUS_COLORS } from '@/lib/utils';
import { PROVIDER_COLORS } from '@/lib/provider-config';
import { ProviderIcon } from '@/components/shared/provider-icon';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import type { ProviderStats } from '../types';
import { getSuccessRate, cleanEmail } from '../utils';
import { getSuccessRate } from '../utils';
import { InlineStatsBadge } from './inline-stats-badge';
interface ProviderCardProps {
@@ -112,7 +113,11 @@ export function ProviderCard({
<div
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
style={{ backgroundColor: acc.color }}
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
title={
privacyMode
? '••••••'
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)
}
/>
{isMissingProjectId && (
<TooltipProvider>
@@ -5,8 +5,8 @@
import { useState, useMemo, useEffect } from 'react';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
import { getProviderDisplayName } from '@/lib/provider-config';
import { getAccountStats } from '@/lib/cliproxy-account-stats';
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
import type { AccountRow, ProviderStats } from './types';
import { ACCOUNT_COLORS } from './utils';
@@ -74,28 +74,33 @@ export function useAuthMonitorData(): AuthMonitorData {
const providerData = providerMap.get(providerKey);
if (!providerData) return;
status.accounts?.forEach((account: OAuthAccount) => {
const realStats = getAccountStats(statsData, account);
const success = realStats?.successCount ?? 0;
const failure = realStats?.failureCount ?? 0;
tSuccess += success;
tFailure += failure;
providerData.success += success;
providerData.failure += failure;
const normalizedAccounts = (status.accounts ?? []).map((account: OAuthAccount) => ({
...account,
provider: account.provider || status.provider,
}));
buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
tSuccess += groupedAccount.successCount;
tFailure += groupedAccount.failureCount;
providerData.success += groupedAccount.successCount;
providerData.failure += groupedAccount.failureCount;
const row: AccountRow = {
id: account.id,
email: account.email || account.id,
id: groupedAccount.id,
email: groupedAccount.email,
tokenFile: groupedAccount.tokenFile,
provider: status.provider,
displayName: status.displayName,
isDefault: account.isDefault,
successCount: success,
failureCount: failure,
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
isDefault: groupedAccount.isDefault,
successCount: groupedAccount.successCount,
failureCount: groupedAccount.failureCount,
lastUsedAt: groupedAccount.lastUsedAt,
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
projectId: account.projectId,
paused: account.paused,
tier: account.tier,
projectId: groupedAccount.projectId,
paused: groupedAccount.paused,
tier: groupedAccount.tier,
memberIds: groupedAccount.memberIds,
variants: groupedAccount.variants,
};
accountsList.push(row);
providerData.accounts.push(row);
@@ -10,7 +10,12 @@ import { STATUS_COLORS } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
import { AccountFlowViz } from '@/components/account-flow-viz';
import { usePrivacy } from '@/contexts/privacy-context';
import { usePauseAccount, useResumeAccount } from '@/hooks/use-cliproxy';
import {
useBulkPauseAccounts,
useBulkResumeAccounts,
usePauseAccount,
useResumeAccount,
} from '@/hooks/use-cliproxy';
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
import { useAuthMonitorData } from './hooks';
@@ -66,14 +71,37 @@ export function AuthMonitor() {
// Account control mutations for flow viz
const pauseMutation = usePauseAccount();
const resumeMutation = useResumeAccount();
const bulkPauseMutation = useBulkPauseAccounts();
const bulkResumeMutation = useBulkResumeAccounts();
// Get selected provider data for detail view
const selectedProviderData = effectiveProvider
? providerStats.find((ps) => ps.provider === effectiveProvider)
: null;
const handlePauseToggle = (accountId: string, paused: boolean) => {
if (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return;
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
if (
!effectiveProvider ||
pauseMutation.isPending ||
resumeMutation.isPending ||
bulkPauseMutation.isPending ||
bulkResumeMutation.isPending
) {
return;
}
if (accountIds.length > 1) {
if (paused) {
bulkPauseMutation.mutate({ provider: effectiveProvider, accountIds });
} else {
bulkResumeMutation.mutate({ provider: effectiveProvider, accountIds });
}
return;
}
const [accountId] = accountIds;
if (!accountId) return;
if (paused) {
pauseMutation.mutate({ provider: effectiveProvider, accountId });
} else {
@@ -165,7 +193,12 @@ export function AuthMonitor() {
providerData={selectedProviderData}
onBack={() => setSelectedProvider(null)}
onPauseToggle={handlePauseToggle}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isPausingAccount={
pauseMutation.isPending ||
resumeMutation.isPending ||
bulkPauseMutation.isPending ||
bulkResumeMutation.isPending
}
/>
) : (
<div className="p-6">
@@ -2,12 +2,15 @@
* Type definitions for Auth Monitor components
*/
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
export interface AccountRow {
id: string;
email: string;
tokenFile: string;
provider: string;
displayName: string;
isDefault: boolean;
@@ -21,6 +24,10 @@ export interface AccountRow {
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
/** Raw member IDs when one visual card represents multiple underlying auth records */
memberIds?: string[];
/** Raw variant details shown inside grouped visual cards */
variants?: AccountVisualVariant[];
}
export interface ProviderStats {
@@ -3,7 +3,9 @@
*/
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
import { getAccountIdentityPresentation } from '@/lib/account-identity';
import { cn } from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import type { AccountStepProps } from '../types';
@@ -24,29 +26,52 @@ export function AccountStep({
{/* Scrollable account list with max-height for many accounts */}
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
{accounts.map((acc) => (
<button
key={acc.id}
type="button"
onClick={() => onSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div>
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{acc.email || acc.id}
{accounts.map((acc) => {
const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile);
return (
<button
key={acc.id}
type="button"
onClick={() => onSelect(acc)}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
<User className="w-4 h-4 text-muted-foreground" />
</div>
<div className="space-y-1">
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{identity.email}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{identity.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
identity.audience === 'business'
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
)}
>
{identity.audienceLabel}
</Badge>
)}
{identity.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{identity.detailLabel}
</Badge>
)}
{acc.isDefault && (
<span className="text-xs text-muted-foreground">Default account</span>
)}
</div>
</div>
{acc.isDefault && (
<div className="text-xs text-muted-foreground">Default account</div>
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
))}
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</button>
);
})}
</div>
{/* Divider */}
@@ -3,9 +3,11 @@
*/
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getAccountIdentityPresentation } from '@/lib/account-identity';
import {
Select,
SelectContent,
@@ -44,6 +46,13 @@ export function VariantStep({
selectedProvider === 'agy' && modelName.trim().length > 0
? isDeniedAgyModelId(modelName)
: false;
const selectedAccountIdentity = selectedAccount
? getAccountIdentityPresentation(
selectedAccount.id,
selectedAccount.email,
selectedAccount.tokenFile
)
: null;
const handleModelSelect = (value: string) => {
if (value === CUSTOM_MODEL_VALUE) {
@@ -58,14 +67,38 @@ export function VariantStep({
return (
<div className="space-y-4">
{selectedAccount && (
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
<div className="flex items-start gap-2 p-2 bg-muted/50 rounded-md text-sm">
<User className="w-4 h-4" />
<span>
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccount.email || selectedAccount.id}
<div className="space-y-1">
<span>
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccountIdentity?.email}
</span>
</span>
</span>
{(selectedAccountIdentity?.audienceLabel || selectedAccountIdentity?.detailLabel) && (
<div className="flex items-center gap-1.5 flex-wrap">
{selectedAccountIdentity?.audienceLabel && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 border-transparent',
selectedAccountIdentity.audience === 'business'
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
)}
>
{selectedAccountIdentity.audienceLabel}
</Badge>
)}
{selectedAccountIdentity?.detailLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{selectedAccountIdentity.detailLabel}
</Badge>
)}
</div>
)}
</div>
</div>
)}
+26 -11
View File
@@ -2,7 +2,7 @@
* React Query hook for CLIProxyAPI stats
*/
import { useQuery } from '@tanstack/react-query';
import { useQueries, useQuery } from '@tanstack/react-query';
import type {
ModelQuota,
QuotaResult,
@@ -334,6 +334,21 @@ async function fetchGhcpQuotaApi(accountId: string): Promise<GhcpQuotaResult> {
// Re-export unified type from utils for consumers
export type { UnifiedQuotaResult } from '@/lib/utils';
function getAccountQuotaQueryOptions(provider: string, accountId: string, enabled = true) {
const canonicalProvider = normalizeQuotaProvider(provider);
return {
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
enabled: enabled && !!canonicalProvider && !!accountId,
staleTime: 60000,
refetchInterval: 60000,
refetchOnWindowFocus: false,
refetchOnMount: false,
retry: 1 as const,
};
}
/**
* Fetch quota by provider (dispatcher)
*/
@@ -365,16 +380,16 @@ async function fetchQuotaByProvider(
* Supports agy, codex, claude, gemini, and ghcp providers
*/
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
const canonicalProvider = normalizeQuotaProvider(provider);
return useQuery(getAccountQuotaQueryOptions(provider, accountId, enabled));
}
return useQuery({
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
enabled: enabled && !!canonicalProvider && !!accountId,
staleTime: 60000, // Match refetchInterval to prevent early refetching
refetchInterval: 60000, // Refresh every 1 minute
refetchOnWindowFocus: false, // Don't refetch on tab switch
refetchOnMount: false, // Don't refetch on component remount (AuthMonitor re-renders)
retry: 1,
export function useAccountQuotas(
accounts: Array<{ provider: string; accountId: string }>,
enabled = true
) {
return useQueries({
queries: accounts.map((account) =>
getAccountQuotaQueryOptions(account.provider, account.accountId, enabled)
),
});
}
+213
View File
@@ -0,0 +1,213 @@
const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']);
const BUSINESS_PLAN_PARTS = new Set(['team']);
// Keep variant parsing aligned with src/cliproxy/accounts/email-account-identity.ts.
// This browser copy stays local because the server module is not bundle-safe for the UI.
export type AccountAudience = 'business' | 'personal' | 'unknown';
export interface AccountIdentityPresentation {
email: string;
audience: AccountAudience;
audienceLabel: string | null;
detailLabel: string | null;
compactDetailLabel: string | null;
inlineLabel: string | null;
}
function normalizeVariantTokenPart(value: string): string {
return value
.trim()
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
.replace(/[^a-z0-9._-]+/gi, '-')
.replace(/-+/g, '-')
.toLowerCase();
}
function formatVariantPart(part: string): string {
const normalized = part.trim().toLowerCase();
if (!normalized) {
return '';
}
switch (normalized) {
case 'team':
return 'Team';
case 'free':
return 'Free';
case 'plus':
return 'Plus';
case 'pro':
return 'Pro';
default:
return /^[a-f0-9]{8}$/i.test(normalized)
? normalized
: normalized
.split(/[._-]+/)
.filter(Boolean)
.map((segment) => segment[0]?.toUpperCase() + segment.slice(1))
.join(' ');
}
}
function extractCanonicalEmailFromAccountId(accountId: string): string | null {
const canonical = accountId.split('#')[0]?.trim();
return canonical && canonical.includes('@') ? canonical : null;
}
function extractVariantKeyFromAccountId(accountId: string, email?: string): string | null {
if (!email) {
return null;
}
const prefix = `${email}#`;
return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null;
}
function extractVariantKeyFromTokenFile(tokenFile?: string, email?: string): string | null {
if (!tokenFile || !email) {
return null;
}
const fileName = tokenFile.split(/[\\/]/).pop() ?? tokenFile;
const baseName = fileName.replace(/\.json$/i, '');
if (!baseName.toLowerCase().startsWith('codex-')) {
return null;
}
const firstDashIndex = baseName.indexOf('-');
const candidate =
firstDashIndex > 0 && !baseName.slice(0, firstDashIndex).includes('@')
? baseName.slice(firstDashIndex + 1)
: baseName;
const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase());
if (emailIndex === -1) {
return null;
}
const before = normalizeVariantTokenPart(candidate.slice(0, emailIndex));
const after = normalizeVariantTokenPart(candidate.slice(emailIndex + email.length));
const parts = [before, after].filter(Boolean);
return parts.length > 0 ? parts.join('-') : null;
}
function formatWorkspaceLabel(parts: string[]): {
detailLabel: string | null;
compactDetailLabel: string | null;
} {
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
if (workspaceId) {
return {
detailLabel: `Workspace ${workspaceId.toLowerCase()}`,
compactDetailLabel: workspaceId.toLowerCase(),
};
}
const extraLabel = parts.map(formatVariantPart).filter(Boolean).join(' · ');
return {
detailLabel: extraLabel || 'Team',
compactDetailLabel: extraLabel || 'Team',
};
}
export function extractAccountVariantKey(
accountId: string,
email?: string,
tokenFile?: string
): string | null {
const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || undefined;
return (
extractVariantKeyFromTokenFile(tokenFile, resolvedEmail) ??
extractVariantKeyFromAccountId(accountId, resolvedEmail)
);
}
export function getAccountIdentityPresentation(
accountId: string,
email?: string,
tokenFile?: string
): AccountIdentityPresentation {
const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || accountId;
const variantKey = extractAccountVariantKey(accountId, resolvedEmail, tokenFile);
if (!variantKey) {
return {
email: resolvedEmail,
audience: 'unknown',
audienceLabel: null,
detailLabel: null,
compactDetailLabel: null,
inlineLabel: null,
};
}
const parts = variantKey.split('-').filter(Boolean);
if (parts.length === 0) {
return {
email: resolvedEmail,
audience: 'unknown',
audienceLabel: null,
detailLabel: null,
compactDetailLabel: null,
inlineLabel: null,
};
}
const suffix = parts[parts.length - 1]?.toLowerCase();
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
const workspace = formatWorkspaceLabel(parts.slice(0, -1));
const inlineLabel = ['Business', workspace.detailLabel].filter(Boolean).join(' · ');
return {
email: resolvedEmail,
audience: 'business',
audienceLabel: 'Business',
detailLabel: workspace.detailLabel,
compactDetailLabel: workspace.compactDetailLabel,
inlineLabel,
};
}
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
const detailParts = [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
.filter(Boolean)
.join(' · ');
const detailLabel = detailParts || formatVariantPart(suffix);
const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · ');
return {
email: resolvedEmail,
audience: 'personal',
audienceLabel: 'Personal',
detailLabel,
compactDetailLabel: detailLabel,
inlineLabel,
};
}
const fallbackLabel = parts.map(formatVariantPart).filter(Boolean).join(' · ');
return {
email: resolvedEmail,
audience: 'unknown',
audienceLabel: null,
detailLabel: fallbackLabel || null,
compactDetailLabel: fallbackLabel || null,
inlineLabel: fallbackLabel || null,
};
}
export function formatAccountVariantLabel(
accountId: string,
email?: string,
tokenFile?: string
): string | null {
return getAccountIdentityPresentation(accountId, email, tokenFile).inlineLabel;
}
export function formatAccountDisplayName(
accountId: string,
email?: string,
tokenFile?: string
): string {
const presentation = getAccountIdentityPresentation(accountId, email, tokenFile);
return presentation.inlineLabel
? `${presentation.email} (${presentation.inlineLabel})`
: presentation.email;
}
+148
View File
@@ -0,0 +1,148 @@
import type { OAuthAccount } from '@/lib/api-client';
import { getAccountIdentityPresentation, type AccountAudience } from '@/lib/account-identity';
import { getAccountStats } from '@/lib/cliproxy-account-stats';
import type { CliproxyStats } from '@/hooks/use-cliproxy-stats';
export interface AccountVisualVariant {
id: string;
email: string;
tokenFile: string;
isDefault: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
paused?: boolean;
tier?: OAuthAccount['tier'];
audience: AccountAudience;
audienceLabel: string | null;
detailLabel: string | null;
}
export interface AccountVisualGroup {
id: string;
email: string;
tokenFile: string;
provider: OAuthAccount['provider'];
isDefault: boolean;
successCount: number;
failureCount: number;
lastUsedAt?: string;
paused?: boolean;
tier?: OAuthAccount['tier'];
projectId?: string;
memberIds?: string[];
variants?: AccountVisualVariant[];
}
const AUDIENCE_ORDER: Record<AccountAudience, number> = {
business: 0,
personal: 1,
unknown: 2,
};
function getLatestTimestamp(current?: string, candidate?: string): string | undefined {
if (!candidate) return current;
if (!current) return candidate;
return new Date(candidate).getTime() > new Date(current).getTime() ? candidate : current;
}
function buildAccountVariant(
account: OAuthAccount,
statsData?: Pick<CliproxyStats, 'accountStats'> | null
): AccountVisualVariant {
const identity = getAccountIdentityPresentation(account.id, account.email, account.tokenFile);
const runtimeStats = getAccountStats(statsData, account);
return {
id: account.id,
email: identity.email || account.email || account.id,
tokenFile: account.tokenFile,
isDefault: account.isDefault,
successCount: runtimeStats?.successCount ?? 0,
failureCount: runtimeStats?.failureCount ?? 0,
lastUsedAt: runtimeStats?.lastUsedAt ?? account.lastUsedAt,
paused: account.paused,
tier: account.tier,
audience: identity.audience,
audienceLabel: identity.audienceLabel,
detailLabel: identity.detailLabel,
};
}
function sortAccountVariants(variants: AccountVisualVariant[]): AccountVisualVariant[] {
return [...variants].sort((left, right) => {
const audienceDelta = AUDIENCE_ORDER[left.audience] - AUDIENCE_ORDER[right.audience];
if (audienceDelta !== 0) {
return audienceDelta;
}
const leftLabel = left.audienceLabel ?? left.detailLabel ?? left.id;
const rightLabel = right.audienceLabel ?? right.detailLabel ?? right.id;
return leftLabel.localeCompare(rightLabel);
});
}
export function buildAccountVisualGroups(
accounts: OAuthAccount[],
statsData?: Pick<CliproxyStats, 'accountStats'> | null
): AccountVisualGroup[] {
const buckets = new Map<string, AccountVisualVariant[]>();
const accountMeta = new Map<string, OAuthAccount>();
for (const account of accounts) {
const variant = buildAccountVariant(account, statsData);
const isCodexProvider = account.provider.toLowerCase() === 'codex';
const bucketKey = isCodexProvider ? `${account.provider}:${variant.email}` : account.id;
if (!buckets.has(bucketKey)) {
buckets.set(bucketKey, []);
}
buckets.get(bucketKey)?.push(variant);
accountMeta.set(account.id, account);
}
return Array.from(buckets.entries()).map(([bucketKey, variants]) => {
if (variants.length === 1) {
const [variant] = variants;
const original = accountMeta.get(variant.id);
return {
id: variant.id,
email: variant.email,
tokenFile: variant.tokenFile,
provider: original?.provider ?? 'codex',
isDefault: variant.isDefault,
successCount: variant.successCount,
failureCount: variant.failureCount,
lastUsedAt: variant.lastUsedAt,
paused: variant.paused,
tier: variant.tier,
projectId: original?.projectId,
};
}
const orderedVariants = sortAccountVariants(variants);
const canonicalEmail = orderedVariants[0]?.email ?? bucketKey;
const originalProvider = accountMeta.get(orderedVariants[0]?.id ?? '')?.provider ?? 'codex';
return {
id: bucketKey,
email: canonicalEmail,
tokenFile: orderedVariants[0]?.tokenFile ?? '',
provider: originalProvider,
isDefault: orderedVariants.some((variant) => variant.isDefault),
successCount: orderedVariants.reduce((sum, variant) => sum + variant.successCount, 0),
failureCount: orderedVariants.reduce((sum, variant) => sum + variant.failureCount, 0),
lastUsedAt: orderedVariants.reduce<string | undefined>(
(latest, variant) => getLatestTimestamp(latest, variant.lastUsedAt),
undefined
),
paused: orderedVariants.every((variant) => Boolean(variant.paused)),
memberIds: orderedVariants.map((variant) => variant.id),
variants: orderedVariants,
};
});
}
+5 -3
View File
@@ -912,15 +912,17 @@ export const api = {
body: JSON.stringify({ accountId }),
}),
remove: (provider: string, accountId: string) =>
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
request(`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}`, {
method: 'DELETE',
}),
pause: (provider: string, accountId: string) =>
request<{ provider: string; accountId: string; paused: boolean }>(
`/cliproxy/auth/accounts/${provider}/${accountId}/pause`,
`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/pause`,
{ method: 'POST' }
),
resume: (provider: string, accountId: string) =>
request<{ provider: string; accountId: string; paused: boolean }>(
`/cliproxy/auth/accounts/${provider}/${accountId}/resume`,
`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/resume`,
{ method: 'POST' }
),
/** Solo mode: activate one account, pause all others */
+12 -3
View File
@@ -9,8 +9,17 @@ export function getAccountStats(
stats: Pick<CliproxyStats, 'accountStats'> | null | undefined,
account: Pick<OAuthAccount, 'provider' | 'email' | 'id'>
): AccountUsageStats | undefined {
const source = account.email || account.id;
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
const sources = Array.from(
new Set([account.id, account.email].filter((value): value is string => Boolean(value?.trim())))
);
return stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
for (const source of sources) {
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
const match = stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
if (match) {
return match;
}
}
return undefined;
}
@@ -22,7 +22,9 @@ function mockRandom(): void {
});
}
function makeAccount(overrides: Partial<AccountData> & Pick<AccountData, 'id' | 'email'>): AccountData {
function makeAccount(
overrides: Partial<AccountData> & Pick<AccountData, 'id' | 'email'>
): AccountData {
return {
provider: 'agy',
successCount: 0,
@@ -170,9 +172,7 @@ describe('generateConnectionEvents()', () => {
it('all generated timestamps are not in the future', () => {
const now = new Date();
const accounts: AccountData[] = [
makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 }),
];
const accounts: AccountData[] = [makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 })];
const events = generateConnectionEvents(accounts);
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
formatAccountDisplayName,
formatAccountVariantLabel,
getAccountIdentityPresentation,
} from '@/lib/account-identity';
describe('account identity presentation', () => {
it('formats duplicate-email team accounts as business workspace labels', () => {
const presentation = getAccountIdentityPresentation(
'kaidu.kd@gmail.com#04a0f049-team',
'kaidu.kd@gmail.com'
);
expect(presentation.audience).toBe('business');
expect(presentation.audienceLabel).toBe('Business');
expect(presentation.detailLabel).toBe('Workspace 04a0f049');
expect(presentation.compactDetailLabel).toBe('04a0f049');
expect(presentation.inlineLabel).toBe('Business · Workspace 04a0f049');
});
it('can derive business workspace labels from token file when account id is plain email', () => {
const presentation = getAccountIdentityPresentation(
'kaidu.kd@gmail.com',
'kaidu.kd@gmail.com',
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
);
expect(presentation.audience).toBe('business');
expect(presentation.audienceLabel).toBe('Business');
expect(presentation.detailLabel).toBe('Workspace 04a0f049');
expect(
formatAccountVariantLabel(
'kaidu.kd@gmail.com',
'kaidu.kd@gmail.com',
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
)
).toBe('Business · Workspace 04a0f049');
});
it('formats personal codex plans deliberately instead of leaking raw free suffixes', () => {
expect(
formatAccountDisplayName(
'kaidu.kd@gmail.com',
'kaidu.kd@gmail.com',
'codex-kaidu.kd@gmail.com-free.json'
)
).toBe('kaidu.kd@gmail.com (Personal · Free)');
});
it('leaves plain accounts without inferred state untouched', () => {
const presentation = getAccountIdentityPresentation('user@example.com', 'user@example.com');
expect(presentation.audience).toBe('unknown');
expect(presentation.audienceLabel).toBeNull();
expect(presentation.detailLabel).toBeNull();
expect(formatAccountDisplayName('user@example.com', 'user@example.com')).toBe(
'user@example.com'
);
});
});
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import type { OAuthAccount } from '@/lib/api-client';
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
function makeAccount(overrides: Partial<OAuthAccount> & Pick<OAuthAccount, 'id' | 'tokenFile'>) {
return {
id: overrides.id,
email: 'kaidu.kd@gmail.com',
provider: 'codex',
isDefault: false,
tokenFile: overrides.tokenFile,
createdAt: '2026-03-30T00:00:00.000Z',
...overrides,
} satisfies OAuthAccount;
}
describe('buildAccountVisualGroups', () => {
it('orders grouped codex variants by audience consistently', () => {
const groups = buildAccountVisualGroups([
makeAccount({
id: 'kaidu.kd@gmail.com#free',
tokenFile: 'codex-kaidu.kd@gmail.com-free.json',
}),
makeAccount({
id: 'kaidu.kd@gmail.com#04a0f049-team',
tokenFile: 'codex-04a0f049-kaidu.kd@gmail.com-team.json',
}),
]);
expect(groups).toHaveLength(1);
expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual([
'business',
'personal',
]);
expect(groups[0]?.memberIds).toEqual([
'kaidu.kd@gmail.com#04a0f049-team',
'kaidu.kd@gmail.com#free',
]);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { api } from '@/lib/api-client';
function createEmptyJsonResponse(status = 200): Response {
return new Response('{}', {
status,
headers: { 'Content-Type': 'application/json' },
});
}
describe('cliproxy account API client', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('encodes duplicate-email account ids for account auth actions', async () => {
const fetchMock = vi.fn(() => Promise.resolve(createEmptyJsonResponse()));
vi.stubGlobal('fetch', fetchMock);
const accountId = 'kaidu.kd@gmail.com#04a0f049-team';
const encodedAccountId = encodeURIComponent(accountId);
await api.cliproxy.accounts.remove('codex', accountId);
await api.cliproxy.accounts.pause('codex', accountId);
await api.cliproxy.accounts.resume('codex', accountId);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}`
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}/pause`
);
expect(fetchMock.mock.calls[2]?.[0]).toBe(
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}/resume`
);
});
});