feat(cliproxy): support duplicate-email codex accounts

- keep Codex team and personal auth files as separate identities

- resolve quota and live monitor stats by token file-backed account id

- surface duplicate-aware account labels across the dashboard and variant UI
This commit is contained in:
Tam Nhu Tran
2026-03-30 15:30:11 -04:00
parent 43ea5554e6
commit c73f33872a
23 changed files with 781 additions and 143 deletions
@@ -0,0 +1,149 @@
import type { CLIProxyProvider } from '../types';
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['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;
}
+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)
+42 -16
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,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 || '',
+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,
@@ -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');
@@ -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,
});
});
});
@@ -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 (
<div
@@ -226,6 +228,11 @@ export function AccountCard({
>
{cleanEmail(account.email)}
</span>
{variantLabel && (
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-muted text-muted-foreground">
{variantLabel}
</span>
)}
{showTierBadge && (
<span
className={cn(
@@ -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,7 @@ 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.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
</option>
))}
@@ -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 (
<div
@@ -274,6 +276,11 @@ export function AccountItem({
>
{account.email || account.id}
</span>
{variantLabel && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
{variantLabel}
</Badge>
)}
{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" />
@@ -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({
<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)}
/>
{isMissingProjectId && (
<TooltipProvider>
@@ -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({
</div>
<div>
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
{acc.email || acc.id}
{formatAccountDisplayName(acc.id, acc.email)}
</div>
{acc.isDefault && (
<div className="text-xs text-muted-foreground">Default account</div>
@@ -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({
<span>
{t('setupVariant.using')}{' '}
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
{selectedAccount.email || selectedAccount.id}
{formatAccountDisplayName(selectedAccount.id, selectedAccount.email)}
</span>
</span>
</div>
+61
View File
@@ -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;
}
+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);