mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
feat: update cliproxy, config loader, glmt transformer, and provider routes
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user