#!/usr/bin/env python3 """ atlas_router_api.py — Atlas Chat backbone router (v0.1 2026-06-02) OpenAI-compatible /v1/chat/completions + /v1/models Routes to: Anthropic : atlas-haiku | atlas-sonnet | atlas-opus Ollama : atlas-free (hermes3:8b) | atlas-code (qwen-coder) atlas-fast (llama3.2:1b) | atlas-qwen (qwen2.5:7b) Smart : atlas-auto → heuristic picks cheapest backend that fits Run (local or atlas-01): python3 atlas_router_api.py --port 8888 ATLAS_ROUTER_PORT=8888 python3 atlas_router_api.py Env vars: ANTHROPIC_API_KEY required for claude backends OLLAMA_BASE_URL default http://localhost:11434 ATLAS_ROUTER_PORT default 8888 ATLAS_HAIKU_MODEL default claude-haiku-4-5-20251001 ATLAS_SONNET_MODEL default claude-sonnet-4-6 ATLAS_OPUS_MODEL default claude-opus-4-8 """ from __future__ import annotations import asyncio, json, logging, os, time, uuid from pathlib import Path import httpx from fastapi import FastAPI, HTTPException, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel from typing import Any import uvicorn # ── secrets (source /opt/atlas/router-secrets.env if present) ────────────────── _sec = Path("/opt/atlas/router-secrets.env") if not _sec.exists(): _sec = Path.home() / ".config/atlas/router-secrets.env" if _sec.exists(): for ln in _sec.read_text().splitlines(): if "=" in ln and not ln.startswith("#"): k, v = ln.split("=", 1) os.environ[k.strip()] = v.strip() # last value wins (normal dotenv behaviour) ANTHROPIC_KEY = os.environ.get("ANTHROPIC_API_KEY", "") CLAUDE_RELAY = os.environ.get("ATLAS_CLAUDE_RELAY", "") # e.g. http://100.X.X.X:9191 OLLAMA_URL = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") PORT = int(os.environ.get("ATLAS_ROUTER_PORT", "8888")) LOG_LEVEL = os.environ.get("ATLAS_LOG_LEVEL", "info") M_HAIKU = os.environ.get("ATLAS_HAIKU_MODEL", "claude-haiku-4-5-20251001") M_SONNET = os.environ.get("ATLAS_SONNET_MODEL", "claude-sonnet-4-6") M_OPUS = os.environ.get("ATLAS_OPUS_MODEL", "claude-opus-4-8") CLAUDE_MODELS = { "atlas-haiku": M_HAIKU, "atlas-sonnet": M_SONNET, "atlas-opus": M_OPUS, } OLLAMA_MODELS = { "atlas-free": os.environ.get("ATLAS_FREE_MODEL", "qwen2.5:7b"), "atlas-code": os.environ.get("ATLAS_CODE_MODEL", "qwen2.5-coder:7b"), "atlas-fast": os.environ.get("ATLAS_FAST_MODEL", "qwen2.5:1.5b"), "atlas-qwen": os.environ.get("ATLAS_QWEN_MODEL", "qwen2.5:7b"), "atlas-phi3": os.environ.get("ATLAS_PHI3_MODEL", "qwen2.5:7b"), } ALL_ATLAS = list(CLAUDE_MODELS) + list(OLLAMA_MODELS) + ["atlas-auto"] logging.basicConfig(level=LOG_LEVEL.upper(), format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("atlas-router") app = FastAPI(title="Atlas Router", version="0.1") # ── Pydantic models ────────────────────────────────────────────────────────────── class Msg(BaseModel): role: str content: Any # str or list[dict] for multimodal class ChatRequest(BaseModel): model: str = "atlas-auto" messages: list[Msg] max_tokens: int = 4096 temperature: float = 0.7 stream: bool = True system: str | None = None # ── Auto-router heuristic ──────────────────────────────────────────────────────── def _auto_route(messages: list[Msg]) -> str: text = " ".join( (m.content if isinstance(m.content, str) else json.dumps(m.content)) for m in messages ).lower() wc = len(text.split()) CODE = {"```","def ","function","class ","import ","bug","implement","refactor", "sql","python","javascript","typescript","bash","powershell","html","css"} PLAN = {"plan","design","architect","strategy","roadmap","analyse","analyze", "overview","approach","decide","compare","when will","how should"} has_code = any(s in text for s in CODE) needs_plan = (any(s in text for s in PLAN) or wc > 200) if wc < 12 and not has_code: return "atlas-fast" # ultra-short → free 1B if has_code and not needs_plan: return "atlas-code" # code → free qwen-coder if needs_plan and wc > 120: return "atlas-sonnet" # heavy planning → sonnet if needs_plan: return "atlas-haiku" # light analysis → haiku (cheap) if not ANTHROPIC_KEY: return "atlas-free" # no key → always free return "atlas-haiku" # default: cheap + fast # ── SSE helpers ────────────────────────────────────────────────────────────────── def _chunk(cid: str, model: str, content: str, finish: str | None = None) -> str: return "data: " + json.dumps({ "id": cid, "object": "chat.completion.chunk", "created": int(time.time()), "model": model, "choices": [{"index": 0, "delta": {"content": content} if content else {}, "finish_reason": finish}] }) + "\n\n" def _done(): return "data: [DONE]\n\n" # ── Anthropic streaming ────────────────────────────────────────────────────────── async def _stream_claude(req: ChatRequest, claude_model: str): if not ANTHROPIC_KEY: yield _chunk("err", req.model, "[atlas-router] ANTHROPIC_API_KEY not set — switch to atlas-free") yield _done(); return cid = f"chatcmpl-{uuid.uuid4().hex[:12]}" # build message list (strip system from messages if present) sys_prompt = req.system or next( (m.content for m in req.messages if m.role == "system" and isinstance(m.content,str)), None) msgs = [{"role": m.role, "content": m.content} for m in req.messages if m.role != "system"] headers = { "x-api-key": ANTHROPIC_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json", } body: dict[str, Any] = { "model": claude_model, "max_tokens": req.max_tokens, "temperature": req.temperature, "messages": msgs, "stream": True, } if sys_prompt: body["system"] = sys_prompt async with httpx.AsyncClient(timeout=120) as client: async with client.stream("POST", "https://api.anthropic.com/v1/messages", headers=headers, json=body) as resp: if resp.status_code != 200: err = await resp.aread() log.error("anthropic %s: %s", resp.status_code, err[:200]) yield _chunk(cid, req.model, f"[atlas-router] Anthropic error {resp.status_code}") yield _done(); return async for line in resp.aiter_lines(): if not line or not line.startswith("data:"): continue raw = line[5:].strip() if raw == "[DONE]": break try: ev = json.loads(raw) except Exception: continue ev_type = ev.get("type","") if ev_type == "content_block_delta": delta = ev.get("delta", {}) if delta.get("type") == "text_delta": yield _chunk(cid, req.model, delta.get("text", "")) elif ev_type == "message_stop": break yield _chunk(cid, req.model, "", "stop") yield _done() log.info("claude/%s done cid=%s", claude_model, cid) # ── Model availability cache ───────────────────────────────────────────────────── _avail_cache: dict[str, bool] = {} _avail_ts: float = 0 def _ollama_model_available(model_name: str) -> bool: """Check if a model exists on the Ollama instance (cached for 60s).""" global _avail_ts if time.time() - _avail_ts < 60 and model_name in _avail_cache: return _avail_cache[model_name] try: import urllib.request req = urllib.request.Request(f"{OLLAMA_URL}/api/tags") with urllib.request.urlopen(req, timeout=5) as r: tags = json.loads(r.read()) available = {m["name"] for m in tags.get("models", [])} for m in available: _avail_cache[m] = True _avail_ts = time.time() return model_name in available except Exception: return True # assume available if we can't check # ── Circuit breaker ─────────────────────────────────────────────────────────────── _failures: dict[str, list[float]] = {} def _breaker_trip(backend: str) -> bool: """Return True if the backend has 3+ failures in 5 minutes (should skip).""" now = time.time() _failures.setdefault(backend, []) _failures[backend] = [t for t in _failures[backend] if now - t < 300] return len(_failures[backend]) >= 3 def _breaker_record(backend: str): """Record a failure for the given backend.""" _failures.setdefault(backend, []).append(time.time()) def _breaker_reset(backend: str): """Reset failures on success.""" _failures.pop(backend, None) # ── Ollama streaming ───────────────────────────────────────────────────────────── async def _stream_ollama(req: ChatRequest, ollama_model: str): cid = f"chatcmpl-{uuid.uuid4().hex[:12]}" msgs = [{"role": m.role, "content": m.content if isinstance(m.content,str) else json.dumps(m.content)} for m in req.messages] body = {"model": ollama_model, "messages": msgs, "stream": True, "options": {"temperature": req.temperature, "num_predict": req.max_tokens}} breaker_key = f"ollama-{ollama_model}" if _breaker_trip(breaker_key): yield _chunk(cid, req.model, f"[atlas-router] Circuit open for {ollama_model} — too many failures") yield _done(); return try: async with httpx.AsyncClient(timeout=120) as client: async with client.stream("POST", f"{OLLAMA_URL}/api/chat", json=body) as resp: if resp.status_code != 200: err = await resp.aread() log.error("ollama %s: %s", resp.status_code, err[:200]) _breaker_record(breaker_key) yield _chunk(cid, req.model, f"[atlas-router] Ollama error {resp.status_code}") yield _done(); return async for line in resp.aiter_lines(): if not line: continue try: ev = json.loads(line) except Exception: continue content = (ev.get("message") or {}).get("content", "") if content: yield _chunk(cid, req.model, content) if ev.get("done"): break except Exception as e: log.error("ollama %s: %s", ollama_model, e) _breaker_record(breaker_key) yield _chunk(cid, req.model, f"[atlas-router] Ollama error {e}") yield _done(); return _breaker_reset(breaker_key) yield _chunk(cid, req.model, "", "stop") yield _done() log.info("ollama/%s done cid=%s", ollama_model, cid) # ── Main dispatch ──────────────────────────────────────────────────────────────── async def _stream_relay(req: ChatRequest, relay_url: str): """Forward Claude-tier request to the local Claude CLI relay (Max subscription).""" cid = f"chatcmpl-{uuid.uuid4().hex[:12]}" body = {"model": req.model, "messages": [{"role": m.role, "content": m.content} for m in req.messages], "max_tokens": req.max_tokens, "stream": False} try: async with httpx.AsyncClient(timeout=130) as client: resp = await client.post(f"{relay_url}/v1/chat/completions", json=body) data = resp.json() content = data["choices"][0]["message"]["content"] except Exception as e: content = f"[atlas-relay unreachable: {e}] — is Tailscale connected and relay running?" yield _chunk(cid, req.model, content) yield _chunk(cid, req.model, "", "stop") yield _done() def _dispatch(req: ChatRequest): model = req.model if model == "atlas-auto": model = _auto_route(req.messages) log.info("auto-route → %s (wc=%d)", model, sum(len((m.content or "").split()) for m in req.messages if isinstance(m.content, str))) if model in CLAUDE_MODELS: if CLAUDE_RELAY: log.info("routing %s → relay %s", model, CLAUDE_RELAY) return _stream_relay(req, CLAUDE_RELAY) elif ANTHROPIC_KEY: return _stream_claude(req, CLAUDE_MODELS[model]) else: # Fallback: downgrade to atlas-free with a note log.warning("%s: no relay or API key — downgrading to atlas-free", model) req_copy = req.model_copy(update={"model": "atlas-free"}) return _stream_ollama(req_copy, OLLAMA_MODELS["atlas-free"]) if model in OLLAMA_MODELS: ollama_model = OLLAMA_MODELS[model] if not _ollama_model_available(ollama_model) and not _breaker_trip(f"ollama-{ollama_model}"): log.warning("%s: model %s not available on ollama — pulling...", model, ollama_model) try: import urllib.request as _ur _ur.urlopen(_ur.Request(f"{OLLAMA_URL}/api/pull", data=json.dumps({"name": ollama_model}).encode(), headers={"Content-Type": "application/json"}, method="POST"), timeout=120) _avail_ts = 0 # force cache refresh except Exception as e: log.error("pull failed: %s", e) if not _ollama_model_available(ollama_model): fallback = next((m for m in ["qwen2.5:1.5b","llama3.2:3b"] if _ollama_model_available(m)), ollama_model) log.warning("falling back to %s", fallback) return _stream_ollama(req, fallback) return _stream_ollama(req, ollama_model) # fallback: treat model string as raw ollama model name log.warning("unknown atlas model %r — forwarding as ollama", model) return _stream_ollama(req, model) # ── Endpoints ──────────────────────────────────────────────────────────────────── @app.get("/v1/models") async def list_models(): models = [] ts = int(time.time()) labels = { "atlas-auto": "Atlas Auto (smart router)", "atlas-haiku": "Atlas Fast (Haiku)", "atlas-sonnet": "Atlas Pro (Sonnet)", "atlas-opus": "Atlas Max (Opus)", "atlas-free": "Atlas Free (hermes3 local)", "atlas-code": "Atlas Code (Qwen Coder local)", "atlas-fast": "Atlas Ultra-Fast (LLaMA 1B local)", "atlas-qwen": "Atlas Qwen (qwen2.5 local)", "atlas-phi3": "Atlas Reason (qwen2.5:7b local)", } for mid in ALL_ATLAS: models.append({"id": mid, "object": "model", "created": ts, "owned_by": "atlas-corporation", "description": labels.get(mid, mid)}) return {"object": "list", "data": models} @app.post("/v1/chat/completions") async def chat(req: ChatRequest): log.info("→ model=%s msgs=%d", req.model, len(req.messages)) gen = _dispatch(req) if req.stream: return StreamingResponse(gen, media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) # non-streaming: collect full response content = "" async for chunk in gen: if chunk.startswith("data:") and not chunk.strip().endswith("[DONE]"): try: ev = json.loads(chunk[5:].strip()) content += (ev["choices"][0]["delta"] or {}).get("content", "") except Exception: pass return {"id": f"chatcmpl-{uuid.uuid4().hex[:12]}", "object": "chat.completion", "created": int(time.time()), "model": req.model, "choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}], "usage": {"prompt_tokens": -1, "completion_tokens": -1, "total_tokens": -1}} @app.get("/health") async def health(): return {"status": "ok", "models": len(ALL_ATLAS), "anthropic": bool(ANTHROPIC_KEY), "ollama": OLLAMA_URL} @app.get("/healthz") async def healthz(): """Full system health: disk, RAM, swap, Docker, uptime.""" import shutil, subprocess disk = shutil.disk_usage("/") mem = {} try: with open("/proc/meminfo") as f: for line in f: if ":" in line: k, v = line.split(":", 1) mem[k.strip()] = int(v.replace("kB","").strip()) // 1024 except: pass docker_ok, docker_total, docker_running = False, 0, 0 try: r = subprocess.run(["docker","ps","-a","--format","{{.Status}}"], capture_output=True, text=True, timeout=5) containers = r.stdout.strip().split("\n") if r.stdout.strip() else [] docker_total = len(containers) docker_running = sum(1 for c in containers if c.startswith("Up")) docker_ok = True except: pass ollama_models = 0 try: import urllib.request as _ur r = _ur.urlopen(f"{OLLAMA_URL}/api/tags", timeout=3) ollama_models = len(json.loads(r.read()).get("models", [])) except: pass uptime_s = time.time() - _start_time if "_start_time" in dir() else 0 return { "status": "ok" if docker_ok and ollama_models > 0 else "degraded", "disk_pct": disk.used / disk.total * 100 if disk.total else 0, "disk_free_gb": disk.free / 1e9, "disk_total_gb": disk.total / 1e9, "ram_used_mb": mem.get("MemTotal", 0) - mem.get("MemAvailable", 0), "ram_total_mb": mem.get("MemTotal", 0), "swap_used_mb": mem.get("SwapTotal", 0) - mem.get("SwapFree", 0), "swap_total_mb": mem.get("SwapTotal", 0), "docker_containers_running": docker_running, "docker_containers_total": docker_total, "ollama_models": ollama_models, "uptime_s": uptime_s, "router_models": len(ALL_ATLAS), } _start_time = time.time() # ── Entry point ─────────────────────────────────────────────────────────────────── if __name__ == "__main__": import argparse ap = argparse.ArgumentParser() ap.add_argument("--port", type=int, default=PORT) ap.add_argument("--host", default="0.0.0.0") ap.add_argument("--reload", action="store_true") a = ap.parse_args() log.info("Atlas Router v0.1 port=%d anthropic=%s ollama=%s", a.port, bool(ANTHROPIC_KEY), OLLAMA_URL) uvicorn.run("atlas_router_api:app" if a.reload else app, host=a.host, port=a.port, reload=a.reload, log_level=LOG_LEVEL, access_log=True)