Files
waggle-os/scripts/vendor-availability-probe.mjs
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

184 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});