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) {