chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 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 ===");
|
||||
})();
|
||||
}
|
||||
Reference in New Issue
Block a user