#!/usr/bin/env python3 """ cluster.py — multi-node Ollama orchestrator. Three potential nodes: - pc : localhost (this PC, GPU, 10 models, fast but laptop-up only) - atlas-01 : :11434 (Hetzner VPS, CPU, 5 models, always-on) - met : :11434 (MET desktop, secondary capacity) Routing strategy: - For "always_on" tasks (overnight, scheduled, server-side): prefer atlas-01 - For "fast" tasks (interactive, jarvis, code completion): prefer pc - For "embed": prefer pc (nomic on both, pc is faster) - For "vision" (qwen2.5vl): pc only (atlas-01 doesn't have it) - Fallback chain: if preferred unreachable → try next available node that has the model Public API: ask(prompt, task='general', node_pref=None, max_tokens=512) -> {ok, text, node, model, elapsed_s} embed(text) -> {ok, vector, node, model} nodes_status() -> [{name, reachable, latency_ms, models}] pick_node(task, model=None) -> (node_name, model_name) """ from __future__ import annotations import json, time, urllib.request, urllib.error, socket NODES = { 'pc': {'host': '127.0.0.1', 'port': 11434, 'always_on': False, 'fast': True, 'has_gpu': True}, 'atlas-01': {'host': '', 'port': 11434, 'always_on': True, 'fast': False, 'has_gpu': False}, # FIX 2026-05-26: was (which is PC's own tailnet IP). # MET-Desktop actually lives at . 'met': {'host': '','port': 11434, 'always_on': False, 'fast': True, 'has_gpu': False}, } TASK_MODEL = { 'chat': 'hermes3:8b', 'voice': 'llama3.2:3b', 'fast': 'llama3.2:1b', 'code': 'qwen2.5-coder:7b', 'code_fast': 'qwen2.5-coder:1.5b', 'reason': 'phi3:medium', 'general': 'qwen2.5:7b', 'general_fast':'qwen2.5:1.5b', 'embed': 'nomic-embed-text', 'vision': 'qwen2.5vl:7b', } # Per-node model availability — populated lazily on first nodes_status() call. _NODE_MODELS_CACHE = {'pc': None, 'atlas-01': None, 'met': None} _CACHE_AT = {'pc': 0, 'atlas-01': 0, 'met': 0} _CACHE_TTL = 300 # 5 min def _http(url: str, body: dict | None = None, timeout=8): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method='POST' if body else 'GET') if body: req.add_header('Content-Type', 'application/json') with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode()) def _check_node(name: str) -> dict: """Probe a node — returns {reachable, latency_ms, models, err?}.""" if name not in NODES: return {'reachable': False, 'err': 'unknown node'} n = NODES[name] t0 = time.time() try: # Fast TCP check first with socket.create_connection((n['host'], n['port']), timeout=2): pass # Then fetch tags r = _http(f"http://{n['host']}:{n['port']}/api/tags", timeout=5) models = [m['name'] for m in r.get('models', [])] return {'reachable': True, 'latency_ms': int((time.time() - t0) * 1000), 'models': models} except (socket.error, urllib.error.URLError, urllib.error.HTTPError) as e: return {'reachable': False, 'latency_ms': int((time.time() - t0) * 1000), 'err': str(e)[:60]} def nodes_status(force=False) -> dict: """Return per-node {reachable, models, latency_ms}. Cached 5 min.""" out = {} now = time.time() for name in NODES: if not force and _NODE_MODELS_CACHE[name] is not None and now - _CACHE_AT[name] < _CACHE_TTL: out[name] = _NODE_MODELS_CACHE[name] else: s = _check_node(name) _NODE_MODELS_CACHE[name] = s _CACHE_AT[name] = now out[name] = s return out def pick_node(task: str = 'general', model: str | None = None, prefer: str | None = None) -> tuple[str, str] | None: """Decide (node, model) for a task. Returns None if no node available.""" if not model: model = TASK_MODEL.get(task, TASK_MODEL['general']) status = nodes_status() # Build preference order order = [] if prefer and prefer in NODES: order.append(prefer) # task-driven defaults if task in ('embed',) or task.endswith('_fast') or task == 'fast' or task == 'voice': order += ['pc', 'atlas-01', 'met'] elif task == 'vision': order += ['pc'] # only PC has vision model else: order += ['pc', 'atlas-01', 'met'] # dedupe preserving order seen = set(); order = [n for n in order if not (n in seen or seen.add(n))] for name in order: s = status.get(name, {}) if not s.get('reachable'): continue models = s.get('models', []) # exact match if model in models: return (name, model) # match-by-prefix (qwen2.5-coder:7b matches qwen2.5-coder:7b-instruct-q4_K_M etc.) for m in models: if m.startswith(model.split(':')[0]): return (name, m) return None def ask(prompt: str, task: str = 'general', node_pref: str | None = None, max_tokens: int = 512, temperature: float = 0.4, timeout: int = 120) -> dict: pick = pick_node(task, prefer=node_pref) if not pick: return {'ok': False, 'err': 'no node has the required model'} node, model = pick n = NODES[node] t0 = time.time() try: r = _http(f"http://{n['host']}:{n['port']}/api/generate", { 'model': model, 'prompt': prompt, 'stream': False, 'options': {'temperature': temperature, 'num_predict': max_tokens}, }, timeout=timeout) return {'ok': True, 'text': r.get('response', '').strip(), 'node': node, 'model': model, 'elapsed_s': round(time.time() - t0, 2)} except Exception as e: return {'ok': False, 'err': str(e)[:120], 'node': node, 'model': model, 'elapsed_s': round(time.time() - t0, 2)} def embed(text: str, node_pref: str | None = None) -> dict: pick = pick_node('embed', prefer=node_pref) if not pick: return {'ok': False, 'err': 'no embed model'} node, model = pick n = NODES[node] t0 = time.time() try: r = _http(f"http://{n['host']}:{n['port']}/api/embeddings", { 'model': model, 'prompt': text, }, timeout=30) return {'ok': True, 'vector': r.get('embedding', []), 'node': node, 'model': model, 'elapsed_s': round(time.time() - t0, 2)} except Exception as e: return {'ok': False, 'err': str(e)[:120]} if __name__ == '__main__': import sys cmd = sys.argv[1] if len(sys.argv) > 1 else 'status' if cmd == 'status': import io try: sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') except: pass s = nodes_status(force=True) for name, info in s.items(): mark = 'OK ' if info.get('reachable') else 'FAIL' mc = len(info.get('models', [])) lat = info.get('latency_ms', '?') err = info.get('err', '') print(f' {mark} {name:10s} {mc:>2d} models {lat:>4}ms {err}') elif cmd == 'ask': task = sys.argv[2] if len(sys.argv) > 2 else 'general' prompt = ' '.join(sys.argv[3:]) or 'say OK only' r = ask(prompt, task=task) if r['ok']: print(f'[{r["node"]} / {r["model"]} / {r["elapsed_s"]}s]') print(r['text']) else: print('ERR:', r.get('err')) elif cmd == 'pick': task = sys.argv[2] if len(sys.argv) > 2 else 'general' prefer = sys.argv[3] if len(sys.argv) > 3 else None p = pick_node(task, prefer=prefer) print(p or 'no node available')