- assets/header.svg: animated SVG header (twinkling stars, orbiting service dots, Atlas A logo with glow, float/pulse animations, corner accents) - README.md: animated banner, full 39-app service catalog, Mermaid architecture, AI stack table, tech stack, quick-deploy, repo structure, security model - ARCHITECTURE.md: one-box philosophy, 5-tier service layers, data flow diagram, Caddy SSO pattern, AI mesh topology, Docker Compose topology - SERVICES.md: all 55 containers + 20 systemd services documented - CHANGELOG.md: full build history from v0.5 to v2.0 - assets/logo.svg: Atlas A logo with gradient - deploy/install.sh: one-command installer (Docker + Caddy + Tailscale + stacks) - deploy/compose/atlas-core.yml: Authentik + Nextcloud + Stalwart + Forgejo + Vaultwarden - deploy/caddy/Caddyfile.template: Authentik SSO pattern, sanitized - deploy/README.md: full deployment guide - server/: sanitized atlas_brain.py, cockpit_shim.py, intelligence.py, meridian.py, atlas_router_api.py, cli-atlas.py (all secrets stripped, env var patterns) - server/systemd/: 6 atlas-* service unit files - server/README.md: deployment and configuration guide Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
414 lines
20 KiB
Python
414 lines
20 KiB
Python
#!/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)
|