184 lines
6.1 KiB
JavaScript
184 lines
6.1 KiB
JavaScript
#!/usr/bin/env node
|
||
// Sprint 10 Task 2.1 — Vendor availability probe.
|
||
//
|
||
// Three-vendor ensemble LOCKED per brief §2:
|
||
// 1. Anthropic Opus 4.7 (already production — Sprint 9 baseline)
|
||
// 2. OpenAI GPT-5.4 (config wired for v5 eval — provisioning TBD)
|
||
// 3. Google Gemini 3.1 Pro (config wired for v5 eval — provisioning TBD)
|
||
//
|
||
// This probe sends a minimal "respond with OK" prompt to each vendor
|
||
// through LiteLLM and reports per-vendor status. If any vendor is not
|
||
// provisionable (404 model_not_found, 401 auth, 402 billing, 429 rate
|
||
// limit, or hard timeout), it surfaces as a Day-1 blocker so Marko can
|
||
// resolve before Task 2.2 Fleiss' kappa baseline depends on the full
|
||
// ensemble path.
|
||
//
|
||
// Budget: ~3 × 20-token completions ≈ $0.001 total. Well under $5 Task
|
||
// 2.1 ceiling.
|
||
//
|
||
// Usage:
|
||
// node scripts/vendor-availability-probe.mjs
|
||
// [--litellm-url http://localhost:4000]
|
||
// [--out preflight-results/vendor-availability-<ISO>.json]
|
||
//
|
||
// Exits:
|
||
// 0 — all three vendors returned HTTP 200 with non-empty completions
|
||
// 1 — at least one vendor failed; details in stdout + JSON artifact
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
|
||
const VENDORS = [
|
||
{ id: 'anthropic', model: 'claude-opus-4-7', label: 'Anthropic Opus 4.7' },
|
||
{ id: 'openai', model: 'gpt-5.4', label: 'OpenAI GPT-5.4' },
|
||
{ id: 'google', model: 'gemini-3.1-pro', label: 'Google Gemini 3.1 Pro' },
|
||
];
|
||
|
||
const args = (() => {
|
||
const out = {
|
||
litellmUrl: process.env.LITELLM_BASE_URL ?? 'http://localhost:4000',
|
||
litellmKey: process.env.LITELLM_MASTER_KEY ?? 'sk-waggle-dev',
|
||
out: undefined,
|
||
};
|
||
const argv = process.argv.slice(2);
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const flag = argv[i];
|
||
const next = argv[i + 1];
|
||
if (flag === '--litellm-url') { out.litellmUrl = next; i++; }
|
||
else if (flag === '--litellm-key') { out.litellmKey = next; i++; }
|
||
else if (flag === '--out') { out.out = next; i++; }
|
||
}
|
||
if (!out.out) {
|
||
const iso = new Date().toISOString().replace(/[:.]/g, '-');
|
||
out.out = `preflight-results/vendor-availability-${iso}.json`;
|
||
}
|
||
return out;
|
||
})();
|
||
|
||
async function probeVendor(vendor) {
|
||
const url = `${args.litellmUrl.replace(/\/$/, '')}/v1/chat/completions`;
|
||
const started = Date.now();
|
||
// Opus 4.7 / GPT-5 / o3 / o4 families reject `temperature` with HTTP
|
||
// 400. Match the heuristic the judge-client already uses so this
|
||
// probe doesn't flag them falsely.
|
||
const rejectsTemperature = /opus-4-7|gpt-5|o3|o4/i.test(vendor.model);
|
||
const reqBody = {
|
||
model: vendor.model,
|
||
messages: [{ role: 'user', content: 'Respond with the single word OK.' }],
|
||
max_tokens: 16,
|
||
};
|
||
if (!rejectsTemperature) reqBody.temperature = 0.0;
|
||
|
||
let res;
|
||
try {
|
||
res = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${args.litellmKey}`,
|
||
},
|
||
body: JSON.stringify(reqBody),
|
||
});
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : String(err);
|
||
return {
|
||
...vendor,
|
||
status: 'fetch_error',
|
||
http: null,
|
||
latencyMs: Date.now() - started,
|
||
error: msg,
|
||
completionText: null,
|
||
promptTokens: 0,
|
||
completionTokens: 0,
|
||
};
|
||
}
|
||
const latencyMs = Date.now() - started;
|
||
const bodyText = await res.text();
|
||
let body;
|
||
try { body = JSON.parse(bodyText); } catch { body = null; }
|
||
|
||
if (!res.ok) {
|
||
const lc = bodyText.toLowerCase();
|
||
let statusTag;
|
||
if (res.status === 401 || /unauthori[sz]ed/.test(lc)) statusTag = 'auth_error';
|
||
else if (res.status === 402 || /billing|payment|quota/.test(lc)) statusTag = 'billing_error';
|
||
else if (res.status === 404 || /model[_ -]?not[_ -]?found|unknown model/.test(lc)) statusTag = 'model_not_found';
|
||
else if (res.status === 429) statusTag = 'rate_limited';
|
||
else statusTag = 'http_error';
|
||
return {
|
||
...vendor,
|
||
status: statusTag,
|
||
http: res.status,
|
||
latencyMs,
|
||
error: bodyText.slice(0, 400),
|
||
completionText: null,
|
||
promptTokens: 0,
|
||
completionTokens: 0,
|
||
};
|
||
}
|
||
|
||
const content = body?.choices?.[0]?.message?.content;
|
||
if (typeof content !== 'string' || content.length === 0) {
|
||
return {
|
||
...vendor,
|
||
status: 'empty_completion',
|
||
http: res.status,
|
||
latencyMs,
|
||
error: `empty content — body_head=${bodyText.slice(0, 200)}`,
|
||
completionText: null,
|
||
promptTokens: body?.usage?.prompt_tokens ?? 0,
|
||
completionTokens: body?.usage?.completion_tokens ?? 0,
|
||
};
|
||
}
|
||
|
||
return {
|
||
...vendor,
|
||
status: 'ok',
|
||
http: res.status,
|
||
latencyMs,
|
||
error: null,
|
||
completionText: content.trim().slice(0, 120),
|
||
promptTokens: body?.usage?.prompt_tokens ?? 0,
|
||
completionTokens: body?.usage?.completion_tokens ?? 0,
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
console.log(`[vendor-probe] starting — litellm=${args.litellmUrl} vendors=${VENDORS.length}`);
|
||
const results = [];
|
||
for (const vendor of VENDORS) {
|
||
process.stdout.write(` ${vendor.id.padEnd(10, ' ')} ${vendor.model.padEnd(22, ' ')} ... `);
|
||
const r = await probeVendor(vendor);
|
||
results.push(r);
|
||
if (r.status === 'ok') {
|
||
console.log(`PASS (http=${r.http} latency=${r.latencyMs}ms completion=${JSON.stringify(r.completionText)})`);
|
||
} else {
|
||
console.log(`FAIL (${r.status}${r.http ? ` http=${r.http}` : ''} latency=${r.latencyMs}ms)`);
|
||
if (r.error) console.log(` error: ${r.error.slice(0, 280)}`);
|
||
}
|
||
}
|
||
|
||
const allOk = results.every(r => r.status === 'ok');
|
||
const payload = {
|
||
generatedAt: new Date().toISOString(),
|
||
litellmUrl: args.litellmUrl,
|
||
allAvailable: allOk,
|
||
vendors: results,
|
||
};
|
||
fs.mkdirSync(path.dirname(args.out), { recursive: true });
|
||
fs.writeFileSync(args.out, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
||
|
||
console.log('');
|
||
console.log(
|
||
`[vendor-probe:summary] all_available=${allOk} ` +
|
||
`passes=${results.filter(r => r.status === 'ok').length}/${results.length} ` +
|
||
`out=${args.out}`,
|
||
);
|
||
process.exit(allOk ? 0 : 1);
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('[vendor-probe:error]', err?.message ?? err);
|
||
process.exit(2);
|
||
});
|