scripts/lsp-capacity-matrix.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
const baseUrl = (process.env.BASE_URL || process.env.WINDSURFAPI_BASE_URL || 'http://127.0.0.1:3003').replace(/\/+$/, '');
const apiKey = process.env.API_KEY || process.env.WINDSURFAPI_API_KEY || '';
const model = process.env.MODEL || process.env.WINDSURFAPI_LSP_MATRIX_MODEL || 'claude-haiku-4.5';
const concurrencyValues = String(process.env.LSP_MATRIX_CONCURRENCY || '1,2,4')
.split(',')
.map(s => Number(s.trim()))
.filter(n => Number.isFinite(n) && n > 0);
const rounds = Math.max(1, Number(process.env.LSP_MATRIX_ROUNDS || 1));
const timeoutMs = Math.max(5_000, Number(process.env.LSP_MATRIX_TIMEOUT_MS || 90_000));
const stream = process.env.LSP_MATRIX_STREAM === '1';
const includeHealth = process.env.LSP_MATRIX_HEALTH !== '0';
const failFast = process.env.LSP_MATRIX_FAIL_FAST === '1';
const marker = `LSP_MATRIX_${Date.now().toString(36)}`;
if (!apiKey) {
console.error('API_KEY is required');
process.exit(2);
}
if (!concurrencyValues.length) {
console.error('LSP_MATRIX_CONCURRENCY must contain at least one positive integer');
process.exit(2);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function compactText(text, max = 800) {
const s = String(text || '').replace(/\s+/g, ' ').trim();
return s.length > max ? `${s.slice(0, max)}...<truncated ${s.length - max} chars>` : s;
}
function percentile(values, p) {
const nums = values.filter(n => Number.isFinite(n)).sort((a, b) => a - b);
if (!nums.length) return 0;
const idx = Math.min(nums.length - 1, Math.max(0, Math.ceil((p / 100) * nums.length) - 1));
return nums[idx];
}
function summarizePool(health) {
const pool = health?.lsPool?.pool || {};
const guard = pool.memoryGuard || health?.lsPool?.memoryGuard || {};
return {
running: !!health?.lsPool?.running,
maxInstances: health?.lsPool?.maxInstances ?? pool.maxInstances ?? null,
totalRssBytes: health?.lsPool?.totalRssBytes ?? null,
size: pool.size ?? null,
occupancy: pool.occupancy ?? null,
effectiveOccupancy: pool.effectiveOccupancy ?? null,
ready: pool.ready ?? null,
starting: pool.starting ?? null,
pending: pool.pending ?? null,
reservedPendingStarts: pool.reservedPendingStarts ?? null,
stopping: pool.stopping ?? null,
activeRequests: pool.activeRequests ?? null,
maintenanceRequests: pool.maintenanceRequests ?? null,
nonDefaultInstances: pool.nonDefaultInstances ?? null,
canStartNewNonDefault: pool.canStartNewNonDefault ?? null,
blockReason: pool.blockReason ?? null,
memoryGuard: {
enabled: guard.enabled ?? null,
availableBytes: guard.availableBytes ?? null,
minAvailableBytes: guard.minAvailableBytes ?? null,
estimatedRssBytesPerInstance: guard.estimatedRssBytesPerInstance ?? null,
okToSpawn: guard.okToSpawn ?? null,
source: guard.minAvailableBytesSource ?? null,
},
admissionStats: health?.lsPool?.admissionStats || null,
};
}
async function fetchHealth(label) {
if (!includeHealth) return null;
try {
const res = await fetch(`${baseUrl}/health?verbose=1`, {
headers: { authorization: `Bearer ${apiKey}` },
});
const text = await res.text();
let json;
try { json = JSON.parse(text); } catch {
return { ok: false, label, status: res.status, error: 'health returned non-JSON', rawPreview: compactText(text) };
}
return {
ok: res.ok,
label,
status: res.status,
version: json.version,
commit: json.commit,
accounts: json.accounts || null,
nativeBridgeConfig: json.nativeBridgeConfig || null,
pool: summarizePool(json),
};
} catch (error) {
return { ok: false, label, error: String(error?.message || error) };
}
}
function requestBody(user, index) {
return {
model,
stream,
user,
max_tokens: 16,
messages: [
{ role: 'user', content: `Reply exactly OK. ${marker} request ${index}.` },
],
};
}
async function runOne(user, index) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const started = Date.now();
try {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
signal: controller.signal,
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify(requestBody(user, index)),
});
const text = await res.text();
const latencyMs = Date.now() - started;
const ok = res.status >= 200 && res.status < 300;
return {
ok,
status: res.status,
latencyMs,
processingMs: Number(res.headers.get('openai-processing-ms') || 0) || null,
accountLikeHeaders: {
model: res.headers.get('openai-model') || null,
requestId: res.headers.get('x-request-id') || null,
},
preview: ok ? compactText(text, 240) : compactText(text, 800),
};
} catch (error) {
return {
ok: false,
status: 0,
latencyMs: Date.now() - started,
error: error?.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : String(error?.message || error),
};
} finally {
clearTimeout(timer);
}
}
function admissionDelta(before, after) {
const b = before?.pool?.admissionStats || {};
const a = after?.pool?.admissionStats || {};
const keys = ['startAttempts', 'startSuccesses', 'startFailures', 'poolWaits', 'memoryWaits', 'poolExhausted', 'memoryGuardBlocks', 'evictions'];
const out = {};
for (const key of keys) out[key] = Number(a[key] || 0) - Number(b[key] || 0);
return out;
}
function summarizeBatch(concurrency, round, before, after, requests) {
const latencies = requests.map(r => r.latencyMs);
const ok = requests.filter(r => r.ok).length;
const statuses = {};
for (const r of requests) statuses[String(r.status)] = (statuses[String(r.status)] || 0) + 1;
const beforePool = before?.pool || {};
const afterPool = after?.pool || {};
return {
round,
concurrency,
ok: ok === requests.length,
success: ok,
failed: requests.length - ok,
statuses,
latencyMs: {
min: Math.min(...latencies),
p50: percentile(latencies, 50),
p95: percentile(latencies, 95),
max: Math.max(...latencies),
},
rssDeltaBytes: Number(afterPool.totalRssBytes || 0) - Number(beforePool.totalRssBytes || 0),
poolBefore: beforePool,
poolAfter: afterPool,
admissionDelta: admissionDelta(before, after),
failures: requests.filter(r => !r.ok).slice(0, 10),
};
}
const matrix = [];
const failures = [];
const overallBefore = await fetchHealth('overall-before');
for (const concurrency of concurrencyValues) {
for (let round = 1; round <= rounds; round++) {
const before = await fetchHealth(`c${concurrency}-r${round}-before`);
const tasks = [];
for (let i = 0; i < concurrency; i++) {
const user = `${marker}-c${concurrency}-r${round}-u${i}`;
tasks.push(runOne(user, `${concurrency}.${round}.${i}`));
}
const requests = await Promise.all(tasks);
await sleep(Number(process.env.LSP_MATRIX_SETTLE_MS || 1000));
const after = await fetchHealth(`c${concurrency}-r${round}-after`);
const row = summarizeBatch(concurrency, round, before, after, requests);
matrix.push(row);
if (!row.ok) failures.push(`c=${concurrency} r=${round} failed=${row.failed}`);
if (failFast && failures.length) break;
}
if (failFast && failures.length) break;
}
const overallAfter = await fetchHealth('overall-after');
console.log(JSON.stringify({
ok: failures.length === 0,
baseUrl,
model,
marker,
stream,
timeoutMs,
concurrencyValues,
rounds,
failures,
overallBefore,
overallAfter,
matrix,
}, null, 2));
if (failures.length) process.exit(1);