Files
2026-07-13 13:39:12 +08:00

320 lines
11 KiB
JavaScript

/**
* scripts/test/_vpsClient.mjs
*
* Reusable Phase-3 VPS HTTP client for OmniRoute combo live-smoke tests.
* NOT a test file — intentionally placed in scripts/test/ so check:test-discovery
* does not scan it.
*
* Combo create/delete mechanism: SSH-sqlite fallback.
* /api/combos requires management auth (returns 401 unauthenticated).
* We insert/delete rows directly via:
* execFileSync("ssh", ["root@192.168.0.15", "sqlite3", "/root/.omniroute/storage.sqlite", SQL])
* Values are static test-scoped data — no untrusted interpolation.
*
* combos table schema (PRAGMA table_info):
* id TEXT PK, name TEXT NOT NULL, data TEXT NOT NULL (JSON blob),
* created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
* system_message TEXT, tool_filter_regex TEXT,
* context_cache_protection INTEGER DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0
*
* The `data` column stores the full combo as JSON (name, models[], strategy, config,
* id, createdAt, updatedAt, version, sortOrder).
*/
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
const BASE_URL = process.env.COMBO_LIVE_BASE_URL ?? "http://192.168.0.15:20128";
const API_KEY = process.env.COMBO_LIVE_API_KEY ?? null;
const VPS_SSH_HOST = "root@192.168.0.15";
const VPS_DB_PATH = "/root/.omniroute/storage.sqlite";
// ---------------------------------------------------------------------------
// Nonce counter — increments per call so semantic cache cannot serve stale
// ---------------------------------------------------------------------------
let _nonceCounter = 0;
export function nonce() {
return ++_nonceCounter;
}
// ---------------------------------------------------------------------------
// Shared fetch helpers
// ---------------------------------------------------------------------------
function authHeaders() {
const h = { "Content-Type": "application/json" };
if (API_KEY) h["Authorization"] = `Bearer ${API_KEY}`;
return h;
}
async function fetchJson(path, options = {}) {
const url = `${BASE_URL}${path}`;
const res = await fetch(url, { ...options, headers: { ...authHeaders(), ...(options.headers ?? {}) } });
let body;
try {
body = await res.json();
} catch {
body = null;
}
return { status: res.status, ok: res.ok, body };
}
// ---------------------------------------------------------------------------
// health() — GET /api/monitoring/health
// ---------------------------------------------------------------------------
export async function health() {
const { status, body } = await fetchJson("/api/monitoring/health");
return {
status,
version: body?.version ?? null,
uptime: body?.uptime ?? null,
raw: body,
};
}
// ---------------------------------------------------------------------------
// chat() — POST /v1/chat/completions (non-streaming)
// ---------------------------------------------------------------------------
export async function chat(model, { maxTokens = 16, content } = {}) {
const n = nonce();
const userContent = content ?? `ping ${n}`;
const payload = {
model,
stream: false,
max_tokens: maxTokens,
temperature: 0,
messages: [{ role: "user", content: userContent }],
};
const { status, body } = await fetchJson("/v1/chat/completions", {
method: "POST",
body: JSON.stringify(payload),
});
const text = body?.choices?.[0]?.message?.content ?? null;
return {
status,
model: body?.model ?? model,
text,
raw: body,
};
}
// ---------------------------------------------------------------------------
// Combo create/delete via SSH sqlite
// ---------------------------------------------------------------------------
function sshSqlite(sql) {
// Pipe SQL via stdin to avoid shell quoting issues with complex SQL statements.
// ssh + sqlite3 reads from stdin when no trailing SQL arg is given.
return execFileSync("ssh", [VPS_SSH_HOST, "sqlite3", VPS_DB_PATH], {
input: sql,
encoding: "utf8",
timeout: 15_000,
}).trim();
}
/**
* createCombo(def) — inserts a combo row via SSH sqlite.
*
* def shape:
* { name, strategy, models, config }
*
* models: array of "providerId/model" strings OR
* array of { providerId, model, connectionId?, weight? } objects.
*
* config: optional object (judgeModel, fusionTuning, etc.)
*
* Returns the combo id string.
*/
export function createCombo(def) {
const id = randomUUID();
const now = new Date().toISOString();
const strategy = def.strategy ?? "priority";
const config = def.config ?? {};
const name = def.name;
// Normalise models to the shape the DB expects
const rawModels = def.models ?? [];
const models = rawModels.map((m, idx) => {
if (typeof m === "string") {
// "providerId/model" shorthand
const slashIdx = m.indexOf("/");
const providerId = slashIdx >= 0 ? m.slice(0, slashIdx) : m;
const model = slashIdx >= 0 ? m.slice(slashIdx + 1) : m;
const slugName = name.toLowerCase().replace(/[^a-z0-9]/g, "-");
const slugModel = model.toLowerCase().replace(/[^a-z0-9]/g, "-");
return {
id: `${slugName}-model-${idx + 1}-${providerId}-${slugModel}-${randomUUID()}`,
kind: "model",
model: `${providerId}/${model}`,
providerId,
weight: 1,
};
}
// Already an object
const providerId = m.providerId;
const model = m.model;
const slugName = name.toLowerCase().replace(/[^a-z0-9]/g, "-");
const slugModel = (model ?? "").toLowerCase().replace(/[^a-z0-9]/g, "-");
return {
id: m.id ?? `${slugName}-model-${idx + 1}-${providerId}-${slugModel}-${randomUUID()}`,
kind: "model",
model: model.includes("/") ? model : `${providerId}/${model}`,
providerId,
...(m.connectionId ? { connectionId: m.connectionId } : {}),
weight: m.weight ?? 1,
};
});
const dataObj = {
name,
models,
strategy,
config,
id,
createdAt: now,
updatedAt: now,
version: 2,
sortOrder: 9999,
};
const dataJson = JSON.stringify(dataObj).replace(/'/g, "''");
const nameSafe = name.replace(/'/g, "''");
const sql = `INSERT INTO combos (id, name, data, created_at, updated_at, sort_order) VALUES ('${id}', '${nameSafe}', '${dataJson}', '${now}', '${now}', 9999);`;
sshSqlite(sql);
return id;
}
/**
* deleteCombo(nameOrId) — deletes a combo by name or id via SSH sqlite.
* Only deletes __live_test__* prefixed combos as a safety guard.
*/
export function deleteCombo(nameOrId) {
if (!nameOrId.startsWith("__live_test__")) {
throw new Error(`deleteCombo safety guard: refusing to delete '${nameOrId}' — only __live_test__* combos allowed.`);
}
const safe = nameOrId.replace(/'/g, "''");
// Try delete by name first, then by id
sshSqlite(`DELETE FROM combos WHERE name='${safe}' OR id='${safe}';`);
}
// ---------------------------------------------------------------------------
// listHealthyProviders(candidates) — probe each "provider/model" candidate
// ---------------------------------------------------------------------------
/**
* For each "provider/model" string, fire a minimal chat() and keep those
* returning HTTP 200 with non-empty text.
*
* @param {string[]} candidates - array of "provider/model" strings
* @returns {Promise<string[]>} healthy candidates
*/
export async function listHealthyProviders(candidates) {
const results = await Promise.allSettled(
candidates.map(async (c) => {
const r = await chat(c, { maxTokens: 16 });
return r.status === 200 && r.text ? c : null;
})
);
return results
.map((r) => (r.status === "fulfilled" ? r.value : null))
.filter(Boolean);
}
// ---------------------------------------------------------------------------
// Preflight self-check (run directly: node scripts/test/_vpsClient.mjs)
// ---------------------------------------------------------------------------
const isMain = process.argv[1]?.endsWith("_vpsClient.mjs") ||
import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
(async () => {
console.log("=== OmniRoute VPS Phase-3 Preflight ===");
console.log(`Base URL: ${BASE_URL}`);
console.log(`API key: ${API_KEY ? "set (Bearer)" : "not set (REQUIRE_API_KEY=false)"}`);
console.log();
// 1. Health
console.log("--- health() ---");
try {
const h = await health();
console.log(` status: ${h.status}`);
console.log(` version: ${h.version}`);
} catch (e) {
console.error(` ERROR: ${e.message}`);
}
// 2. Combo mechanism probe
console.log();
console.log("--- combo create/delete mechanism ---");
console.log(" /api/combos GET (unauthenticated):", (() => {
try {
const r = execFileSync("curl", ["-s", "-o", "/dev/null", "-w", "%{http_code}", `${BASE_URL}/api/combos`], { encoding: "utf8", timeout: 5000 });
return r.trim();
} catch { return "error"; }
})());
console.log(" Mechanism: SSH sqlite fallback (management API requires auth)");
console.log(" SSH host:", VPS_SSH_HOST);
console.log(" DB path:", VPS_DB_PATH);
// 3. Create + delete a probe combo via SSH sqlite
console.log();
console.log("--- createCombo / deleteCombo (SSH sqlite) ---");
const probeName = "__live_test__probe";
let probeId;
try {
probeId = createCombo({
name: probeName,
strategy: "priority",
models: ["groq/llama-3.1-8b-instant"],
config: {},
});
console.log(` created id: ${probeId}`);
deleteCombo(probeName);
console.log(` deleted OK`);
} catch (e) {
console.error(` ERROR: ${e.message}`);
// Attempt cleanup on error
if (probeId) {
try { deleteCombo(probeName); } catch {}
}
}
// 4. ollama-cloud chat probe
console.log();
console.log("--- chat probe: ollama-cloud/glm-5.2 ---");
try {
const r = await chat("ollama-cloud/glm-5.2", { maxTokens: 16 });
console.log(` status: ${r.status}`);
console.log(` model: ${r.model}`);
console.log(` text: ${r.text ? r.text.slice(0, 80) : "(empty)"}`);
if (r.status !== 200 || !r.text) {
console.log(" NOTE: ollama-cloud/glm-5.2 did not return a valid response.");
console.log(" raw error:", JSON.stringify(r.raw?.error ?? r.raw).slice(0, 200));
}
} catch (e) {
console.error(` ERROR: ${e.message}`);
}
// 5. Quick healthy provider scan over a small candidate set
console.log();
console.log("--- listHealthyProviders (subset scan) ---");
const candidates = [
"groq/llama-3.1-8b-instant",
"gemini/gemini-2.0-flash",
"deepseek/deepseek-chat",
"cerebras/llama3.1-8b",
"ollama-cloud/glm-5.2",
];
try {
const healthy = await listHealthyProviders(candidates);
console.log(` tested: ${candidates.join(", ")}`);
console.log(` healthy (${healthy.length}/${candidates.length}): ${healthy.join(", ") || "(none)"}`);
} catch (e) {
console.error(` ERROR: ${e.message}`);
}
console.log();
console.log("=== Preflight complete ===");
})();
}