← back to AbramsOS

lib/chat-providers/ollama.js

56 lines

// Ollama adapter — local, $0, on-box. Private lane: raw PII is allowed here
// because nothing leaves the network. Degrades gracefully when a model is not
// pulled.

const BASE = process.env.OLLAMA_BASE_URL || 'http://192.168.1.133:11434';
const CHAT_TIMEOUT_MS = 60_000;
const TAGS_TIMEOUT_MS = 3_000;

async function availableModels(base = BASE) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), TAGS_TIMEOUT_MS);
  try {
    const r = await fetch(`${base}/api/tags`, { signal: ctrl.signal });
    if (!r.ok) return [];
    const j = await r.json();
    return (j.models || []).map((m) => m.name);
  } catch (_) {
    return [];
  } finally {
    clearTimeout(t);
  }
}

async function chat({ system, messages, providerModel }) {
  const models = await availableModels();
  if (models.length && !models.includes(providerModel)) {
    throw new Error(`model ${providerModel} not pulled on Ollama`);
  }

  const msgs = [];
  if (system) msgs.push({ role: 'system', content: String(system) });
  for (const m of messages) {
    msgs.push({ role: m.role === 'assistant' ? 'assistant' : 'user', content: String(m.content ?? '') });
  }

  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), CHAT_TIMEOUT_MS);
  try {
    const r = await fetch(`${BASE}/api/chat`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: providerModel, messages: msgs, stream: false }),
      signal: ctrl.signal,
    });
    if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text().catch(() => '')).slice(0, 300)}`);
    const j = await r.json();
    const text = (j.message?.content || '').trim();
    if (!text) throw new Error('ollama returned empty response');
    return { text };
  } finally {
    clearTimeout(t);
  }
}

module.exports = { chat, availableModels, BASE };