|
#!/usr/bin/env node |
|
/** |
|
* Probe the Chaozhiyuan OpenAI-compatible gateway across multiple API keys. |
|
* |
|
* Node 18+; no external dependencies. |
|
* Keep API_KEYS below, or replace the values with your own keys. |
|
*/ |
|
|
|
import { performance } from "node:perf_hooks"; |
|
import { writeFile } from "node:fs/promises"; |
|
import process from "node:process"; |
|
|
|
const BASE_URL = "https://pool.chaozhiyuanai.com/v1"; |
|
|
|
// key type -> API key. Do not commit real credentials to source control. |
|
const API_KEYS = { |
|
GPT_PRO: "sk-", |
|
KIRO: "sk-", |
|
CLAUDE_KIRO: "sk-", |
|
}; |
|
|
|
const DEFAULTS = { |
|
concurrency: 3, |
|
timeoutMs: 60_000, |
|
retries: 0, |
|
prompt: "Reply with exactly PONG.", |
|
}; |
|
|
|
function usage() { |
|
console.log(`Usage: node chaozhiyuan-model-probe.mjs [options] |
|
|
|
Options: |
|
--concurrency N Maximum simultaneous requests (default: ${DEFAULTS.concurrency}) |
|
--timeout-ms N Per-request timeout (default: ${DEFAULTS.timeoutMs}) |
|
--retries N Retries after a failed request (default: ${DEFAULTS.retries}) |
|
--key NAME[,NAME...] Probe only these key names |
|
--model MODEL[,MODEL] Probe only these model IDs after discovery |
|
--discover-only Fetch /models but do not run the cross-product probes |
|
--json Print the complete report as JSON instead of a table |
|
--out FILE Also write the complete report as JSON to FILE |
|
--help Show this help |
|
|
|
The default probe matrix is every configured key x the union of models returned |
|
by /models for all configured keys. Model API selection is automatic: IDs whose |
|
name starts with "claude" use Anthropic Messages; all others use OpenAI Chat |
|
Completions.`); |
|
} |
|
|
|
function parseArgs(argv) { |
|
const options = { ...DEFAULTS, json: false, discoverOnly: false, out: undefined }; |
|
for (let i = 0; i < argv.length; i += 1) { |
|
const arg = argv[i]; |
|
const value = () => { |
|
const next = argv[++i]; |
|
if (next === undefined) throw new Error(`Missing value for ${arg}`); |
|
return next; |
|
}; |
|
if (arg === "--help" || arg === "-h") { |
|
usage(); |
|
process.exit(0); |
|
} else if (arg === "--concurrency") { |
|
options.concurrency = positiveInt(value(), arg); |
|
} else if (arg === "--timeout-ms") { |
|
options.timeoutMs = positiveInt(value(), arg); |
|
} else if (arg === "--retries") { |
|
options.retries = nonNegativeInt(value(), arg); |
|
} else if (arg === "--key") { |
|
options.keys = splitList(value()); |
|
} else if (arg === "--model") { |
|
options.models = splitList(value()); |
|
} else if (arg === "--discover-only") { |
|
options.discoverOnly = true; |
|
} else if (arg === "--json") { |
|
options.json = true; |
|
} else if (arg === "--out") { |
|
options.out = value(); |
|
} else { |
|
throw new Error(`Unknown option: ${arg}`); |
|
} |
|
} |
|
return options; |
|
} |
|
|
|
function positiveInt(value, flag) { |
|
const parsed = Number(value); |
|
if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`${flag} requires a positive integer`); |
|
return parsed; |
|
} |
|
|
|
function nonNegativeInt(value, flag) { |
|
const parsed = Number(value); |
|
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} requires a non-negative integer`); |
|
return parsed; |
|
} |
|
|
|
function splitList(value) { |
|
return value.split(",").map((item) => item.trim()).filter(Boolean); |
|
} |
|
|
|
function apiForModel(model) { |
|
return /^claude(?:[-/]|$)/i.test(model) ? "anthropic-messages" : "openai-completions"; |
|
} |
|
|
|
function endpointFor(api) { |
|
return api === "anthropic-messages" ? `${BASE_URL}/messages` : `${BASE_URL}/chat/completions`; |
|
} |
|
|
|
function headersFor(api, apiKey) { |
|
if (api === "anthropic-messages") { |
|
return { |
|
"content-type": "application/json", |
|
"x-api-key": apiKey, |
|
"anthropic-version": "2023-06-01", |
|
}; |
|
} |
|
return { |
|
"content-type": "application/json", |
|
authorization: `Bearer ${apiKey}`, |
|
}; |
|
} |
|
|
|
function requestBody(api, model, prompt) { |
|
if (api === "anthropic-messages") { |
|
return { |
|
model, |
|
max_tokens: 32, |
|
temperature: 0, |
|
messages: [{ role: "user", content: prompt }], |
|
}; |
|
} |
|
return { |
|
model, |
|
max_tokens: 32, |
|
temperature: 0, |
|
messages: [{ role: "user", content: prompt }], |
|
}; |
|
} |
|
|
|
async function fetchJson(url, init, timeoutMs) { |
|
const controller = new AbortController(); |
|
const timer = setTimeout(() => controller.abort(), timeoutMs); |
|
try { |
|
const response = await fetch(url, { ...init, signal: controller.signal }); |
|
const text = await response.text(); |
|
let body; |
|
try { |
|
body = text ? JSON.parse(text) : null; |
|
} catch { |
|
body = { raw: text }; |
|
} |
|
return { response, body, rawText: text }; |
|
} finally { |
|
clearTimeout(timer); |
|
} |
|
} |
|
|
|
function errorMessage(body, rawText, fallback) { |
|
const error = body?.error; |
|
if (typeof error === "string") return error; |
|
if (error?.message) return error.message; |
|
if (body?.message) return body.message; |
|
if (rawText) return rawText.slice(0, 500); |
|
return fallback; |
|
} |
|
|
|
function textFromContent(content) { |
|
if (typeof content === "string") return content; |
|
if (!Array.isArray(content)) return ""; |
|
return content.map((part) => { |
|
if (typeof part === "string") return part; |
|
return part?.text ?? part?.content ?? ""; |
|
}).join(""); |
|
} |
|
|
|
function extractReply(api, body) { |
|
if (api === "anthropic-messages") { |
|
return textFromContent(body?.content); |
|
} |
|
return textFromContent(body?.choices?.[0]?.message?.content); |
|
} |
|
|
|
function modelIdsFromResponse(body) { |
|
if (!Array.isArray(body?.data)) return []; |
|
return body.data |
|
.map((model) => typeof model === "string" ? model : model?.id) |
|
.filter((id) => typeof id === "string" && id.length > 0); |
|
} |
|
|
|
async function discoverModels(keyName, apiKey, timeoutMs) { |
|
const started = performance.now(); |
|
try { |
|
const { response, body, rawText } = await fetchJson( |
|
`${BASE_URL}/models`, |
|
{ headers: { authorization: `Bearer ${apiKey}` } }, |
|
timeoutMs, |
|
); |
|
const models = modelIdsFromResponse(body); |
|
return { |
|
key: keyName, |
|
status: response.status, |
|
ok: response.ok, |
|
elapsedMs: elapsed(started), |
|
models, |
|
error: response.ok ? undefined : errorMessage(body, rawText, `HTTP ${response.status}`), |
|
}; |
|
} catch (error) { |
|
return { |
|
key: keyName, |
|
status: undefined, |
|
ok: false, |
|
elapsedMs: elapsed(started), |
|
models: [], |
|
error: error instanceof Error && error.name === "AbortError" ? `timeout after ${timeoutMs}ms` : String(error), |
|
}; |
|
} |
|
} |
|
|
|
async function probe(keyName, apiKey, model, options) { |
|
const api = apiForModel(model); |
|
const endpoint = endpointFor(api); |
|
const started = performance.now(); |
|
let lastFailure; |
|
|
|
for (let attempt = 0; attempt <= options.retries; attempt += 1) { |
|
try { |
|
const { response, body, rawText } = await fetchJson( |
|
endpoint, |
|
{ |
|
method: "POST", |
|
headers: headersFor(api, apiKey), |
|
body: JSON.stringify(requestBody(api, model, options.prompt)), |
|
}, |
|
options.timeoutMs, |
|
); |
|
const reply = extractReply(api, body); |
|
const passed = response.ok && reply.trim() === "PONG"; |
|
if (passed || attempt === options.retries) { |
|
return { |
|
key: keyName, |
|
model, |
|
api, |
|
endpoint, |
|
status: response.status, |
|
ok: response.ok, |
|
passed, |
|
elapsedMs: elapsed(started), |
|
reply: reply || undefined, |
|
finishReason: api === "anthropic-messages" |
|
? body?.stop_reason |
|
: body?.choices?.[0]?.finish_reason, |
|
usage: body?.usage, |
|
error: response.ok && !passed |
|
? `unexpected reply: ${JSON.stringify(reply).slice(0, 500)}` |
|
: response.ok ? undefined : errorMessage(body, rawText, `HTTP ${response.status}`), |
|
attempts: attempt + 1, |
|
}; |
|
} |
|
lastFailure = errorMessage(body, rawText, `HTTP ${response.status}`); |
|
} catch (error) { |
|
lastFailure = error instanceof Error && error.name === "AbortError" |
|
? `timeout after ${options.timeoutMs}ms` |
|
: String(error); |
|
if (attempt === options.retries) { |
|
return { |
|
key: keyName, |
|
model, |
|
api, |
|
endpoint, |
|
status: undefined, |
|
ok: false, |
|
passed: false, |
|
elapsedMs: elapsed(started), |
|
error: lastFailure, |
|
attempts: attempt + 1, |
|
}; |
|
} |
|
} |
|
} |
|
|
|
throw new Error(`Probe fell through unexpectedly: ${keyName}/${model}: ${lastFailure}`); |
|
} |
|
|
|
function elapsed(started) { |
|
return Math.round((performance.now() - started) * 100) / 100; |
|
} |
|
|
|
async function mapConcurrent(items, concurrency, worker) { |
|
const results = new Array(items.length); |
|
let next = 0; |
|
async function consume() { |
|
while (true) { |
|
const index = next++; |
|
if (index >= items.length) return; |
|
results[index] = await worker(items[index], index); |
|
} |
|
} |
|
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, consume)); |
|
return results; |
|
} |
|
|
|
function sortedUnique(values) { |
|
return [...new Set(values)].sort((a, b) => a.localeCompare(b)); |
|
} |
|
|
|
function buildSummary(discovery, probes) { |
|
const byKey = Object.fromEntries(discovery.map((item) => [item.key, { |
|
status: item.status, |
|
ok: item.ok, |
|
elapsedMs: item.elapsedMs, |
|
models: item.models, |
|
error: item.error, |
|
}])); |
|
const byModel = {}; |
|
for (const model of sortedUnique(probes.map((probe) => probe.model))) { |
|
const rows = probes.filter((probe) => probe.model === model); |
|
byModel[model] = { |
|
api: apiForModel(model), |
|
discoveredBy: discovery.filter((item) => item.models.includes(model)).map((item) => item.key), |
|
passedBy: rows.filter((row) => row.passed).map((row) => row.key), |
|
failedBy: rows.filter((row) => !row.passed).map((row) => row.key), |
|
}; |
|
} |
|
return { byKey, byModel }; |
|
} |
|
|
|
function printTable(report) { |
|
console.log("\nModel discovery:"); |
|
for (const item of report.discovery) { |
|
console.log(` ${item.key.padEnd(14)} ${String(item.status ?? "ERR").padEnd(5)} ${String(item.models.length).padStart(3)} models ${item.elapsedMs}ms${item.error ? ` ${item.error}` : ""}`); |
|
} |
|
|
|
console.log("\nProbe matrix (✓ means HTTP success and exact PONG):"); |
|
const keys = Object.keys(API_KEYS).filter((key) => report.selectedKeys.includes(key)); |
|
const models = sortedUnique(report.probes.map((probe) => probe.model)); |
|
console.log(` ${"model".padEnd(40)} ${keys.map((key) => key.padEnd(16)).join("")}`); |
|
for (const model of models) { |
|
const cells = keys.map((key) => { |
|
const row = report.probes.find((probe) => probe.model === model && probe.key === key); |
|
if (!row) return "".padEnd(16); |
|
const mark = row.passed ? "✓" : "✗"; |
|
return `${mark} ${row.status ?? "ERR"} ${row.elapsedMs}ms`.padEnd(16); |
|
}); |
|
console.log(` ${model.padEnd(40)} ${cells.join("")}`); |
|
} |
|
|
|
console.log("\nPer-model analysis:"); |
|
for (const [model, summary] of Object.entries(report.summary.byModel)) { |
|
console.log(` ${model} [${summary.api}]`); |
|
console.log(` discovered by: ${summary.discoveredBy.join(", ") || "none"}`); |
|
console.log(` passed by: ${summary.passedBy.join(", ") || "none"}`); |
|
console.log(` failed by: ${summary.failedBy.join(", ") || "none"}`); |
|
} |
|
} |
|
|
|
async function main() { |
|
const options = parseArgs(process.argv.slice(2)); |
|
const selectedKeys = options.keys ?? Object.keys(API_KEYS); |
|
const unknownKeys = selectedKeys.filter((key) => !(key in API_KEYS)); |
|
if (unknownKeys.length > 0) throw new Error(`Unknown key name(s): ${unknownKeys.join(", ")}`); |
|
|
|
const discovery = await mapConcurrent(selectedKeys, options.concurrency, (key) => |
|
discoverModels(key, API_KEYS[key], options.timeoutMs)); |
|
const union = sortedUnique(discovery.flatMap((item) => item.models)); |
|
const models = options.models ? union.filter((model) => options.models.includes(model)) : union; |
|
const missingRequestedModels = (options.models ?? []).filter((model) => !union.includes(model)); |
|
const combos = options.discoverOnly ? [] : selectedKeys.flatMap((key) => models.map((model) => ({ key, model }))); |
|
const probes = await mapConcurrent(combos, options.concurrency, ({ key, model }) => |
|
probe(key, API_KEYS[key], model, options)); |
|
|
|
const report = { |
|
generatedAt: new Date().toISOString(), |
|
baseUrl: BASE_URL, |
|
selectedKeys, |
|
options: { |
|
concurrency: options.concurrency, |
|
timeoutMs: options.timeoutMs, |
|
retries: options.retries, |
|
prompt: options.prompt, |
|
}, |
|
discovery, |
|
unionModels: union, |
|
selectedModels: models, |
|
missingRequestedModels, |
|
probes, |
|
summary: buildSummary(discovery, probes), |
|
}; |
|
|
|
if (options.out) { |
|
await writeFile(options.out, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); |
|
if (!options.json) console.log(`\nWrote report: ${options.out}`); |
|
} |
|
if (options.json) console.log(JSON.stringify(report, null, 2)); |
|
else printTable(report); |
|
} |
|
|
|
main().catch((error) => { |
|
console.error(`error: ${error instanceof Error ? error.message : String(error)}`); |
|
process.exitCode = 1; |
|
}); |