mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
feat: update cliproxy, config loader, glmt transformer, and provider routes
This commit is contained in:
1 parent
7ceb019bb7
commit
8c6afe2e73
6 files changed
+103
-19
No files matched your search
@@ -177,13 +177,15 @@ export function isRetryableError(error: Error): boolean {
|
|||||||
/**
|
/**
|
||||||
* Download file from URL with progress tracking
|
* Download file from URL with progress tracking
|
||||||
* @param timeout Timeout in ms (default 120000 for large files)
|
* @param timeout Timeout in ms (default 120000 for large files)
|
||||||
|
* @param maxRedirects Maximum number of redirects to follow (default 10)
|
||||||
*/
|
*/
|
||||||
export function downloadFile(
|
export function downloadFile(
|
||||||
url: string,
|
url: string,
|
||||||
destPath: string,
|
destPath: string,
|
||||||
onProgress?: ProgressCallback,
|
onProgress?: ProgressCallback,
|
||||||
verbose = false,
|
verbose = false,
|
||||||
timeout = 120000
|
timeout = 120000,
|
||||||
|
maxRedirects = 10
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
@@ -203,10 +205,14 @@ export function downloadFile(
|
|||||||
cleanup(new Error('Redirect without location header'));
|
cleanup(new Error('Redirect without location header'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (maxRedirects <= 0) {
|
||||||
|
cleanup(new Error('Too many redirects'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.error(`[cliproxy] Following redirect: ${redirectUrl}`);
|
console.error(`[cliproxy] Following redirect: ${redirectUrl}`);
|
||||||
}
|
}
|
||||||
downloadFile(redirectUrl, destPath, onProgress, verbose, timeout)
|
downloadFile(redirectUrl, destPath, onProgress, verbose, timeout, maxRedirects - 1)
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
return;
|
return;
|
||||||
@@ -340,7 +346,12 @@ export async function downloadWithRetry(
|
|||||||
/**
|
/**
|
||||||
* Fetch text content from URL (single attempt)
|
* 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
@@ -352,7 +363,13 @@ function fetchTextOnce(url: string, verbose = false, timeout = 30000): Promise<s
|
|||||||
reject(new Error('Redirect without location header'));
|
reject(new Error('Redirect without location header'));
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,7 +453,8 @@ export async function fetchText(url: string, verbose = false, maxRetries = 3): P
|
|||||||
function fetchJsonOnce(
|
function fetchJsonOnce(
|
||||||
url: string,
|
url: string,
|
||||||
verbose = false,
|
verbose = false,
|
||||||
timeout = 15000
|
timeout = 15000,
|
||||||
|
maxRedirects = 10
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
@@ -456,7 +474,13 @@ function fetchJsonOnce(
|
|||||||
reject(new Error('Redirect without location header'));
|
reject(new Error('Redirect without location header'));
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,26 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo
|
|||||||
// PRE-FLIGHT CHECK
|
// 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
|
* Result of pre-flight quota check
|
||||||
*/
|
*/
|
||||||
@@ -213,9 +233,10 @@ export async function findHealthyAccount(
|
|||||||
|
|
||||||
if (available.length === 0) return null;
|
if (available.length === 0) return null;
|
||||||
|
|
||||||
// Fetch quota for each available account (with caching and deduplication)
|
// Fetch quota for each available account (batched to prevent connection burst)
|
||||||
const withQuotas = await Promise.all(
|
const withQuotas = await batchedMap(
|
||||||
available.map(async (account) => {
|
available,
|
||||||
|
async (account) => {
|
||||||
let quota = getCachedQuota(provider, account.id);
|
let quota = getCachedQuota(provider, account.id);
|
||||||
if (!quota) {
|
if (!quota) {
|
||||||
quota = await fetchQuotaWithDedup(provider, account.id);
|
quota = await fetchQuotaWithDedup(provider, account.id);
|
||||||
@@ -228,7 +249,8 @@ export async function findHealthyAccount(
|
|||||||
tier: account.tier || 'unknown',
|
tier: account.tier || 'unknown',
|
||||||
lastQuota: avgQuota,
|
lastQuota: avgQuota,
|
||||||
};
|
};
|
||||||
})
|
},
|
||||||
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter by threshold
|
// Filter by threshold
|
||||||
|
|||||||
@@ -89,10 +89,13 @@ function acquireLock(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Acquire lock
|
// Acquire lock
|
||||||
fs.writeFileSync(lockPath, lockData, { mode: 0o600 });
|
fs.writeFileSync(lockPath, lockData, { flag: 'wx', mode: 0o600 });
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch (error) {
|
||||||
// Lock acquisition failed
|
// EEXIST means another process acquired the lock between our check and write
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,7 +648,15 @@ export function saveUnifiedConfig(config: UnifiedConfig): void {
|
|||||||
// Synchronous sleep without CPU-intensive busy-wait
|
// Synchronous sleep without CPU-intensive busy-wait
|
||||||
// Uses Atomics.wait which properly sleeps the thread
|
// Uses Atomics.wait which properly sleeps the thread
|
||||||
// Note: saveUnifiedConfig is sync API with 19+ callers, converting to async not feasible
|
// 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) {
|
if (!lockAcquired) {
|
||||||
|
|||||||
@@ -382,15 +382,15 @@ export class GlmtProxy {
|
|||||||
* Honors Retry-After header if provided
|
* Honors Retry-After header if provided
|
||||||
*/
|
*/
|
||||||
private calculateRetryDelay(attempt: number, retryAfterHeader?: string): number {
|
private calculateRetryDelay(attempt: number, retryAfterHeader?: string): number {
|
||||||
|
const MAX_DELAY_MS = 30000;
|
||||||
// Honor Retry-After header if present and valid
|
// Honor Retry-After header if present and valid
|
||||||
if (retryAfterHeader) {
|
if (retryAfterHeader) {
|
||||||
const retryAfter = parseInt(retryAfterHeader, 10);
|
const retryAfter = parseInt(retryAfterHeader, 10);
|
||||||
if (!isNaN(retryAfter) && retryAfter > 0) {
|
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
|
// Exponential backoff: 2^attempt * baseDelay + random jitter (0-500ms), capped at 30s
|
||||||
const MAX_DELAY_MS = 30000;
|
|
||||||
const exponentialDelay = Math.min(
|
const exponentialDelay = Math.min(
|
||||||
Math.pow(2, attempt) * this.retryConfig.baseDelay,
|
Math.pow(2, attempt) * this.retryConfig.baseDelay,
|
||||||
MAX_DELAY_MS
|
MAX_DELAY_MS
|
||||||
|
|||||||
@@ -148,13 +148,32 @@ export class GlmtTransformer {
|
|||||||
return this.streamParser.finalizeDelta(accumulator);
|
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 {
|
private writeDebugLog(type: string, data: unknown): void {
|
||||||
if (!this.debugLog) return;
|
if (!this.debugLog) return;
|
||||||
try {
|
try {
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0];
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').split('.')[0];
|
||||||
const filepath = path.join(this.debugLogDir, `${timestamp}-${type}.json`);
|
const filepath = path.join(this.debugLogDir, `${timestamp}-${type}.json`);
|
||||||
fs.mkdirSync(this.debugLogDir, { recursive: true });
|
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) {
|
} catch (error) {
|
||||||
console.error(`[glmt-transformer] Debug log error: ${(error as Error).message}`);
|
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
|
// Mask API keys for security
|
||||||
const masked = providers.map((p) => ({
|
const masked = providers.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
apiKey: p.apiKey ? `...${p.apiKey.slice(-4)}` : '',
|
apiKey: p.apiKey ? (p.apiKey.length > 8 ? `...${p.apiKey.slice(-4)}` : '***') : '',
|
||||||
}));
|
}));
|
||||||
res.json({ providers: masked });
|
res.json({ providers: masked });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -58,7 +58,11 @@ router.get('/:name', (req: Request, res: Response): void => {
|
|||||||
// Mask API key
|
// Mask API key
|
||||||
res.json({
|
res.json({
|
||||||
...provider,
|
...provider,
|
||||||
apiKey: provider.apiKey ? `...${provider.apiKey.slice(-4)}` : '',
|
apiKey: provider.apiKey
|
||||||
|
? provider.apiKey.length > 8
|
||||||
|
? `...${provider.apiKey.slice(-4)}`
|
||||||
|
: '***'
|
||||||
|
: '',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
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' });
|
res.status(400).json({ error: 'apiKey is required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (models && !Array.isArray(models)) {
|
||||||
|
res.status(400).json({ error: 'models must be an array' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
addOpenAICompatProvider({
|
addOpenAICompatProvider({
|
||||||
name,
|
name,
|
||||||
|
|||||||
Reference in new issue
Block a user