Docs/Skills and code/Top-level code

test-openrouter.js

Code287 lines8.6 KBTop-level code

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.