mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-04 06:19:31 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user