diff --git a/src/cliproxy/accounts/email-account-identity.ts b/src/cliproxy/accounts/email-account-identity.ts new file mode 100644 index 00000000..a6a07a6d --- /dev/null +++ b/src/cliproxy/accounts/email-account-identity.ts @@ -0,0 +1,149 @@ +import type { CLIProxyProvider } from '../types'; + +const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set(['codex']); + +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; +} diff --git a/src/cliproxy/accounts/query.ts b/src/cliproxy/accounts/query.ts index 4cee1123..22748d4d 100644 --- a/src/cliproxy/accounts/query.ts +++ b/src/cliproxy/accounts/query.ts @@ -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( diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index c230c89e..7d755229 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -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 = { - 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 { + const counts = new Map(); + + 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 = { + 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; diff --git a/src/cliproxy/auth/token-manager.ts b/src/cliproxy/auth/token-manager.ts index 788ebe73..1a4a1e16 100644 --- a/src/cliproxy/auth/token-manager.ts +++ b/src/cliproxy/auth/token-manager.ts @@ -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; const { registerAccount } = require('../account-manager'); let selectedCandidate: Omit | 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(); + const duplicateEmailTokenSets = new Map>(); + for (const account of existingAccounts) { + if (!account.email) continue; + const key = account.email.toLowerCase(); + const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set(); + 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(); + 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 diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index dfef56f5..1715d1bc 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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 " 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) diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index 52ef60cc..6adf5e72 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -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,44 @@ 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); + 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 = extractCanonicalEmailFromAccountId(accountId) ?? accountId; + const sanitizedId = sanitizeEmail(legacyEmail); const expectedFile = `codex-${sanitizedId}.json`; for (const authDir of authDirs) { @@ -184,19 +220,9 @@ 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; } } @@ -208,7 +234,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 || '', diff --git a/src/cliproxy/stats-transformer.ts b/src/cliproxy/stats-transformer.ts index 74c10aa0..a525b1d8 100644 --- a/src/cliproxy/stats-transformer.ts +++ b/src/cliproxy/stats-transformer.ts @@ -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 { const lookup = new Map(); + const duplicateEmailCounts = new Map(); + + 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 { + 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; diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index ae0c8573..2ff268ed 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -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 { 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 { 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 { 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 { } 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 { 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 { } 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')); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 99dcef7f..be9cae13 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -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 ) ); diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index f8c74e6d..cd98a259 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -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) { diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 471174c7..c893324a 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -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 { 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'); + }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index 23473109..dd4a4c04 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -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, @@ -409,6 +411,55 @@ 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('maps 403 responses to forbidden metadata', async () => { createValidCodexAccount('forbidden@example.com', 'workspace-forbidden'); diff --git a/tests/unit/cliproxy/stats-transformer.test.ts b/tests/unit/cliproxy/stats-transformer.test.ts index a58482be..dd32aeab 100644 --- a/tests/unit/cliproxy/stats-transformer.test.ts +++ b/tests/unit/cliproxy/stats-transformer.test.ts @@ -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, + }); + }); }); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 2d8bbfe3..af63b795 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -12,6 +12,7 @@ import { isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; +import { formatAccountVariantLabel } from '@/lib/account-identity'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, @@ -188,6 +189,7 @@ export function AccountCard({ account.tier && account.tier !== 'unknown' && account.tier !== 'free'; + const variantLabel = formatAccountVariantLabel(account.id, account.email); return (
{cleanEmail(account.email)} + {variantLabel && ( + + {variantLabel} + + )} {showTierBadge && ( {t('cliproxyDialog.useDefaultAccount')} {providerAccounts.map((acc) => ( ))} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 85ae03c5..c749656b 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -40,6 +40,7 @@ import { isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; +import { formatAccountVariantLabel } from '@/lib/account-identity'; import { getAccountStats } from '@/lib/cliproxy-account-stats'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; @@ -187,6 +188,7 @@ export function AccountItem({ : failureInfo?.tone === 'destructive' ? 'border-destructive/50 text-destructive' : 'border-muted-foreground/50 text-muted-foreground'; + const variantLabel = formatAccountVariantLabel(account.id, account.email); return (
{account.email || account.id} + {variantLabel && ( + + {variantLabel} + + )} {account.isDefault && ( diff --git a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx index 59af0ac4..04ad2534 100644 --- a/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx +++ b/ui/src/components/monitoring/auth-monitor/components/provider-card.tsx @@ -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,7 @@ export function ProviderCard({
{isMissingProjectId && ( diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index 0d9da4c6..339b1ae7 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react'; +import { formatAccountDisplayName } from '@/lib/account-identity'; import { cn } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import type { AccountStepProps } from '../types'; @@ -37,7 +38,7 @@ export function AccountStep({
- {acc.email || acc.id} + {formatAccountDisplayName(acc.id, acc.email)}
{acc.isDefault && (
Default account
diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 1e185081..0961fb37 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -6,6 +6,7 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { formatAccountDisplayName } from '@/lib/account-identity'; import { Select, SelectContent, @@ -63,7 +64,7 @@ export function VariantStep({ {t('setupVariant.using')}{' '} - {selectedAccount.email || selectedAccount.id} + {formatAccountDisplayName(selectedAccount.id, selectedAccount.email)}
diff --git a/ui/src/lib/account-identity.ts b/ui/src/lib/account-identity.ts new file mode 100644 index 00000000..d28e9a29 --- /dev/null +++ b/ui/src/lib/account-identity.ts @@ -0,0 +1,61 @@ +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(' '); + } +} + +export function extractAccountVariantKey(accountId: string, email?: string): string | null { + if (!email) { + return null; + } + + const prefix = `${email}#`; + return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null; +} + +export function formatAccountVariantLabel(accountId: string, email?: string): string | null { + const variantKey = extractAccountVariantKey(accountId, email); + if (!variantKey) { + return null; + } + + const parts = variantKey.split('-').filter(Boolean); + if (parts.length === 0) { + return null; + } + + const genericSuffix = parts[parts.length - 1]; + if (['team', 'free', 'plus', 'pro'].includes(genericSuffix)) { + return [formatVariantPart(genericSuffix), ...parts.slice(0, -1).map(formatVariantPart)] + .filter(Boolean) + .join(' · '); + } + + return parts.map(formatVariantPart).filter(Boolean).join(' · '); +} + +export function formatAccountDisplayName(accountId: string, email?: string): string { + const base = email || accountId; + const variantLabel = formatAccountVariantLabel(accountId, email); + return variantLabel ? `${base} (${variantLabel})` : base; +} diff --git a/ui/src/lib/cliproxy-account-stats.ts b/ui/src/lib/cliproxy-account-stats.ts index a9acd8ec..043fbad7 100644 --- a/ui/src/lib/cliproxy-account-stats.ts +++ b/ui/src/lib/cliproxy-account-stats.ts @@ -9,8 +9,17 @@ export function getAccountStats( stats: Pick | null | undefined, account: Pick ): 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; } diff --git a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts index 4a7c1eac..3d70a166 100644 --- a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts +++ b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts @@ -22,7 +22,9 @@ function mockRandom(): void { }); } -function makeAccount(overrides: Partial & Pick): AccountData { +function makeAccount( + overrides: Partial & Pick +): 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);