[object Object]

← back to Big Red

Add LLM_BACKEND=ollama switch; Big Red answers from Mac2 qwen3:14b over tailnet (no Anthropic-credit dependency)

b7f86db28abb115f53d38a5bd5ca267fac61e76b · 2026-06-24 16:42:54 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit b7f86db28abb115f53d38a5bd5ca267fac61e76b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jun 24 16:42:54 2026 -0700

    Add LLM_BACKEND=ollama switch; Big Red answers from Mac2 qwen3:14b over tailnet (no Anthropic-credit dependency)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 67 insertions(+), 2 deletions(-)

diff --git a/server.js b/server.js
index 3f20436..c2cb374 100644
--- a/server.js
+++ b/server.js
@@ -322,12 +322,73 @@ app.get('/api/pair/new', async (req, res) => {
 // /api/chat request 500'd with ENOENT. Now we call the Anthropic Messages API
 // directly via @anthropic-ai/sdk. Same signature as the old runClaude so all
 // three callsites (chat, analyze, warm-up) keep working unchanged.
+// Backend switch. Default 'anthropic' (Messages API) keeps the original
+// behaviour; set LLM_BACKEND=ollama to answer from a local/self-hosted Ollama
+// model instead (no metered API spend, no Anthropic-credit dependency). On
+// Kamatera prod we point OLLAMA_URL at the Mac2 Studio's Ollama exposed over
+// the tailnet (tailscale serve --tcp). Anything else falls through to Anthropic.
+const LLM_BACKEND = (process.env.LLM_BACKEND || 'anthropic').toLowerCase();
+const OLLAMA_URL = (process.env.OLLAMA_URL || 'http://127.0.0.1:11434').replace(/\/+$/, '');
+const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen3:14b';
+const OLLAMA_VISION_MODEL = process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b';
+
 const ANTHROPIC_KEY = process.env.ANTHROPIC_API_KEY || '';
-if (!ANTHROPIC_KEY) {
-  console.warn('[big-red] WARNING: ANTHROPIC_API_KEY not set — /api/chat and /api/analyze will 500. Add it to .env.');
+if (LLM_BACKEND === 'anthropic' && !ANTHROPIC_KEY) {
+  console.warn('[big-red] WARNING: ANTHROPIC_API_KEY not set — /api/chat and /api/analyze will 500. Add it to .env or set LLM_BACKEND=ollama.');
+}
+if (LLM_BACKEND === 'ollama') {
+  console.log(`[big-red] LLM backend: ollama (${OLLAMA_URL}, text=${OLLAMA_MODEL}, vision=${OLLAMA_VISION_MODEL})`);
 }
 const anthropic = ANTHROPIC_KEY ? new Anthropic({ apiKey: ANTHROPIC_KEY }) : null;
 
+// Local/self-hosted backend via Ollama's OpenAI-compatible endpoint. Same
+// contract as runClaude: takes the assembled prompt, returns the reply text.
+// Vision requests (imagePath set) route to a vision-capable model; text chat
+// uses the main text model. Thinking models (qwen3) may wrap their reasoning in
+// <think>…</think> — we strip it so clients only see the final answer.
+async function runOllama(prompt, { imagePath = null, timeoutMs = 240_000 } = {}) {
+  const model = imagePath ? OLLAMA_VISION_MODEL : OLLAMA_MODEL;
+  let content;
+  if (imagePath) {
+    const buf = fs.readFileSync(imagePath);
+    const ext = path.extname(imagePath).slice(1).toLowerCase();
+    const media_type =
+      ext === 'png' ? 'image/png' :
+      ext === 'webp' ? 'image/webp' :
+      ext === 'gif' ? 'image/gif' :
+      'image/jpeg';
+    content = [
+      { type: 'text', text: prompt },
+      { type: 'image_url', image_url: { url: `data:${media_type};base64,${buf.toString('base64')}` } },
+    ];
+  } else {
+    content = prompt;
+  }
+  const ac = new AbortController();
+  const t = setTimeout(() => ac.abort(), timeoutMs);
+  try {
+    const r = await fetch(`${OLLAMA_URL}/v1/chat/completions`, {
+      method: 'POST',
+      headers: { 'content-type': 'application/json' },
+      body: JSON.stringify({ model, max_tokens: 1024, stream: false, messages: [{ role: 'user', content }] }),
+      signal: ac.signal,
+    });
+    if (!r.ok) {
+      const body = await r.text().catch(() => '');
+      throw new Error(`Ollama ${r.status}: ${body.slice(0, 200)}`);
+    }
+    const d = await r.json();
+    let text = d?.choices?.[0]?.message?.content || '';
+    text = text.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+    return text;
+  } catch (e) {
+    if (e.name === 'AbortError') throw new Error('Ollama request timed out');
+    throw e;
+  } finally {
+    clearTimeout(t);
+  }
+}
+
 // Map the legacy `model: 'haiku'` shorthand (used by old CLI callers) to a
 // current API model ID. Anything else is passed through verbatim so callers
 // can ask for a specific snapshot.
@@ -339,6 +400,10 @@ function resolveModelId(m) {
 }
 
 function runClaude(prompt, { imagePath = null, model = 'haiku', timeoutMs = 240_000 } = {}) {
+  // Local backend takes over when selected — same return contract (reply text).
+  if (LLM_BACKEND === 'ollama') {
+    return runOllama(prompt, { imagePath, timeoutMs });
+  }
   if (!anthropic) {
     return Promise.reject(new Error('ANTHROPIC_API_KEY missing — cannot call Anthropic API'));
   }

← 3315d11 auto-save: 2026-06-21T18:53:05 (1 files) — .claude/  ·  back to Big Red  ·  (newest)