feat: update cliproxy, config loader, glmt transformer, and provider routes

This commit is contained in:
kaitranntt
2026-02-10 10:45:16 -05:00
parent 7ceb019bb7
commit 8c6afe2e73
6 changed files with 103 additions and 19 deletions
+30 -6
View File
@@ -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<void> {
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<string> {
function fetchTextOnce(
url: string,
verbose = false,
timeout = 30000,
maxRedirects = 10
): Promise<string> {
return new Promise((resolve, reject) => {
let resolved = false;
@@ -352,7 +363,13 @@ function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise<s
reject(new Error('Redirect without location header'));
return;
}
fetchTextOnce(redirectUrl, verbose, timeout).then(resolve).catch(reject);
if (maxRedirects <= 0) {
reject(new Error('Too many redirects'));
return;
}
fetchTextOnce(redirectUrl, verbose, timeout, maxRedirects - 1)
.then(resolve)
.catch(reject);
return;
}
@@ -436,7 +453,8 @@ export async function fetchText(url: string, verbose = false, maxRetries = 3): P
function fetchJsonOnce(
url: string,
verbose = false,
timeout = 15000
timeout = 15000,
maxRedirects = 10
): Promise<Record<string, unknown>> {
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;
}
+26 -4
View File
@@ -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<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
concurrency = 10
): Promise<R[]> {
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
+15 -4
View File
@@ -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) {
+2 -2
View File
@@ -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
+20 -1
View File
@@ -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<string, unknown> = {};
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
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}`);
}
+10 -2
View File
@@ -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,