Docs/Skills and code/Top-level code
test-openrouter.js
import { TextDecoder } from "util";
import { pathToFileURL } from "node:url";
import { parseJson } from "./src/llm.js";
// ============================================================
// OPENROUTER FREE-ENDPOINT TEST
//
// node test-openrouter.js list free models, chat with the first that answers
// node test-openrouter.js --list list free models only (no API key needed)
// node test-openrouter.js --model=<id> use one specific model (no fallback)
// node test-openrouter.js --prompt="..." custom user prompt
// node test-openrouter.js --no-stream wait for the full response instead of streaming
// node test-openrouter.js --json ask for JSON and run it through parseJson()
//
// Chat requests need OPENROUTER_API_KEY, from the environment or a .env file.
// Optional: OPENROUTER_MODEL, OPENROUTER_BASE_URL.
// ============================================================
try {
process.loadEnvFile(".env");
} catch {
// No .env file; rely on the real environment.
}
const MAX_FALLBACK_MODELS = 5;
const DEFAULT_PROMPT = "Reply with one short sentence confirming that you are working.";
const JSON_SYSTEM = "You are a classifier. Return only a JSON object, with no commentary.";
const JSON_PROMPT = [
'Classify the YouTube channel "Veritasium" against the ecosystem "science".',
'Return {"classification":"core|adjacent|unknown|irrelevant","confidence":0.0,"rationale":"one sentence"}.'
].join("\n");
export function getOpenRouterConfig() {
return {
baseUrl: (process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1").replace(/\/$/, ""),
apiKey: process.env.OPENROUTER_API_KEY || "",
model: process.env.OPENROUTER_MODEL || ""
};
}
// Free models are priced at zero for both prompt and completion; most also carry a ":free" suffix.
// The catalogue changes often, so this is read live instead of being hardcoded.
export async function listFreeModels() {
const { baseUrl } = getOpenRouterConfig();
const res = await fetch(`${baseUrl}/models`);
if (!res.ok) {
const text = await res.text();
throw new Error(`OpenRouter model list failed: ${res.status} ${res.statusText} - ${text}`);
}
const { data = [] } = await res.json();
return data
.filter((m) =>
m.id.endsWith(":free") ||
(Number(m.pricing?.prompt) === 0 && Number(m.pricing?.completion) === 0)
)
.filter((m) => (m.architecture?.output_modalities ?? ["text"]).includes("text"))
.sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
.map((m) => ({ id: m.id, name: m.name, contextLength: m.context_length ?? null }));
}
// maxTokens defaults to a small value for quick tests. Batch JSON replies need far more.
export async function postChat({ model, messages, stream, maxTokens = 1200, temperature = 0.1, timeoutMs = 0 }) {
const { baseUrl, apiKey } = getOpenRouterConfig();
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set. Put it in the environment or in a .env file.");
}
const res = await fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"X-Title": "YouTube Ecosystem Intelligence"
},
body: JSON.stringify({ model, messages, stream, temperature, max_tokens: maxTokens }),
signal: timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined
});
if (!res.ok) {
const text = await res.text();
const error = new Error(
`OpenRouter request failed (${model}): ${res.status} ${res.statusText} - ${text.slice(0, 1200)}`
);
error.status = res.status;
throw error;
}
return res;
}
export async function callOpenRouter(messages, { model, stream = true } = {}) {
if (!model) {
throw new Error("No model given. Pass one, set OPENROUTER_MODEL, or let the script pick a free model.");
}
const res = await postChat({ model, messages, stream });
if (!stream) {
const data = await res.json();
if (data.error) {
throw new Error(`OpenRouter error (${model}): ${JSON.stringify(data.error).slice(0, 1200)}`);
}
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error(
`OpenRouter returned no content (${model}), finish_reason=${data.choices?.[0]?.finish_reason ?? "unknown"}`
);
}
return content.trim();
}
if (!res.body) {
throw new Error("OpenRouter response has no body to stream");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let result = "";
process.stdout.write("[LLM] ");
const flushLine = (line) => {
const trimmed = line.trim();
// SSE comments such as ": OPENROUTER PROCESSING" are keep-alives.
if (!trimmed || trimmed.startsWith(":") || trimmed === "data: [DONE]") {
return;
}
const payloadText = trimmed.startsWith("data:") ? trimmed.slice(5).trim() : trimmed;
let payload;
try {
payload = JSON.parse(payloadText);
} catch {
return;
}
// Errors that happen after streaming has started arrive as a normal chunk.
if (payload.error) {
throw new Error(`OpenRouter stream error (${model}): ${JSON.stringify(payload.error).slice(0, 1200)}`);
}
const text = payload.choices?.[0]?.delta?.content;
if (text) {
process.stdout.write(text);
result += text;
}
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || "";
for (const line of lines) {
flushLine(line);
}
}
buffer += decoder.decode();
if (buffer) {
flushLine(buffer);
}
} catch (error) {
// Keep the error message off the partial streamed line.
process.stdout.write("\n");
throw error;
}
process.stdout.write("\n");
if (!result.trim()) {
throw new Error(`OpenRouter stream ended with no content (${model})`);
}
return result.trim();
}
export async function callLLM({
system = "",
user = "",
model = getOpenRouterConfig().model,
stream = true
} = {}) {
const messages = [
system?.trim() ? { role: "system", content: system.trim() } : null,
{ role: "user", content: user.trim() }
].filter(Boolean);
const content = await callOpenRouter(messages, { model, stream });
return {
content,
model
};
}
export { parseJson as parseJsonObject };
// ============================================================
// CLI
// ============================================================
const args = process.argv.slice(2);
const hasFlag = (name) => args.includes(`--${name}`);
const getOption = (name) => args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
async function main() {
const freeModels = await listFreeModels();
console.log(`Found ${freeModels.length} free text models. Newest first:`);
for (const m of freeModels.slice(0, 15)) {
console.log(` ${m.id} (context ${m.contextLength ?? "?"})`);
}
if (hasFlag("list")) {
return;
}
if (!getOpenRouterConfig().apiKey) {
throw new Error("OPENROUTER_API_KEY is not set. Put it in the environment or in a .env file (--list works without it).");
}
const explicitModel = getOption("model") || getOpenRouterConfig().model;
const candidates = explicitModel
? [explicitModel]
: freeModels.slice(0, MAX_FALLBACK_MODELS).map((m) => m.id);
if (!candidates.length) {
throw new Error("No free models were found to test.");
}
const useJson = hasFlag("json");
const request = {
system: useJson ? JSON_SYSTEM : "",
user: getOption("prompt") || (useJson ? JSON_PROMPT : DEFAULT_PROMPT),
stream: !hasFlag("no-stream")
};
// Free endpoints are often rate limited or briefly unavailable, so walk down the list.
for (const model of candidates) {
console.log(`\nCalling ${model} ...`);
try {
const started = Date.now();
const result = await callLLM({ ...request, model });
if (!request.stream) {
console.log(`[LLM] ${result.content}`);
}
console.log(`\nOK: ${result.model} answered in ${Date.now() - started} ms (${result.content.length} chars)`);
if (useJson) {
console.log("parseJson() result:");
console.log(parseJson(result.content));
}
return;
} catch (error) {
console.error(`FAILED: ${error.message}`);
}
}
throw new Error(`None of the ${candidates.length} model(s) tried returned a usable answer.`);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.