← 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 };