From 8c6afe2e73049ecfbb12cb29a894e83eb2576354 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Tue, 10 Feb 2026 10:45:16 -0500 Subject: [PATCH] feat: update cliproxy, config loader, glmt transformer, and provider routes --- src/cliproxy/binary/downloader.ts | 36 ++++++++++++++++++++---- src/cliproxy/quota-manager.ts | 30 +++++++++++++++++--- src/config/unified-config-loader.ts | 19 ++++++++++--- src/glmt/glmt-proxy.ts | 4 +-- src/glmt/glmt-transformer.ts | 21 +++++++++++++- src/web-server/routes/provider-routes.ts | 12 ++++++-- 6 files changed, 103 insertions(+), 19 deletions(-) diff --git a/src/cliproxy/binary/downloader.ts b/src/cliproxy/binary/downloader.ts index e34c5490..c7e1ea71 100644 --- a/src/cliproxy/binary/downloader.ts +++ b/src/cliproxy/binary/downloader.ts @@ -177,13 +177,15 @@ export function isRetryableError(error: Error): boolean { /** * Download file from URL with progress tracking * @param timeout Timeout in ms (default 120000 for large files) + * @param maxRedirects Maximum number of redirects to follow (default 10) */ export function downloadFile( url: string, destPath: string, onProgress?: ProgressCallback, verbose = false, - timeout = 120000 + timeout = 120000, + maxRedirects = 10 ): Promise { return new Promise((resolve, reject) => { let resolved = false; @@ -203,10 +205,14 @@ export function downloadFile( cleanup(new Error('Redirect without location header')); return; } + if (maxRedirects <= 0) { + cleanup(new Error('Too many redirects')); + return; + } if (verbose) { console.error(`[cliproxy] Following redirect: ${redirectUrl}`); } - downloadFile(redirectUrl, destPath, onProgress, verbose, timeout) + downloadFile(redirectUrl, destPath, onProgress, verbose, timeout, maxRedirects - 1) .then(resolve) .catch(reject); return; @@ -340,7 +346,12 @@ export async function downloadWithRetry( /** * Fetch text content from URL (single attempt) */ -function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise { +function fetchTextOnce( + url: string, + verbose = false, + timeout = 30000, + maxRedirects = 10 +): Promise { return new Promise((resolve, reject) => { let resolved = false; @@ -352,7 +363,13 @@ function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise> { return new Promise((resolve, reject) => { let resolved = false; @@ -456,7 +474,13 @@ function fetchJsonOnce( reject(new Error('Redirect without location header')); return; } - fetchJsonOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject); + if (maxRedirects <= 0) { + reject(new Error('Too many redirects')); + return; + } + fetchJsonOnce(redirectUrl, verbose, timeout, maxRedirects - 1) + .then(resolve) + .catch(reject); return; } diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index a5943c6a..0aea4a1b 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -164,6 +164,26 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo // PRE-FLIGHT CHECK // ============================================================================ +/** + * Process items with limited concurrency to prevent connection burst + * @param items - Items to process + * @param fn - Async function to apply to each item + * @param concurrency - Number of concurrent operations (default: 10) + */ +async function batchedMap( + items: T[], + fn: (item: T) => Promise, + concurrency = 10 +): Promise { + const results: R[] = []; + for (let i = 0; i < items.length; i += concurrency) { + const batch = items.slice(i, i + concurrency); + const batchResults = await Promise.all(batch.map(fn)); + results.push(...batchResults); + } + return results; +} + /** * Result of pre-flight quota check */ @@ -213,9 +233,10 @@ export async function findHealthyAccount( if (available.length === 0) return null; - // Fetch quota for each available account (with caching and deduplication) - const withQuotas = await Promise.all( - available.map(async (account) => { + // Fetch quota for each available account (batched to prevent connection burst) + const withQuotas = await batchedMap( + available, + async (account) => { let quota = getCachedQuota(provider, account.id); if (!quota) { quota = await fetchQuotaWithDedup(provider, account.id); @@ -228,7 +249,8 @@ export async function findHealthyAccount( tier: account.tier || 'unknown', lastQuota: avgQuota, }; - }) + }, + 10 ); // Filter by threshold diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index a8e336de..76401431 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -89,10 +89,13 @@ function acquireLock(): boolean { } // Acquire lock - fs.writeFileSync(lockPath, lockData, { mode: 0o600 }); + fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 }); return true; - } catch { - // Lock acquisition failed + } catch (error) { + // EEXIST means another process acquired the lock between our check and write + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + return false; + } return false; } } @@ -645,7 +648,15 @@ export function saveUnifiedConfig(config: UnifiedConfig): void { // Synchronous sleep without CPU-intensive busy-wait // Uses Atomics.wait which properly sleeps the thread // Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); + try { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, retryDelayMs); + } catch { + // Fallback for environments without SharedArrayBuffer/Atomics support + const end = Date.now() + retryDelayMs; + while (Date.now() < end) { + /* busy-wait */ + } + } } if (!lockAcquired) { diff --git a/src/glmt/glmt-proxy.ts b/src/glmt/glmt-proxy.ts index f52d55a9..2d7be16c 100644 --- a/src/glmt/glmt-proxy.ts +++ b/src/glmt/glmt-proxy.ts @@ -382,15 +382,15 @@ export class GlmtProxy { * Honors Retry-After header if provided */ private calculateRetryDelay(attempt: number, retryAfterHeader?: string): number { + const MAX_DELAY_MS = 30000; // Honor Retry-After header if present and valid if (retryAfterHeader) { const retryAfter = parseInt(retryAfterHeader, 10); if (!isNaN(retryAfter) && retryAfter > 0) { - return retryAfter * 1000; // Convert seconds to ms + return Math.min(retryAfter * 1000, MAX_DELAY_MS); // Cap at 30s } } // Exponential backoff: 2^attempt * baseDelay + random jitter (0-500ms), capped at 30s - const MAX_DELAY_MS = 30000; const exponentialDelay = Math.min( Math.pow(2, attempt) * this.retryConfig.baseDelay, MAX_DELAY_MS diff --git a/src/glmt/glmt-transformer.ts b/src/glmt/glmt-transformer.ts index 5c4a41e7..a1854210 100644 --- a/src/glmt/glmt-transformer.ts +++ b/src/glmt/glmt-transformer.ts @@ -148,13 +148,32 @@ export class GlmtTransformer { return this.streamParser.finalizeDelta(accumulator); } + private redactSensitiveData(data: unknown): unknown { + if (data === null || data === undefined) return data; + if (typeof data !== 'object') return data; + if (Array.isArray(data)) return data.map((item) => this.redactSensitiveData(item)); + + const SENSITIVE_KEYS = + /^(authorization|auth[_-]?token|api[_-]?key|apikey|token|secret|password|credential|x-api-key|anthropic-api-key|cookie)$/i; + const result: Record = {}; + for (const [key, value] of Object.entries(data as Record)) { + if (SENSITIVE_KEYS.test(key)) { + result[key] = '[REDACTED]'; + } else { + result[key] = this.redactSensitiveData(value); + } + } + return result; + } + private writeDebugLog(type: string, data: unknown): void { if (!this.debugLog) return; try { const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0]; const filepath = path.join(this.debugLogDir, `${timestamp}-${type}.json`); fs.mkdirSync(this.debugLogDir, { recursive: true }); - fs.writeFileSync(filepath, JSON.stringify(data, null, 2) + '\n', 'utf8'); + const redacted = this.redactSensitiveData(data); + fs.writeFileSync(filepath, JSON.stringify(redacted, null, 2) + '\n', 'utf8'); } catch (error) { console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`); } diff --git a/src/web-server/routes/provider-routes.ts b/src/web-server/routes/provider-routes.ts index e5b05659..4b91125c 100644 --- a/src/web-server/routes/provider-routes.ts +++ b/src/web-server/routes/provider-routes.ts @@ -25,7 +25,7 @@ router.get('/', (_req: Request, res: Response): void => { // Mask API keys for security const masked = providers.map((p) => ({ ...p, - apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '', + apiKey: p.apiKey ? (p.apiKey.length > 8 ? `...${p.apiKey.slice(-4)}` : '***') : '', })); res.json({ providers: masked }); } catch (error) { @@ -58,7 +58,11 @@ router.get('/:name', (req: Request, res: Response): void => { // Mask API key res.json({ ...provider, - apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '', + apiKey: provider.apiKey + ? provider.apiKey.length > 8 + ? `...${provider.apiKey.slice(-4)}` + : '***' + : '', }); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -94,6 +98,10 @@ router.post('/', (req: Request, res: Response): void => { res.status(400).json({ error: 'apiKey is required' }); return; } + if (models && !Array.isArray(models)) { + res.status(400).json({ error: 'models must be an array' }); + return; + } addOpenAICompatProvider({ name,