← back to Vendor Recrawl Dispatcher
lib/enrich-router.js
110 lines
'use strict';
// LOCAL-MODELS-ONLY enrichment router for the recrawl dispatcher.
//
// Order of preference (HARD CONSTRAINT — no paid APIs ever):
// 1. exo cluster -> http://localhost:52415/v1/chat/completions (OpenAI-compatible)
// 2. Mac1 Ollama fallback -> http://192.168.1.133:11434/api/chat (qwen3:14b)
// 3. SKIP enrichment -> if BOTH are down, structured scrape still writes;
// the data refresh is NEVER blocked on a model being down.
//
// Every call reports a "$0 (local)" cost line. Reaching for Gemini/OpenAI/Replicate
// here is forbidden.
const cfg = require('./config');
async function fetchWithTimeout(url, opts, ms) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), ms);
try {
return await fetch(url, { ...opts, signal: ctrl.signal });
} finally {
clearTimeout(t);
}
}
// --- health checks -------------------------------------------------------
// exo's daemon can be UP (/v1/models = 200) while it CANNOT serve inference
// (mlx not built — needs full Xcode). So "up" means it actually returns non-empty
// content for a tiny completion, not merely that the API answers. This makes the
// router correctly fall through to Ollama today and auto-upgrade to exo once it serves.
async function exoUp() {
try {
const models = await fetchWithTimeout(`${cfg.EXO_BASE}/v1/models`, {}, cfg.HEALTH_TIMEOUT_MS);
if (!models.ok) return false;
const r = await fetchWithTimeout(`${cfg.EXO_BASE}/v1/chat/completions`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: cfg.EXO_MODEL, messages: [{ role: 'user', content: 'ok' }], max_tokens: 4, stream: false }),
}, cfg.ENRICH_TIMEOUT_MS);
if (!r.ok) return false;
const j = await r.json();
return !!(j?.choices?.[0]?.message?.content || '').trim(); // serves only if non-empty
} catch (_) { return false; }
}
async function ollamaUp() {
try {
const r = await fetchWithTimeout(`${cfg.OLLAMA_BASE}/api/tags`, {}, cfg.HEALTH_TIMEOUT_MS);
if (!r.ok) return false;
const j = await r.json();
return Array.isArray(j.models) && j.models.some(m => (m.name || '').startsWith(cfg.OLLAMA_MODEL.split(':')[0]));
} catch (_) { return false; }
}
// Resolve which provider to use this run. Cache within a process so we health-check once.
let _routeCache = null;
async function resolveProvider() {
if (_routeCache) return _routeCache;
if (await exoUp()) _routeCache = 'exo';
else if (await ollamaUp()) _routeCache = 'ollama';
else _routeCache = 'none';
return _routeCache;
}
// --- enrichment call -----------------------------------------------------
// messages: [{role, content}]. Returns { provider, text, costUSD:0, skipped }.
async function chat(messages) {
const provider = await resolveProvider();
const costLine = { costUSD: 0, cost: '$0 (local)' };
if (provider === 'exo') {
try {
const r = await fetchWithTimeout(`${cfg.EXO_BASE}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: cfg.EXO_MODEL, messages, stream: false, max_tokens: 512 }),
}, cfg.ENRICH_TIMEOUT_MS);
const j = await r.json();
const text = j?.choices?.[0]?.message?.content || '';
if (text.trim()) return { provider: 'exo', text, ...costLine, skipped: false };
// exo answered EMPTY (daemon up but not serving) -> fall back to ollama, don't return blank
if (await ollamaUp()) return chatOllama(messages, costLine);
return { provider: 'none', text: '', ...costLine, skipped: true };
} catch (e) {
// exo blipped mid-run -> try ollama once before giving up
if (await ollamaUp()) return chatOllama(messages, costLine);
return { provider: 'none', text: '', ...costLine, skipped: true, error: String(e) };
}
}
if (provider === 'ollama') return chatOllama(messages, costLine);
// both down -> skip (never block the refresh, never reach for a paid API)
return { provider: 'none', text: '', ...costLine, skipped: true };
}
async function chatOllama(messages, costLine) {
try {
const r = await fetchWithTimeout(`${cfg.OLLAMA_BASE}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: cfg.OLLAMA_MODEL, messages, stream: false }),
}, cfg.ENRICH_TIMEOUT_MS);
const j = await r.json();
const text = j?.message?.content || '';
return { provider: 'ollama', text, ...costLine, skipped: false };
} catch (e) {
return { provider: 'none', text: '', ...costLine, skipped: true, error: String(e) };
}
}
module.exports = { exoUp, ollamaUp, resolveProvider, chat };