← back to Ventura Claw
server/lib/smart-router.js
123 lines
// VenturaClaw — Smart LLM Router
// Goal: route prompts to the cheapest viable model. Local Ollama for the easy 80%,
// Claude/GPT only for the hard 20%. Saves Anthropic spend without sacrificing UX.
//
// Wiring:
// const router = require("./lib/smart-router");
// const { answer, routed_to, est_cost_usd } = await router.route(prompt, { allowAnthropic: true });
//
// Toggle env:
// SMART_ROUTER_ALLOW_ANTHROPIC=1 - opt-in for Claude fallback (default: false)
// SMART_ROUTER_ALLOW_OPENAI=1 - opt-in for OpenAI fallback (default: false)
//
// Hard rule: never spawn `claude` CLI from this module without explicit opt-in.
const OLLAMA_URL = process.env.OLLAMA_URL || "http://192.168.1.133:11434";
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "qwen3:14b";
// Tiers, ordered cheapest → most capable.
const TIERS = [
{ id: "local-fast", label: "Ollama qwen3:14b (Mac1)", url: OLLAMA_URL, model: OLLAMA_MODEL, cost_per_1k: 0, max_tokens: 800 },
{ id: "local-big", label: "Ollama deepseek-r1:14b", url: OLLAMA_URL, model: "deepseek-r1:14b", cost_per_1k: 0, max_tokens: 1200 },
{ id: "anthropic", label: "Claude (via CLI)", url: null, model: "claude-opus-4-8", cost_per_1k: 0.003, max_tokens: 4000 },
];
// Estimate complexity 0..1 from a prompt without calling any LLM. Heuristic, not perfect — but free.
function estimateComplexity(prompt) {
if (!prompt) return 0;
const len = prompt.length;
let score = 0;
if (len > 200) score += 0.15;
if (len > 600) score += 0.20;
if (len > 1500) score += 0.25;
// signals of complex synthesis / reasoning / code
const keywords = /\b(legal|contract|tax|liability|jurisdiction|comply|implications|architecture|migration|debug|refactor|design|tradeoff|why does|prove|optimize|because)\b/i;
if (keywords.test(prompt)) score += 0.20;
// signals of coding / technical depth
if (/\bcode|function|regex|algorithm|complexity|big-o|recursion|async\b/i.test(prompt)) score += 0.20;
// signals of nuance / multi-step / comparison
if (/\b(vs\.?|compare|differ|pros|cons|alternatives?|deciding between)\b/i.test(prompt)) score += 0.15;
// signals of simple factual lookup → keep low
if (/^(what is|how do i|where can i|when is|who is|is there)/i.test(prompt) && len < 120) score -= 0.10;
return Math.max(0, Math.min(1, score));
}
function pickTier(complexity, opts = {}) {
const allowAnthropic = opts.allowAnthropic ?? (process.env.SMART_ROUTER_ALLOW_ANTHROPIC === "1");
// Tuned for "qwen3:14b is pre-warmed, deepseek-r1 cold-loads ~45s on Mac1".
// Stay on the warm model for ~90% of traffic.
if (complexity < 0.55) return TIERS[0]; // pre-warmed local
if (complexity < 0.80) return TIERS[1]; // bigger local — accept cold load risk
if (allowAnthropic) return TIERS[2]; // Claude only for the genuinely hardest prompts
return TIERS[0]; // fall back to fast/warm if Claude not allowed
}
async function callOllama(tier, prompt, opts = {}) {
const r = await fetch(`${tier.url}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: tier.model,
prompt,
stream: false,
keep_alive: "30m",
options: { temperature: opts.temperature ?? 0.3, num_predict: opts.maxTokens || tier.max_tokens },
}),
signal: AbortSignal.timeout(opts.timeoutMs || 60000),
});
if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text()).slice(0, 200)}`);
const d = await r.json();
return (d.response || "").trim();
}
// Claude fallback — uses the Max plan via the `claude` CLI to avoid burning Anthropic API credits.
// Spawns one-shot, never persists a session. Strips ANTHROPIC_API_KEY env to ensure CLI uses Max plan.
async function callClaudeCLI(prompt, opts = {}) {
const { spawn } = require("child_process");
return new Promise((resolve, reject) => {
const env = { ...process.env };
delete env.ANTHROPIC_API_KEY;
delete env.ANTHROPIC_AUTH_TOKEN;
const child = spawn("claude", ["--model", "opus", "-p", "--output-format", "text"], { env });
let out = "", err = "";
child.stdout.on("data", d => out += d);
child.stderr.on("data", d => err += d);
child.on("close", code => code === 0 ? resolve(out.trim()) : reject(new Error(`claude CLI exit ${code}: ${err.slice(0, 200)}`)));
child.on("error", reject);
const timer = setTimeout(() => { child.kill(); reject(new Error("claude CLI timeout")); }, opts.timeoutMs || 90000);
child.on("close", () => clearTimeout(timer));
child.stdin.end(prompt);
});
}
async function route(prompt, opts = {}) {
const start = Date.now();
const complexity = estimateComplexity(prompt);
const tier = pickTier(complexity, opts);
let answer, error;
try {
if (tier.id === "anthropic") {
answer = await callClaudeCLI(prompt, opts);
} else {
answer = await callOllama(tier, prompt, opts);
}
} catch (e) {
error = e.message;
// graceful fallback: if local-fast fails, try local-big; if local-big fails, give up (don't escalate to Claude on error — bill control).
if (tier.id === "local-fast") {
try { answer = await callOllama(TIERS[1], prompt, opts); } catch (e2) { error += " · also " + e2.message; }
}
}
return {
answer: answer || `(error) ${error}`,
routed_to: tier.id,
routed_to_label: tier.label,
complexity_score: complexity,
est_cost_usd: tier.cost_per_1k * Math.ceil((prompt.length + (answer || "").length) / 4 / 1000),
elapsed_ms: Date.now() - start,
error: error || null,
};
}
module.exports = { route, estimateComplexity, pickTier, TIERS };