Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions src/auth/AuthSource.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ class AuthSource {
this.logger = logger;
this.authMode = "file";
this.availableIndices = [];
// Indices used for rotation/switching (deduplicated by email, keeping the latest index per account)
this.rotationIndices = [];
// Duplicate auth indices detected (valid JSON but skipped from rotation due to same email)
this.duplicateIndices = [];
this.initialIndices = [];
this.accountNameMap = new Map();
// Map any valid index -> canonical (latest) index for the same account email
this.canonicalIndexMap = new Map();
// Duplicate groups (email -> kept + duplicates)
this.duplicateGroups = [];
this.lastScannedIndices = "[]"; // Cache to track changes

this.logger.info('[Auth] Using files in "configs/auth/" directory for authentication.');
Expand Down Expand Up @@ -96,13 +104,19 @@ class AuthSource {
_preValidateAndFilter() {
if (this.initialIndices.length === 0) {
this.availableIndices = [];
this.rotationIndices = [];
this.duplicateIndices = [];
this.accountNameMap.clear();
this.canonicalIndexMap.clear();
this.duplicateGroups = [];
return;
}

const validIndices = [];
const invalidSourceDescriptions = [];
this.accountNameMap.clear(); // Clear old names before re-validating
this.canonicalIndexMap.clear();
this.duplicateGroups = [];

for (const index of this.initialIndices) {
// Iterate over initial to check all, not just previously available
Expand Down Expand Up @@ -131,6 +145,73 @@ class AuthSource {
}

this.availableIndices = validIndices.sort((a, b) => a - b);
this._buildRotationIndices();
}

_normalizeEmailKey(accountName) {
if (typeof accountName !== "string") return null;
const trimmed = accountName.trim();
if (!trimmed) return null;
// Conservative: only deduplicate when the name looks like an email address.
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(trimmed)) return null;
return trimmed.toLowerCase();
}

_buildRotationIndices() {
this.rotationIndices = [];
this.duplicateIndices = [];
this.duplicateGroups = [];

const emailKeyToIndices = new Map();

for (const index of this.availableIndices) {
const accountName = this.accountNameMap.get(index);
const emailKey = this._normalizeEmailKey(accountName);

if (!emailKey) {
this.rotationIndices.push(index);
this.canonicalIndexMap.set(index, index);
continue;
}

const list = emailKeyToIndices.get(emailKey) || [];
list.push(index);
emailKeyToIndices.set(emailKey, list);
}

for (const [emailKey, indices] of emailKeyToIndices.entries()) {
indices.sort((a, b) => a - b);
const keptIndex = indices[indices.length - 1];
this.rotationIndices.push(keptIndex);

const duplicateIndices = [];
for (const index of indices) {
this.canonicalIndexMap.set(index, keptIndex);
if (index !== keptIndex) {
duplicateIndices.push(index);
}
}

if (duplicateIndices.length > 0) {
this.duplicateIndices.push(...duplicateIndices);
this.duplicateGroups.push({
email: emailKey,
keptIndex,
removedIndices: duplicateIndices,
});
}
}

this.rotationIndices = [...new Set(this.rotationIndices)].sort((a, b) => a - b);
this.duplicateIndices = [...new Set(this.duplicateIndices)].sort((a, b) => a - b);

if (this.duplicateIndices.length > 0) {
this.logger.warn(
`[Auth] Detected ${this.duplicateIndices.length} duplicate auth files (same email). ` +
`Rotation will only use latest index per account: [${this.rotationIndices.join(", ")}].`
);
}
}

_getAuthContent(index) {
Expand Down Expand Up @@ -162,6 +243,20 @@ class AuthSource {
return null;
}
}

getRotationIndices() {
return this.rotationIndices;
}

getCanonicalIndex(index) {
if (!Number.isInteger(index)) return null;
if (!this.availableIndices.includes(index)) return null;
return this.canonicalIndexMap.get(index) ?? index;
}

getDuplicateGroups() {
return this.duplicateGroups;
}
}

module.exports = AuthSource;
31 changes: 25 additions & 6 deletions src/auth/AuthSwitcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ class AuthSwitcher {
}

getNextAuthIndex() {
const available = this.authSource.availableIndices;
const available = this.authSource.getRotationIndices();
if (available.length === 0) return null;

const currentIndexInArray = available.indexOf(this.currentAuthIndex);
const currentCanonicalIndex =
this.currentAuthIndex >= 0
? this.authSource.getCanonicalIndex(this.currentAuthIndex)
: this.currentAuthIndex;
const currentIndexInArray = available.indexOf(currentCanonicalIndex);

if (currentIndexInArray === -1) {
this.logger.warn(
Expand All @@ -47,7 +51,7 @@ class AuthSwitcher {
}

async switchToNextAuth() {
const available = this.authSource.availableIndices;
const available = this.authSource.getRotationIndices();

if (available.length === 0) {
throw new Error("No available authentication sources, cannot switch.");
Expand Down Expand Up @@ -86,15 +90,21 @@ class AuthSwitcher {
}

// Multi-account mode
const currentIndexInArray = available.indexOf(this.currentAuthIndex);
const currentCanonicalIndex =
this.currentAuthIndex >= 0
? this.authSource.getCanonicalIndex(this.currentAuthIndex)
: this.currentAuthIndex;
const currentIndexInArray = available.indexOf(currentCanonicalIndex);
const hasCurrentAccount = currentIndexInArray !== -1;
const startIndex = hasCurrentAccount ? currentIndexInArray : 0;
const originalStartAccount = hasCurrentAccount ? available[startIndex] : null;

this.logger.info("==================================================");
this.logger.info(`🔄 [Auth] Multi-account mode: Starting intelligent account switching`);
this.logger.info(` • Current account: #${this.currentAuthIndex}`);
this.logger.info(` • Available accounts: [${available.join(", ")}]`);
this.logger.info(
` • Available accounts (dedup by email, keeping latest index): [${available.join(", ")}]`
);
if (hasCurrentAccount) {
this.logger.info(` • Starting from: #${originalStartAccount}`);
} else {
Expand Down Expand Up @@ -191,13 +201,22 @@ class AuthSwitcher {
this.logger.info("🔄 [Auth] Account switching in progress, skipping duplicate operation");
return { reason: "Switch already in progress.", success: false };
}
if (!this.authSource.availableIndices.includes(targetIndex)) {

const canonicalIndex = this.authSource.getCanonicalIndex(targetIndex);
if (canonicalIndex === null) {
return {
reason: `Switch failed: Account #${targetIndex} invalid or does not exist.`,
success: false,
};
}

if (canonicalIndex !== targetIndex) {
this.logger.warn(
`[Auth] Requested account #${targetIndex} is a duplicate for the same email. Redirecting to latest auth index #${canonicalIndex}.`
);
}
targetIndex = canonicalIndex;

this.isSystemBusy = true;
try {
this.logger.info(`🔄 [Auth] Starting switch to specified account #${targetIndex}...`);
Expand Down
25 changes: 19 additions & 6 deletions src/core/ProxyServerSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,23 +61,35 @@ class ProxyServerSystem extends EventEmitter {
this.logger.info(`[System] Proxy server system startup complete.`);

const allAvailableIndices = this.authSource.availableIndices;
const allRotationIndices = this.authSource.getRotationIndices();

if (allAvailableIndices.length === 0) {
this.logger.warn("[System] No available authentication source. Starting in account binding mode.");
this.emit("started");
return; // Exit early
}

let startupOrder = [...allAvailableIndices];
if (initialAuthIndex && allAvailableIndices.includes(initialAuthIndex)) {
this.logger.info(`[System] Detected specified startup index #${initialAuthIndex}, will try it first.`);
startupOrder = [initialAuthIndex, ...allAvailableIndices.filter(i => i !== initialAuthIndex)];
} else {
if (initialAuthIndex) {
let startupOrder = allRotationIndices.length > 0 ? [...allRotationIndices] : [...allAvailableIndices];
const hasInitialAuthIndex = Number.isInteger(initialAuthIndex);
if (hasInitialAuthIndex) {
const canonicalInitialIndex = this.authSource.getCanonicalIndex(initialAuthIndex);
if (canonicalInitialIndex !== null && startupOrder.includes(canonicalInitialIndex)) {
if (canonicalInitialIndex !== initialAuthIndex) {
this.logger.warn(
`[System] Specified startup index #${initialAuthIndex} is a duplicate for the same email, using latest auth index #${canonicalInitialIndex} instead.`
);
} else {
this.logger.info(
`[System] Detected specified startup index #${initialAuthIndex}, will try it first.`
);
}
startupOrder = [canonicalInitialIndex, ...startupOrder.filter(i => i !== canonicalInitialIndex)];
} else {
this.logger.warn(
`[System] Specified startup index #${initialAuthIndex} is invalid or unavailable, will start in default order.`
);
}
} else {
this.logger.info(
`[System] No valid startup index specified, will try in default order [${startupOrder.join(", ")}].`
);
Expand Down Expand Up @@ -121,6 +133,7 @@ class ProxyServerSystem extends EventEmitter {
"/health",
"/api/status",
"/api/accounts/current",
"/api/accounts/deduplicate",
"/api/settings/streaming-mode",
"/api/settings/force-thinking",
"/api/settings/force-web-search",
Expand Down
2 changes: 1 addition & 1 deletion src/core/RequestHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ class RequestHandler {
} catch (error) {
this.logger.error(`❌ [System] Recovery failed: ${error.message}`);

if (wasDirectRecovery && this.authSource.availableIndices.length > 1) {
if (wasDirectRecovery && this.authSource.getRotationIndices().length > 1) {
this.logger.warn("⚠️ [System] Attempting to switch to alternative account...");
try {
const result = await this.authSwitcher.switchToNextAuth();
Expand Down
Loading