mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +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 { CLIProxyProvider } from '../types';
|
||||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||||
import { AccountInfo } from './types';
|
import { AccountInfo } from './types';
|
||||||
import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
|
import { hydrateRegistryFromTokenFiles, loadAccountsRegistry } from './registry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all accounts for a provider
|
* Get all accounts for a provider
|
||||||
@@ -14,8 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
|
|||||||
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
|
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
|
||||||
const registry = loadAccountsRegistry();
|
const registry = loadAccountsRegistry();
|
||||||
|
|
||||||
// Sync in-memory view with actual token files without mutating disk on read.
|
// Hydrate the in-memory view from token files without mutating disk on read.
|
||||||
syncRegistryWithTokenFiles(registry);
|
hydrateRegistryFromTokenFiles(registry);
|
||||||
|
|
||||||
const providerAccounts = registry.providers[provider];
|
const providerAccounts = registry.providers[provider];
|
||||||
|
|
||||||
@@ -55,14 +55,16 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
|
|||||||
const accounts = getProviderAccounts(provider);
|
const accounts = getProviderAccounts(provider);
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
|
|
||||||
// Exact match first (id, email, nickname)
|
const exactIdMatch = accounts.find((a) => a.id === query);
|
||||||
const exactMatch = accounts.find(
|
if (exactIdMatch) return exactIdMatch;
|
||||||
(a) =>
|
|
||||||
a.id === query ||
|
const emailMatches = accounts.filter((a) => a.email?.toLowerCase() === lowerQuery);
|
||||||
a.email?.toLowerCase() === lowerQuery ||
|
if (emailMatches.length === 1) return emailMatches[0];
|
||||||
a.nickname?.toLowerCase() === lowerQuery
|
if (emailMatches.length > 1) return null;
|
||||||
);
|
|
||||||
if (exactMatch) return exactMatch;
|
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
|
// Partial match on nickname or email prefix
|
||||||
const partialMatches = accounts.filter(
|
const partialMatches = accounts.filter(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
deleteTokenFile,
|
deleteTokenFile,
|
||||||
listRecoverableTokenFiles,
|
listRecoverableTokenFiles,
|
||||||
} from './token-file-ops';
|
} from './token-file-ops';
|
||||||
|
import { buildEmailBackedAccountId, buildEmailBackedNickname } from './email-account-identity';
|
||||||
|
|
||||||
/** Default registry structure */
|
/** Default registry structure */
|
||||||
function createDefaultRegistry(): AccountsRegistry {
|
function createDefaultRegistry(): AccountsRegistry {
|
||||||
@@ -90,6 +91,16 @@ interface RegistryPopulationIssue {
|
|||||||
reason: string;
|
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 {
|
function describeRegistryPopulationIssue(issue: RegistryPopulationIssue): string {
|
||||||
const sourceDir = issue.paused ? 'auth-paused' : 'auth';
|
const sourceDir = issue.paused ? 'auth-paused' : 'auth';
|
||||||
return `${sourceDir}/${issue.tokenFile} (${issue.reason})`;
|
return `${sourceDir}/${issue.tokenFile} (${issue.reason})`;
|
||||||
@@ -105,10 +116,15 @@ function getRegistryPopulationIssueReason(error: unknown): string {
|
|||||||
return 'unreadable token file';
|
return 'unreadable token file';
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateRegistryFromTokenFiles(
|
function buildProviderEmailCountKey(provider: CLIProxyProvider, email: string): string {
|
||||||
registry: AccountsRegistry,
|
return `${provider}:${email.trim().toLowerCase()}`;
|
||||||
options: { includePaused?: boolean } = {}
|
}
|
||||||
): RegistryPopulationIssue[] {
|
|
||||||
|
function readRecoverableTokenFiles(options: { includePaused?: boolean } = {}): {
|
||||||
|
tokens: ParsedRecoverableTokenFile[];
|
||||||
|
issues: RegistryPopulationIssue[];
|
||||||
|
} {
|
||||||
|
const tokens: ParsedRecoverableTokenFile[] = [];
|
||||||
const issues: RegistryPopulationIssue[] = [];
|
const issues: RegistryPopulationIssue[] = [];
|
||||||
|
|
||||||
for (const token of listRecoverableTokenFiles(options)) {
|
for (const token of listRecoverableTokenFiles(options)) {
|
||||||
@@ -133,69 +149,134 @@ function populateRegistryFromTokenFiles(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerAccounts = ensureProviderRegistry(registry, provider);
|
|
||||||
const projectId =
|
|
||||||
typeof data.project_id === 'string' && data.project_id.trim()
|
|
||||||
? data.project_id.trim()
|
|
||||||
: null;
|
|
||||||
const email =
|
const email =
|
||||||
typeof data.email === 'string' && data.email.trim()
|
typeof data.email === 'string' && data.email.trim()
|
||||||
? data.email.trim()
|
? data.email.trim()
|
||||||
: inferEmailFromTokenFileName(token.tokenFile, provider);
|
: 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(
|
tokens.push({
|
||||||
([, 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,
|
|
||||||
tokenFile: token.tokenFile,
|
tokenFile: token.tokenFile,
|
||||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
filePath: token.filePath,
|
||||||
lastUsedAt: (stats.mtime || stats.birthtime || new Date()).toISOString(),
|
paused: token.paused,
|
||||||
};
|
provider,
|
||||||
|
email,
|
||||||
if (token.paused) {
|
projectId,
|
||||||
accountMeta.paused = true;
|
stats: fs.statSync(token.filePath),
|
||||||
}
|
});
|
||||||
|
|
||||||
if (provider === 'agy' && projectId) {
|
|
||||||
accountMeta.projectId = projectId;
|
|
||||||
}
|
|
||||||
|
|
||||||
providerAccounts.accounts[accountId] = accountMeta;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
issues.push({
|
issues.push({
|
||||||
tokenFile: token.tokenFile,
|
tokenFile: token.tokenFile,
|
||||||
paused: token.paused,
|
paused: token.paused,
|
||||||
reason: getRegistryPopulationIssueReason(error),
|
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;
|
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;
|
return issues;
|
||||||
@@ -384,6 +465,17 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
|
|||||||
return modified;
|
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
|
* Register a new account
|
||||||
* Called after successful OAuth to record the account
|
* Called after successful OAuth to record the account
|
||||||
@@ -448,8 +540,60 @@ export function registerAccount(
|
|||||||
accountNickname =
|
accountNickname =
|
||||||
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
|
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
|
||||||
} else {
|
} else {
|
||||||
accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
const sameEmailEntries = email
|
||||||
accountNickname = nickname || generateNickname(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;
|
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 { getProviderAuthDir } from '../config-generator';
|
||||||
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
||||||
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
|
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
|
||||||
|
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
|
||||||
import {
|
import {
|
||||||
AuthStatus,
|
AuthStatus,
|
||||||
PROVIDER_AUTH_PREFIXES,
|
PROVIDER_AUTH_PREFIXES,
|
||||||
@@ -215,6 +216,7 @@ export function registerAccountFromToken(
|
|||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
alreadyRegistered: boolean;
|
alreadyRegistered: boolean;
|
||||||
};
|
};
|
||||||
|
type RawTokenCandidate = Omit<TokenCandidate, 'accountId'>;
|
||||||
|
|
||||||
const { registerAccount } = require('../account-manager');
|
const { registerAccount } = require('../account-manager');
|
||||||
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
|
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
|
||||||
@@ -222,33 +224,71 @@ export function registerAccountFromToken(
|
|||||||
const files = fs.readdirSync(tokenDir);
|
const files = fs.readdirSync(tokenDir);
|
||||||
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
||||||
const existingAccounts = getProviderAccounts(provider);
|
const existingAccounts = getProviderAccounts(provider);
|
||||||
const candidates: TokenCandidate[] = jsonFiles
|
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
|
||||||
.map((file): TokenCandidate | null => {
|
const filePath = path.join(tokenDir, file);
|
||||||
const filePath = path.join(tokenDir, file);
|
if (!isTokenFileForProvider(filePath, provider)) return [];
|
||||||
if (!isTokenFileForProvider(filePath, provider)) return null;
|
|
||||||
|
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
const data = JSON.parse(content) as { email?: string; project_id?: string };
|
const data = JSON.parse(content) as { email?: string; project_id?: string };
|
||||||
const email = data.email || undefined;
|
const email = data.email || undefined;
|
||||||
const projectId = data.project_id || undefined;
|
const projectId = data.project_id || undefined;
|
||||||
const accountId = extractAccountIdFromTokenFile(file, email);
|
const stats = fs.statSync(filePath);
|
||||||
const stats = fs.statSync(filePath);
|
|
||||||
return {
|
return [
|
||||||
|
{
|
||||||
file,
|
file,
|
||||||
filePath,
|
filePath,
|
||||||
email,
|
email,
|
||||||
projectId,
|
projectId,
|
||||||
accountId,
|
|
||||||
mtimeMs: stats.mtimeMs,
|
mtimeMs: stats.mtimeMs,
|
||||||
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
|
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);
|
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||||
|
|
||||||
if (expectedAccountId) {
|
if (expectedAccountId) {
|
||||||
selectedCandidate =
|
selectedCandidate =
|
||||||
candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
|
candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
|
||||||
|
candidates.find((candidate) => candidate.file === expectedAccountId) ||
|
||||||
candidates.find((candidate) => {
|
candidates.find((candidate) => {
|
||||||
const existingAccount = existingAccounts.find(
|
const existingAccount = existingAccounts.find(
|
||||||
(account) => account.id === expectedAccountId
|
(account) => account.id === expectedAccountId
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
renameAccount,
|
renameAccount,
|
||||||
getDefaultAccount,
|
getDefaultAccount,
|
||||||
} from '../account-manager';
|
} from '../account-manager';
|
||||||
|
import { formatAccountDisplayName } from '../accounts/email-account-identity';
|
||||||
import {
|
import {
|
||||||
ensureMcpWebSearch,
|
ensureMcpWebSearch,
|
||||||
installWebSearchHook,
|
installWebSearchHook,
|
||||||
@@ -422,7 +423,7 @@ export async function execClaudeWithCLIProxy(
|
|||||||
for (const acct of accounts) {
|
for (const acct of accounts) {
|
||||||
const defaultMark = acct.isDefault ? ' (default)' : '';
|
const defaultMark = acct.isDefault ? ' (default)' : '';
|
||||||
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
|
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`);
|
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) {
|
if (accounts.length > 0) {
|
||||||
console.error(` Available accounts:`);
|
console.error(` Available accounts:`);
|
||||||
for (const acct of 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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
setDefaultAccount(provider, account.id);
|
setDefaultAccount(provider, account.id);
|
||||||
touchAccount(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)
|
// Handle --nickname (rename account)
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { getAuthDir } from './config-generator';
|
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 { sanitizeEmail, isTokenExpired } from './auth-utils';
|
||||||
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
|
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
|
||||||
|
import { extractCanonicalEmailFromAccountId } from './accounts/email-account-identity';
|
||||||
|
|
||||||
/** ChatGPT backend API base URL */
|
/** ChatGPT backend API base URL */
|
||||||
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
|
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
|
* 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 {
|
function readCodexAuthData(accountId: string): CodexAuthData | null {
|
||||||
const authDirs = [getAuthDir(), getPausedDir()];
|
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`;
|
const expectedFile = `codex-${sanitizedId}.json`;
|
||||||
|
|
||||||
for (const authDir of authDirs) {
|
for (const authDir of authDirs) {
|
||||||
@@ -184,19 +220,9 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
|
|||||||
|
|
||||||
const filePath = path.join(authDir, expectedFile);
|
const filePath = path.join(authDir, expectedFile);
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
try {
|
const authData = readCodexAuthFile(filePath);
|
||||||
const content = fs.readFileSync(filePath, 'utf-8');
|
if (authData) {
|
||||||
const data = JSON.parse(content);
|
return authData;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +234,7 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
|
|||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||||
const data = JSON.parse(content);
|
const data = JSON.parse(content);
|
||||||
if (data.email === accountId && data.access_token) {
|
if (data.email === legacyEmail && data.access_token) {
|
||||||
return {
|
return {
|
||||||
accessToken: data.access_token,
|
accessToken: data.access_token,
|
||||||
accountId: data.account_id || data.accountId || '',
|
accountId: data.account_id || data.accountId || '',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { buildQualifiedAccountStatsKey } from './account-stats-key';
|
import { buildQualifiedAccountStatsKey } from './account-stats-key';
|
||||||
import { mapExternalProviderName } from './provider-capabilities';
|
import { mapExternalProviderName } from './provider-capabilities';
|
||||||
|
import { buildEmailBackedAccountId } from './accounts/email-account-identity';
|
||||||
import type {
|
import type {
|
||||||
AccountUsageStats,
|
AccountUsageStats,
|
||||||
CliproxyManagementAuthFile,
|
CliproxyManagementAuthFile,
|
||||||
@@ -15,6 +16,9 @@ interface BuildCliproxyStatsOptions {
|
|||||||
interface ResolvedAuthFile {
|
interface ResolvedAuthFile {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string;
|
||||||
|
duplicateEmailCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProvider(provider: string): string {
|
function normalizeProvider(provider: string): string {
|
||||||
@@ -30,6 +34,16 @@ function buildAuthIndexLookup(
|
|||||||
authFiles: CliproxyManagementAuthFile[] | undefined
|
authFiles: CliproxyManagementAuthFile[] | undefined
|
||||||
): ReadonlyMap<string, ResolvedAuthFile> {
|
): ReadonlyMap<string, ResolvedAuthFile> {
|
||||||
const lookup = new Map<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 ?? []) {
|
for (const authFile of authFiles ?? []) {
|
||||||
if (authFile.auth_index === undefined || authFile.auth_index === null) {
|
if (authFile.auth_index === undefined || authFile.auth_index === null) {
|
||||||
@@ -37,6 +51,8 @@ function buildAuthIndexLookup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const provider = authFile.provider ? normalizeProvider(authFile.provider) : undefined;
|
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;
|
const source = authFile.email?.trim() || authFile.name?.trim() || undefined;
|
||||||
if (!provider && !source) {
|
if (!provider && !source) {
|
||||||
continue;
|
continue;
|
||||||
@@ -45,6 +61,12 @@ function buildAuthIndexLookup(
|
|||||||
lookup.set(String(authFile.auth_index), {
|
lookup.set(String(authFile.auth_index), {
|
||||||
provider,
|
provider,
|
||||||
source,
|
source,
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
duplicateEmailCount:
|
||||||
|
provider && email
|
||||||
|
? (duplicateEmailCounts.get(`${provider}:${email.toLowerCase()}`) ?? 1)
|
||||||
|
: 1,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,15 +87,29 @@ function resolveProviderForDetail(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveSourceForDetail(
|
function resolveSourceForDetail(
|
||||||
|
resolvedProvider: string,
|
||||||
detail: CliproxyRequestDetail,
|
detail: CliproxyRequestDetail,
|
||||||
authIndexLookup: ReadonlyMap<string, ResolvedAuthFile>
|
authIndexLookup: ReadonlyMap<string, ResolvedAuthFile>
|
||||||
): string {
|
): 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();
|
const source = detail.source?.trim();
|
||||||
if (source) {
|
if (source) {
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
return authIndexLookup.get(String(detail.auth_index))?.source ?? 'unknown';
|
return resolvedAuthFile?.source ?? 'unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildCliproxyStatsFromUsageResponse(
|
export function buildCliproxyStatsFromUsageResponse(
|
||||||
@@ -110,8 +146,8 @@ export function buildCliproxyStatsFromUsageResponse(
|
|||||||
for (const detail of modelData.details) {
|
for (const detail of modelData.details) {
|
||||||
sawAnyDetail = true;
|
sawAnyDetail = true;
|
||||||
sawProviderDetail = true;
|
sawProviderDetail = true;
|
||||||
const source = resolveSourceForDetail(detail, authIndexLookup);
|
|
||||||
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
|
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
|
||||||
|
const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup);
|
||||||
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
|
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
|
||||||
requestsByProvider[resolvedProvider] = (requestsByProvider[resolvedProvider] ?? 0) + 1;
|
requestsByProvider[resolvedProvider] = (requestsByProvider[resolvedProvider] ?? 0) + 1;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
QUOTA_SUPPORTED_PROVIDER_IDS,
|
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||||
type QuotaSupportedProvider,
|
type QuotaSupportedProvider,
|
||||||
} from '../../cliproxy/provider-capabilities';
|
} 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';
|
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
|
||||||
|
|
||||||
interface CliproxyProfileArgs {
|
interface CliproxyProfileArgs {
|
||||||
@@ -97,6 +98,11 @@ function formatResetTimeISO(isoTime: string): string {
|
|||||||
return formatResetTime(seconds);
|
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 {
|
interface QuotaFailureDisplayEntry {
|
||||||
tone: 'error' | 'info' | 'dim';
|
tone: 'error' | 'info' | 'dim';
|
||||||
text: string;
|
text: string;
|
||||||
@@ -360,13 +366,7 @@ function displayAntigravityQuotaSection(
|
|||||||
const tier = account.tier || 'unknown';
|
const tier = account.tier || 'unknown';
|
||||||
const status = statusParts.join(', ');
|
const status = statusParts.join(', ');
|
||||||
|
|
||||||
rows.push([
|
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
|
||||||
defaultMark,
|
|
||||||
account.nickname || account.email || account.id,
|
|
||||||
tier,
|
|
||||||
avgQuota,
|
|
||||||
status,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -384,10 +384,11 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
|||||||
|
|
||||||
for (const { account, quota } of results) {
|
for (const { account, quota } of results) {
|
||||||
const accountInfo = findAccountByQuery('codex', account);
|
const accountInfo = findAccountByQuery('codex', account);
|
||||||
|
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(account)}${defaultMark}`);
|
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||||
displayQuotaFailure(quota);
|
displayQuotaFailure(quota);
|
||||||
console.log('');
|
console.log('');
|
||||||
continue;
|
continue;
|
||||||
@@ -406,7 +407,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
|||||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||||
|
|
||||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
|
||||||
|
|
||||||
const coreUsageSummary = quota.coreUsage ?? {
|
const coreUsageSummary = quota.coreUsage ?? {
|
||||||
fiveHour: fiveHourWindow
|
fiveHour: fiveHourWindow
|
||||||
@@ -539,10 +540,11 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
|
|||||||
|
|
||||||
for (const { account, quota } of results) {
|
for (const { account, quota } of results) {
|
||||||
const accountInfo = findAccountByQuery('claude', account);
|
const accountInfo = findAccountByQuery('claude', account);
|
||||||
|
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(account)}${defaultMark}`);
|
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||||
displayQuotaFailure(quota);
|
displayQuotaFailure(quota);
|
||||||
console.log('');
|
console.log('');
|
||||||
continue;
|
continue;
|
||||||
@@ -562,7 +564,7 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
|
|||||||
const statusIcon =
|
const statusIcon =
|
||||||
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||||
|
|
||||||
console.log(` ${statusIcon}${account}${defaultMark}`);
|
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
|
||||||
|
|
||||||
const resetParts: string[] = [];
|
const resetParts: string[] = [];
|
||||||
if (fiveHourWindow?.resetAt)
|
if (fiveHourWindow?.resetAt)
|
||||||
@@ -616,10 +618,11 @@ function displayGeminiCliQuotaSection(
|
|||||||
|
|
||||||
for (const { account, quota } of results) {
|
for (const { account, quota } of results) {
|
||||||
const accountInfo = findAccountByQuery('gemini', account);
|
const accountInfo = findAccountByQuery('gemini', account);
|
||||||
|
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(account)}${defaultMark}`);
|
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||||
displayQuotaFailure(quota);
|
displayQuotaFailure(quota);
|
||||||
console.log('');
|
console.log('');
|
||||||
continue;
|
continue;
|
||||||
@@ -631,7 +634,7 @@ function displayGeminiCliQuotaSection(
|
|||||||
: 0;
|
: 0;
|
||||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||||
|
|
||||||
console.log(` ${statusIcon}${account}${defaultMark}`);
|
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
|
||||||
if (quota.projectId) {
|
if (quota.projectId) {
|
||||||
console.log(` Project: ${dim(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) {
|
for (const { account, quota } of results) {
|
||||||
const accountInfo = findAccountByQuery('ghcp', account);
|
const accountInfo = findAccountByQuery('ghcp', account);
|
||||||
|
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(account)}${defaultMark}`);
|
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||||
displayQuotaFailure(quota);
|
displayQuotaFailure(quota);
|
||||||
console.log('');
|
console.log('');
|
||||||
continue;
|
continue;
|
||||||
@@ -685,7 +689,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
|
|||||||
const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||||
|
|
||||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
|
||||||
if (quota.quotaResetDate) {
|
if (quota.quotaResetDate) {
|
||||||
console.log(` ${dim(`Resets ${formatResetTimeISO(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);
|
const quotaResult = await fetchAllProviderQuotas(provider, verbose);
|
||||||
|
|
||||||
for (const { account, quota } of quotaResult.accounts) {
|
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') : '';
|
const defaultBadge = account.isDefault ? color(' (default)', 'info') : '';
|
||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
@@ -933,7 +937,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
|
|||||||
console.log('Available accounts:');
|
console.log('Available accounts:');
|
||||||
for (const acc of accounts) {
|
for (const acc of accounts) {
|
||||||
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
|
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
|
||||||
console.log(` - ${acc.email || acc.id}${badge}`);
|
console.log(` - ${formatCliAccountLabel(acc)}${badge}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`No accounts found for provider: ${provider}`);
|
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);
|
const success = setDefaultAccount(provider, account.id);
|
||||||
|
|
||||||
if (success) {
|
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}`));
|
console.log(info(`Provider: ${provider}`));
|
||||||
} else {
|
} else {
|
||||||
console.log(fail('Failed to set default account'));
|
console.log(fail('Failed to set default account'));
|
||||||
@@ -973,7 +977,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (account.paused) {
|
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'}`));
|
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -981,7 +985,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
|
|||||||
const success = pauseAccount(provider, account.id);
|
const success = pauseAccount(provider, account.id);
|
||||||
|
|
||||||
if (success) {
|
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'));
|
console.log(info('Account will be skipped in quota rotation'));
|
||||||
} else {
|
} else {
|
||||||
console.log(fail('Failed to pause account'));
|
console.log(fail('Failed to pause account'));
|
||||||
@@ -1009,14 +1013,14 @@ export async function handleResumeAccount(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!account.paused) {
|
if (!account.paused) {
|
||||||
console.log(warn(`Account is not paused: ${account.email || account.id}`));
|
console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const success = resumeAccount(provider, account.id);
|
const success = resumeAccount(provider, account.id);
|
||||||
|
|
||||||
if (success) {
|
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'));
|
console.log(info('Account is now active in quota rotation'));
|
||||||
} else {
|
} else {
|
||||||
console.log(fail('Failed to resume account'));
|
console.log(fail('Failed to resume account'));
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from '../../cliproxy/services';
|
} from '../../cliproxy/services';
|
||||||
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
||||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||||
|
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||||
|
|
||||||
interface CliproxyProfileArgs {
|
interface CliproxyProfileArgs {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -118,6 +119,15 @@ function getBackendLabel(backend: CLIProxyBackend): string {
|
|||||||
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
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.
|
* Interactive prompt to select provider + model for a single tier.
|
||||||
* Returns a CompositeTierConfig, or null if user cancelled auth.
|
* Returns a CompositeTierConfig, or null if user cancelled auth.
|
||||||
@@ -158,7 +168,7 @@ async function selectTierConfig(
|
|||||||
console.log(fail('Authentication failed'));
|
console.log(fail('Authentication failed'));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select model
|
// Select model
|
||||||
@@ -364,7 +374,7 @@ export async function handleCreate(
|
|||||||
}
|
}
|
||||||
account = newAccount.id;
|
account = newAccount.id;
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||||
} else if (providerAccounts.length === 1) {
|
} else if (providerAccounts.length === 1) {
|
||||||
account = providerAccounts[0].id;
|
account = providerAccounts[0].id;
|
||||||
} else {
|
} else {
|
||||||
@@ -372,7 +382,7 @@ export async function handleCreate(
|
|||||||
const accountOptions = [
|
const accountOptions = [
|
||||||
...providerAccounts.map((acc) => ({
|
...providerAccounts.map((acc) => ({
|
||||||
id: acc.id,
|
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') },
|
{ id: ADD_NEW_ID, label: color('[+ Add new account...]', 'info') },
|
||||||
];
|
];
|
||||||
@@ -394,7 +404,7 @@ export async function handleCreate(
|
|||||||
}
|
}
|
||||||
account = newAccount.id;
|
account = newAccount.id;
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||||
} else {
|
} else {
|
||||||
account = selectedAccount;
|
account = selectedAccount;
|
||||||
}
|
}
|
||||||
@@ -406,7 +416,7 @@ export async function handleCreate(
|
|||||||
console.log('');
|
console.log('');
|
||||||
console.log('Available accounts:');
|
console.log('Available accounts:');
|
||||||
providerAccounts.forEach((a) =>
|
providerAccounts.forEach((a) =>
|
||||||
console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`)
|
console.log(` - ${formatVariantAccountLabel(a)}${a.isDefault ? ' (default)' : ''}`)
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -450,9 +460,13 @@ export async function handleCreate(
|
|||||||
? '~/.ccs/config.yaml'
|
? '~/.ccs/config.yaml'
|
||||||
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
|
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
|
||||||
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
|
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(
|
console.log(
|
||||||
infoBox(
|
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
|
configType
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
bulkResumeAccounts,
|
bulkResumeAccounts,
|
||||||
soloAccount,
|
soloAccount,
|
||||||
} from '../../cliproxy/account-manager';
|
} from '../../cliproxy/account-manager';
|
||||||
|
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||||
import {
|
import {
|
||||||
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||||
@@ -122,7 +123,7 @@ router.get('/', (_req: Request, res: Response): void => {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Use unique ID for key to prevent collisions between accounts with same nickname/email
|
// 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 rawKey = `${provider}:${acct.id}`;
|
||||||
const key = buildCliproxyAccountKey(rawKey, merged);
|
const key = buildCliproxyAccountKey(rawKey, merged);
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
pauseAccount as pauseAccountFn,
|
pauseAccount as pauseAccountFn,
|
||||||
resumeAccount as resumeAccountFn,
|
resumeAccount as resumeAccountFn,
|
||||||
touchAccount,
|
touchAccount,
|
||||||
extractAccountIdFromTokenFile,
|
|
||||||
hasAccountNameConflict,
|
hasAccountNameConflict,
|
||||||
PROVIDERS_WITHOUT_EMAIL,
|
PROVIDERS_WITHOUT_EMAIL,
|
||||||
validateNickname,
|
validateNickname,
|
||||||
@@ -932,7 +931,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
|||||||
getProviderTokenDir(localProvider),
|
getProviderTokenDir(localProvider),
|
||||||
pendingAuth.nickname,
|
pendingAuth.nickname,
|
||||||
false,
|
false,
|
||||||
extractAccountIdFromTokenFile(tokenSnapshot.file, tokenSnapshot.email)
|
tokenSnapshot.file
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|||||||
@@ -116,4 +116,41 @@ describe('registerAccount optional nickname flow', () => {
|
|||||||
|
|
||||||
expect(match).toBeNull();
|
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 buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary;
|
||||||
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
|
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
|
||||||
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
|
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
|
||||||
|
let registerAccount: typeof import('../../../src/cliproxy/account-manager').registerAccount;
|
||||||
|
|
||||||
function createCodexAccount(
|
function createCodexAccount(
|
||||||
accountId: string,
|
accountId: string,
|
||||||
@@ -40,6 +41,7 @@ beforeEach(async () => {
|
|||||||
);
|
);
|
||||||
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
|
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
|
||||||
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
|
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
|
||||||
|
({ registerAccount } = accountManager);
|
||||||
|
|
||||||
({
|
({
|
||||||
buildCodexQuotaWindows,
|
buildCodexQuotaWindows,
|
||||||
@@ -409,6 +411,55 @@ describe('Codex Quota Fetcher', () => {
|
|||||||
expect(result.actionHint).toContain('ccs cliproxy auth codex');
|
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 () => {
|
it('maps 403 responses to forbidden metadata', async () => {
|
||||||
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
|
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
|
||||||
|
|
||||||
|
|||||||
@@ -288,4 +288,45 @@ describe('buildCliproxyStatsFromUsageResponse', () => {
|
|||||||
});
|
});
|
||||||
expect(stats.requestsByProvider).toEqual({ codex: 1, 'ccs-internal-managed': 2 });
|
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,
|
isClaudeQuotaResult,
|
||||||
isCodexQuotaResult,
|
isCodexQuotaResult,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
|
import { formatAccountVariantLabel } from '@/lib/account-identity';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import {
|
import {
|
||||||
GripVertical,
|
GripVertical,
|
||||||
@@ -188,6 +189,7 @@ export function AccountCard({
|
|||||||
account.tier &&
|
account.tier &&
|
||||||
account.tier !== 'unknown' &&
|
account.tier !== 'unknown' &&
|
||||||
account.tier !== 'free';
|
account.tier !== 'free';
|
||||||
|
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -226,6 +228,11 @@ export function AccountCard({
|
|||||||
>
|
>
|
||||||
{cleanEmail(account.email)}
|
{cleanEmail(account.email)}
|
||||||
</span>
|
</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 && (
|
{showTierBadge && (
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { toast } from 'sonner';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
|
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||||
import { isDeniedAgyModelId } from '@/lib/utils';
|
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -220,7 +221,7 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
|||||||
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
|
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
|
||||||
{providerAccounts.map((acc) => (
|
{providerAccounts.map((acc) => (
|
||||||
<option key={acc.id} value={acc.id}>
|
<option key={acc.id} value={acc.id}>
|
||||||
{privacyMode ? '••••••' : acc.email || acc.id}
|
{privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
|
||||||
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
|
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
isClaudeQuotaResult,
|
isClaudeQuotaResult,
|
||||||
isCodexQuotaResult,
|
isCodexQuotaResult,
|
||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
|
import { formatAccountVariantLabel } from '@/lib/account-identity';
|
||||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||||
@@ -187,6 +188,7 @@ export function AccountItem({
|
|||||||
: failureInfo?.tone === 'destructive'
|
: failureInfo?.tone === 'destructive'
|
||||||
? 'border-destructive/50 text-destructive'
|
? 'border-destructive/50 text-destructive'
|
||||||
: 'border-muted-foreground/50 text-muted-foreground';
|
: 'border-muted-foreground/50 text-muted-foreground';
|
||||||
|
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -274,6 +276,11 @@ export function AccountItem({
|
|||||||
>
|
>
|
||||||
{account.email || account.id}
|
{account.email || account.id}
|
||||||
</span>
|
</span>
|
||||||
|
{variantLabel && (
|
||||||
|
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||||
|
{variantLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{account.isDefault && (
|
{account.isDefault && (
|
||||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||||
<Star className="w-2.5 h-2.5 fill-current" />
|
<Star className="w-2.5 h-2.5 fill-current" />
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
|
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { ChevronRight, AlertTriangle } from 'lucide-react';
|
import { ChevronRight, AlertTriangle } from 'lucide-react';
|
||||||
|
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||||
import { cn, STATUS_COLORS } from '@/lib/utils';
|
import { cn, STATUS_COLORS } from '@/lib/utils';
|
||||||
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||||
import { ProviderIcon } from '@/components/shared/provider-icon';
|
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { ProviderStats } from '../types';
|
import type { ProviderStats } from '../types';
|
||||||
import { getSuccessRate, cleanEmail } from '../utils';
|
import { getSuccessRate } from '../utils';
|
||||||
import { InlineStatsBadge } from './inline-stats-badge';
|
import { InlineStatsBadge } from './inline-stats-badge';
|
||||||
|
|
||||||
interface ProviderCardProps {
|
interface ProviderCardProps {
|
||||||
@@ -112,7 +113,7 @@ export function ProviderCard({
|
|||||||
<div
|
<div
|
||||||
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
||||||
style={{ backgroundColor: acc.color }}
|
style={{ backgroundColor: acc.color }}
|
||||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
title={privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
|
||||||
/>
|
/>
|
||||||
{isMissingProjectId && (
|
{isMissingProjectId && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
|
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
|
||||||
|
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import type { AccountStepProps } from '../types';
|
import type { AccountStepProps } from '../types';
|
||||||
@@ -37,7 +38,7 @@ export function AccountStep({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||||
{acc.email || acc.id}
|
{formatAccountDisplayName(acc.id, acc.email)}
|
||||||
</div>
|
</div>
|
||||||
{acc.isDefault && (
|
{acc.isDefault && (
|
||||||
<div className="text-xs text-muted-foreground">Default account</div>
|
<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 { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -63,7 +64,7 @@ export function VariantStep({
|
|||||||
<span>
|
<span>
|
||||||
{t('setupVariant.using')}{' '}
|
{t('setupVariant.using')}{' '}
|
||||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||||
{selectedAccount.email || selectedAccount.id}
|
{formatAccountDisplayName(selectedAccount.id, selectedAccount.email)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,8 +9,17 @@ export function getAccountStats(
|
|||||||
stats: Pick<CliproxyStats, 'accountStats'> | null | undefined,
|
stats: Pick<CliproxyStats, 'accountStats'> | null | undefined,
|
||||||
account: Pick<OAuthAccount, 'provider' | 'email' | 'id'>
|
account: Pick<OAuthAccount, 'provider' | 'email' | 'id'>
|
||||||
): AccountUsageStats | undefined {
|
): AccountUsageStats | undefined {
|
||||||
const source = account.email || account.id;
|
const sources = Array.from(
|
||||||
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
|
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 {
|
return {
|
||||||
provider: 'agy',
|
provider: 'agy',
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
@@ -170,9 +172,7 @@ describe('generateConnectionEvents()', () => {
|
|||||||
|
|
||||||
it('all generated timestamps are not in the future', () => {
|
it('all generated timestamps are not in the future', () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const accounts: AccountData[] = [
|
const accounts: AccountData[] = [makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 })];
|
||||||
makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 }),
|
|
||||||
];
|
|
||||||
|
|
||||||
const events = generateConnectionEvents(accounts);
|
const events = generateConnectionEvents(accounts);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user