feat(2.0): Phase 1 — Engine + Routing (Herzstueck)

Backend-Services: fit/caps/sources (portiert), discover (live HF + Fit +
Caps + ranked recommendation), llama-swap write/register + groups (Ko-
Residenz swap:false), LiteLLM-Gateway-Config + gateway-Service (model:auto +
Fallbacks). Router: discover/fit/register/groups/routing; health zeigt
gateway_reachable. Frontend: Modelle&Routing mit Caps-Chips, Fit-Badges,
Discover-Tab (live), Routing-View.

Lokal verifiziert: Backend-Smoke (alle Endpunkte) + Frontend-Build +
Browser (Shell, Discover, Caps/Fit). Box-Verifikation offen.

Docs: README + docs/STATUS.md (Phasen-Tracker + Resume-Guide).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-25 08:01:12 +02:00
parent 1b421e30f9
commit cff3f0b1a8
25 changed files with 1253 additions and 235 deletions
+9 -3
View File
@@ -7,10 +7,16 @@ siehe Architektur-Plan (`docs/` bzw. der genehmigte Plan).
Mission Control 2.0 (FastAPI + React/shadcn) · Hermes Agent + hermes-webui ·
Shared Memory (SQLite via MCP). Jede Schicht hinter stabilem Vertrag austauschbar.
## Status: Phase 0 (Gerüst)
## Status: Phase 1 (Engine + Routing) ✅
- `backend/` — FastAPI: `GET /api/health`, `GET /api/models` (read-only aus llama-swap config).
- `frontend/` — Vite + React + Tailwind v4 + shadcn-Style, App-Shell mit Cmd+K, Dark-default.
Fortschritt & Resume-Guide: siehe [`docs/STATUS.md`](docs/STATUS.md).
- **Phase 0** — FastAPI-Skeleton + React/shadcn-Shell (Cmd+K, Dark, PWA).
- **Phase 1** — Compute-Module (fit/caps/sources, portiert), **Discover** (live HF + Fit + Caps),
**Engine-Write** (register + `groups`/Ko-Residenz), **LiteLLM-Gateway** (Config + Service,
`model: auto` + Fallbacks), Frontend **Modelle & Routing** (Caps-Chips, Fit, Discover, Routing-View).
API: `health · models · discover · fit · models/register · groups · routing` (Details in `docs/STATUS.md`).
## Entwickeln
+2 -1
View File
@@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles
from starlette.requests import Request
from config import FRONTEND_DIST, VERSION
from routers import health, models
from routers import health, models, routing
app = FastAPI(title="Mission Control 2.0", version=VERSION)
@@ -36,6 +36,7 @@ async def no_cache(request: Request, call_next):
app.include_router(health.router)
app.include_router(models.router)
app.include_router(routing.router)
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
+18 -1
View File
@@ -15,9 +15,26 @@ from ruamel.yaml import YAML
LLAMA_SWAP_URL = os.environ.get("MC_LLAMA_SWAP_URL", "http://127.0.0.1:8080").rstrip("/")
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/etc/llama-swap/config.yaml"))
MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
# Cache der Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
# Persistent neben den Modellen (übersteht Deploys). TTL = Frische-Fenster.
DISCOVER_CACHE_PATH = Path(os.environ.get("MC_DISCOVER_CACHE", str(MODELS_DIR / "mc2-discover.json")))
DISCOVER_TTL = int(os.environ.get("MC_DISCOVER_TTL", "43200")) # 12 h
# Befehl-Vorlage für llama-swap: {model}=GGUF-Pfad, {ctx}=Kontext, ${PORT} bleibt stehen.
_DEFAULT_CMD_TEMPLATE = (
"llama-server -m {model} --host 127.0.0.1 --port ${PORT} "
"-c {ctx} -ngl 999 -fa 1 --no-mmap"
)
CMD_TEMPLATE = os.environ.get("MC_CMD_TEMPLATE", _DEFAULT_CMD_TEMPLATE)
if "{model}" not in CMD_TEMPLATE:
CMD_TEMPLATE = _DEFAULT_CMD_TEMPLATE
DEFAULT_TTL = int(os.environ.get("MC_DEFAULT_TTL", "300"))
# Env für HuggingFace-Downloads: XET deaktivieren (Hänger bei ~6 MB, siehe v1-Gotcha).
HF_DOWNLOAD_ENV = {"HF_HUB_DISABLE_XET": "1"}
# --- Routing-Gateway (LiteLLM, model: auto) ----------------------------------
GATEWAY_URL = os.environ.get("MC_GATEWAY_URL", "http://127.0.0.1:4000").rstrip("/")
GATEWAY_CONFIG_PATH = Path(os.environ.get(
"MC_GATEWAY_CONFIG", str(Path(__file__).resolve().parent.parent / "gateway" / "config.yaml")))
# --- Server ------------------------------------------------------------------
HOST = os.environ.get("MC_HOST", "0.0.0.0")
@@ -27,7 +44,7 @@ PORT = int(os.environ.get("MC_PORT", "9000"))
FRONTEND_DIST = Path(os.environ.get("MC_FRONTEND_DIST", str(Path(__file__).resolve().parent.parent / "frontend" / "dist")))
# Version (Phase 0 — Greenfield-Skeleton).
VERSION = "2.0.0-phase0"
VERSION = "2.0.0-phase1"
# Gemeinsame YAML-Instanz (preserve_quotes hält Kommentare/Quotes in config.yaml).
yaml = YAML()
+1
View File
@@ -2,3 +2,4 @@ fastapi>=0.115
uvicorn[standard]>=0.30
httpx>=0.27
ruamel.yaml>=0.18
psutil>=5.9
+2 -1
View File
@@ -3,7 +3,7 @@
from fastapi import APIRouter
from config import VERSION
from services import llamaswap
from services import gateway, llamaswap
router = APIRouter(prefix="/api")
@@ -14,4 +14,5 @@ def health() -> dict:
"status": "ok",
"version": VERSION,
"engine_reachable": llamaswap.engine_reachable(),
"gateway_reachable": gateway.gateway_reachable(),
}
+71 -3
View File
@@ -1,13 +1,81 @@
"""Modelle-Endpoint (Phase 0: read-only Liste aus der llama-swap config.yaml)."""
"""Modelle-Endpoints: Liste (mit Caps), Discover, Fit, Register, Groups."""
from fastapi import APIRouter
import psutil
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import llamaswap
from services import discover, llamaswap
from services.fit import evaluate_fit, max_ctx_for
router = APIRouter(prefix="/api")
def _ram_gb() -> float:
return psutil.virtual_memory().total / (1024 ** 3)
@router.get("/models")
def models() -> dict:
items = llamaswap.list_models()
return {"models": items, "count": len(items)}
@router.get("/discover")
def discover_models(force: bool = False) -> dict:
ram = _ram_gb()
data = discover.refresh_discover(ram) if force else discover.safe_discover(ram)
if not data:
raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut.")
return {**data, "sys_ram_gb": round(ram, 1)}
@router.get("/fit")
def fit(params_b: float, quant: str = "Q4_K_M", ctx: int = 8192, name: str = "") -> dict:
ram = _ram_gb()
return {
"fit": evaluate_fit(params_b, quant, ctx, ram, name=name),
"optimal_ctx": max_ctx_for(params_b, quant, ram),
"sys_ram_gb": round(ram, 1),
}
class RegisterReq(BaseModel):
model_path: str
role: str | None = None
ctx: int = 8192
ttl: int | None = None
mmproj_path: str | None = None
jinja: bool = False
@router.post("/models/register")
def register(req: RegisterReq) -> dict:
try:
model_id = llamaswap.register_model(
req.model_path, role=req.role, ctx=req.ctx, ttl=req.ttl,
mmproj_path=req.mmproj_path, jinja=req.jinja,
)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True, "model_id": model_id}
@router.get("/groups")
def groups() -> dict:
return {"groups": llamaswap.list_groups()}
class GroupReq(BaseModel):
group: str
members: list[str]
swap: bool = False
persist: bool = False
@router.put("/groups")
def set_group(req: GroupReq) -> dict:
try:
llamaswap.set_group(req.group, req.members, swap=req.swap, persist=req.persist)
except PermissionError as exc:
raise HTTPException(500, str(exc))
return {"ok": True}
+28
View File
@@ -0,0 +1,28 @@
"""Routing-Endpoints: Gateway-Übersicht (welcher Alias = fast/heavy/…) + Mapping setzen."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services import gateway
router = APIRouter(prefix="/api")
@router.get("/routing")
def routing() -> dict:
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
class RouteReq(BaseModel):
name: str # Gateway-Modellname, z.B. "fast" / "heavy" / "vision" / "coder"
target_alias: str # llama-swap-Alias, der bedient wird
api_base: str = "http://127.0.0.1:8080/v1"
@router.put("/routing/route")
def set_route(req: RouteReq) -> dict:
try:
gateway.set_route(req.name, req.target_alias, api_base=req.api_base)
except Exception as exc: # noqa: BLE001
raise HTTPException(500, str(exc))
return {"ok": True}
+131
View File
@@ -0,0 +1,131 @@
"""
Modell-Capabilities — EINE Quelle der Wahrheit für Modell-Eigenschaften
(MoE / Tools / Vision / Coder / Reasoning / Embedding / Kontext).
Portiert aus Mission Control v1 (model_caps.py).
Quellen, geschichtet: GGUF-Header (offline, authoritativ) → cmd-Flags
(--jinja/--mmproj) → HF-Block (tags + chat_template) → Familien-Fallback.
Tool-Fähigkeit dreistufig: yes (bestätigt) | likely (Familie) | no.
"""
import re
import struct
from services.fit import extract_active_params_b, extract_params_b
_GGUF_FIXED = {0: 1, 1: 1, 2: 2, 3: 2, 4: 4, 5: 4, 6: 4, 7: 1, 10: 8, 11: 8, 12: 8}
def _read_gguf_meta(path: str) -> dict:
"""Liest nur den GGUF-Metadaten-Header (architecture/context_length/expert_count/
parameter_count). Bricht vor dem Tokenizer-Array ab → schnell, lädt NICHT das Modell."""
out: dict = {}
try:
with open(path, "rb") as f:
if f.read(4) != b"GGUF":
return {}
struct.unpack("<I", f.read(4))[0]
f.read(8)
kv = struct.unpack("<Q", f.read(8))[0]
def ru32() -> int: return struct.unpack("<I", f.read(4))[0]
def ru64() -> int: return struct.unpack("<Q", f.read(8))[0]
def rstr() -> str: return f.read(ru64()).decode("utf-8", "replace")
def rval(t: int):
if t == 8: return rstr()
if t == 0: return struct.unpack("<B", f.read(1))[0]
if t == 1: return struct.unpack("<b", f.read(1))[0]
if t == 2: return struct.unpack("<H", f.read(2))[0]
if t == 3: return struct.unpack("<h", f.read(2))[0]
if t == 4: return struct.unpack("<I", f.read(4))[0]
if t == 5: return struct.unpack("<i", f.read(4))[0]
if t == 6: return struct.unpack("<f", f.read(4))[0]
if t == 7: return f.read(1) != b"\x00"
if t == 10: return struct.unpack("<Q", f.read(8))[0]
if t == 11: return struct.unpack("<q", f.read(8))[0]
if t == 12: return struct.unpack("<d", f.read(8))[0]
if t == 9:
et = ru32(); cnt = ru64()
if et == 8:
for _ in range(cnt):
f.seek(ru64(), 1)
elif et == 9:
for _ in range(cnt):
rval(9)
else:
f.seek(cnt * _GGUF_FIXED.get(et, 0), 1)
return None
raise ValueError(f"unbekannter GGUF-Typ {t}")
want = {"architecture", "context_length", "expert_count", "parameter_count"}
for _ in range(kv):
key = rstr()
t = ru32()
if key == "tokenizer.ggml.tokens":
break
v = rval(t)
short = key.split(".")[-1]
if short in want and short not in out:
out[short] = v
except Exception:
return out
return out
_TOOL_FAMILIES = (
"qwen2.5", "qwen3", "qwen2", "hermes", "mistral", "mixtral", "devstral",
"command-r", "command_r", "llama-3.1", "llama3.1", "llama-3.3", "llama-4", "llama4",
"functionary", "watt", "firefunction", "granite", "glm-4", "glm-5", "ministral",
)
_REASON_KW = (
"-r1", "deepseek-r1", "qwq", "magistral", "-think", "thinking", "-o1",
"gpt-oss", "reasoning", "exaone-deep", "phi-4-reasoning", "phi-4-mini-reasoning",
)
_CODE_KW = ("coder", "-code", "code-", "codestral", "starcoder", "deepseek-coder")
_VISION_KW = ("-vl", "vision", "llava", "pixtral", "multimodal", "-mm-", "qwen3vl", "qwen2-vl")
_EMBED_KW = ("bge", "e5-", "gte-", "nomic-embed", "embed")
_MOE_ARCH = ("moe", "mixtral", "deepseek2", "deepseek3", "llama4", "qwen3moe", "grok")
def capabilities(name: str = "", cmd: str = "", gguf_path: str = "", hf: dict | None = None) -> dict:
"""Capability-Tag-Set für ein Modell. Alle Quellen optional — nutzt, was da ist."""
low = (name or "").lower()
cmdl = (cmd or "").lower()
hf = hf or {}
meta = _read_gguf_meta(gguf_path) if gguf_path else {}
arch = str(meta.get("architecture") or hf.get("architecture") or "").lower()
tags = [str(t).lower() for t in (hf.get("tags") or [])]
chat_tpl = str(hf.get("chat_template") or "")
expert_count = int(meta.get("expert_count") or 0)
moe = (
expert_count > 1
or any(a in arch for a in _MOE_ARCH)
or bool(re.search(r"\d+x\d+\.?\d*b", low))
or bool(re.search(r"a\d+\.?\d*b", low))
)
active_b = extract_active_params_b(name)
pcount = int(meta.get("parameter_count") or 0)
params_b = round(pcount / 1e9, 1) if pcount else extract_params_b(name)
ctx = meta.get("context_length")
if not ctx:
m = re.search(r"-(?:c|-ctx-size)\s+(\d+)", cmdl)
ctx = int(m.group(1)) if m else None
tool_confirmed = "--jinja" in cmdl or "tool_call" in chat_tpl or "<tools>" in chat_tpl
tool_family = any(fam in low for fam in _TOOL_FAMILIES) or "function-calling" in tags
tools = "yes" if tool_confirmed else ("likely" if tool_family else "no")
vision = "--mmproj" in cmdl or "vl" in arch or "clip" in arch or any(k in low for k in _VISION_KW)
coder = any(k in low for k in _CODE_KW)
reasoning = any(k in low for k in _REASON_KW) or "reasoning" in tags
embedding = "bert" in arch or any(k in low for k in _EMBED_KW)
return {
"moe": moe, "active_b": active_b, "tools": tools, "vision": vision,
"coder": coder, "reasoning": reasoning, "embedding": embedding,
"ctx": ctx, "params_b": params_b or None, "arch": arch or None,
}
+133
View File
@@ -0,0 +1,133 @@
"""
Automatische Modell-Entdeckung ("aktuell beste Modelle"): fragt vertrauenswürdige
HF-Orgs live ab, kategorisiert per Stichwort, rankt nach Hardware-Fit + Beliebtheit
und cached. Portiert aus Mission Control v1 (cookbook.py-Discover).
Wichtig (Greenfield-Fix gegen v1): EIN gemeinsamer Ranking-Helfer `rank_runnable`
ist die Quelle der Wahrheit — sowohl die „beste Empfehlung" je Kategorie als auch
spätere Auto-Setups nutzen ihn, damit sie nie auseinanderlaufen.
"""
import json
import os
import time
import httpx
from config import DISCOVER_CACHE_PATH, DISCOVER_TTL
from services.caps import capabilities
from services.fit import evaluate_fit, extract_params_b, max_ctx_for
from services.sources import CATEGORIES, SKIP_TOKENS, TRUSTED_AUTHORS
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
def _categorize(repo_id: str) -> str:
low = repo_id.lower()
for cat in CATEGORIES:
if any(k in low for k in cat["kw"]):
return cat["role"]
return "scout"
def _fetch_author_models(author: str) -> list:
url = (f"https://huggingface.co/api/models?author={author}"
f"&filter=gguf&sort=downloads&direction=-1&limit=40")
try:
with httpx.Client(timeout=12.0) as c:
data = c.get(url).json()
return data if isinstance(data, list) else []
except Exception:
return []
def rank_runnable(models: list[dict]) -> list[dict]:
"""EINE Quelle der Wahrheit fürs Ranking lauffähiger Modelle:
bestes Fit-Level zuerst (perfect < marginal), bei Gleichstand meistgeladen.
Zu große Modelle (too_tight) fliegen raus."""
return sorted(
[m for m in models if m["fit"]["level"] != "too_tight"],
key=lambda m: (_FIT_ORDER[m["fit"]["level"]], -int(m.get("downloads") or 0)),
)
def refresh_discover(ram_gb: float) -> dict:
"""Quellen live abfragen, kategorisieren, ranken, cachen. Wirft nur, wenn KEINE
Quelle erreichbar war."""
raw, seen, ok = [], set(), 0
for author in TRUSTED_AUTHORS:
models = _fetch_author_models(author)
if models:
ok += 1
for m in models:
rid = m.get("id")
if not rid or rid in seen:
continue
seen.add(rid)
raw.append(m)
if ok == 0:
raise RuntimeError("Keine Quelle erreichbar.")
by_cat: dict[str, list] = {c["role"]: [] for c in CATEGORIES}
for m in raw:
rid = m["id"]
low = rid.lower()
if any(tok in low for tok in SKIP_TOKENS):
continue
role = _categorize(rid)
params_b = extract_params_b(rid)
quant = "Q4_K_M" # Referenz-Quant für die Fit-Einschätzung
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
tags = [str(t) for t in (m.get("tags") or [])]
by_cat[role].append({
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
"role": role, "params_b": params_b, "quant": quant, "tags": tags,
"downloads": int(m.get("downloads") or 0), "likes": int(m.get("likes") or 0),
"lastModified": m.get("lastModified"),
"fit": fit, "optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
"caps": capabilities(name=rid, hf={"tags": tags}),
})
cats = []
for c in CATEGORIES:
items = by_cat[c["role"]]
ranked = rank_runnable(items)
# Top 4 je Kategorie (für die Anzeige) — gerankt, dann nach Downloads aufgefüllt.
items.sort(key=lambda x: (x["fit"]["level"] != "too_tight", x["downloads"]), reverse=True)
top = items[:4]
if top:
cats.append({
"role": c["role"], "title": c["title"], "icon": c["icon"],
"models": top,
"recommended": ranked[0]["repo"] if ranked else None,
})
data = {"updated": time.time(), "categories": cats}
try:
DISCOVER_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = DISCOVER_CACHE_PATH.with_name(DISCOVER_CACHE_PATH.name + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, DISCOVER_CACHE_PATH)
except Exception:
pass # Cache ist nur Beschleunigung
return data
def load_discover() -> dict | None:
try:
if DISCOVER_CACHE_PATH.exists():
return json.loads(DISCOVER_CACHE_PATH.read_text(encoding="utf-8"))
except Exception:
pass
return None
def safe_discover(ram_gb: float) -> dict | None:
"""Aus Cache (wenn frisch) oder live; wirft nie — None wenn nichts da."""
cached = load_discover()
if cached and (time.time() - cached.get("updated", 0) < DISCOVER_TTL):
return cached
try:
return refresh_discover(ram_gb)
except Exception:
return cached
+92
View File
@@ -0,0 +1,92 @@
"""
Hardware-Fit-Mathe (VRAM/RAM, tps-Schätzung) für APUs mit Unified Memory
(Bosgame M5 / Strix Halo). Portiert aus Mission Control v1 (hw_math.py).
"""
import re
# Bytes pro Parameter je GGUF-Quant (Annahme).
QUANT_BYTES_PER_PARAM = {
"Q2_K": 0.35, "Q3_K_S": 0.38, "Q3_K_M": 0.42, "Q3_K_L": 0.45,
"Q4_0": 0.50, "Q4_1": 0.55, "Q4_K_S": 0.50, "Q4_K_M": 0.55,
"Q5_0": 0.62, "Q5_1": 0.68, "Q5_K_S": 0.62, "Q5_K_M": 0.65,
"Q6_K": 0.75, "Q8_0": 1.00, "F16": 2.00, "BF16": 2.00,
}
def estimate_memory_gb(params_b: float, quant: str, ctx: int) -> float:
"""Geschätzter Speicherbedarf in GB (Gewichte + Kontext-KV)."""
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
context_vram = (ctx / 8192) * (max(params_b, 7) / 7) * 0.8
return weights + context_vram
def extract_active_params_b(name: str) -> float | None:
"""Aktive Parameter bei MoE ('30B-A3B' → 3.0). None bei Dense."""
m = re.search(r"(?<![a-zA-Z])a(\d+(?:\.\d+)?)b\b", name.lower())
return float(m.group(1)) if m else None
def estimate_speed(req_gb: float, sys_ram_gb: float, moe_active_ratio: float = 1.0) -> float:
"""Geschätzte t/s anhand der ~273 GB/s Bandbreite der APU.
moe_active_ratio = aktive/gesamt Params; < 1 bei MoE."""
bw = 273 if sys_ram_gb > 8 else 70
if req_gb <= 0:
return 0.0
raw_tps = (bw / req_gb) * 0.55
if moe_active_ratio < 0.8:
raw_tps *= (1.0 / moe_active_ratio) ** 0.5
return raw_tps
def evaluate_fit(params_b: float, quant: str, ctx: int, sys_ram_gb: float, name: str = "") -> dict:
"""Fit für ein Shared-Memory-System (APU). name → MoE-Erkennung (optional)."""
req_gb = estimate_memory_gb(params_b, quant, ctx)
active_b = extract_active_params_b(name) if name else None
moe_ratio = (active_b / params_b) if (active_b and params_b > 0) else 1.0
tps = estimate_speed(req_gb, sys_ram_gb, moe_ratio)
usable_ram = max(sys_ram_gb - 4.0, 0)
if req_gb > usable_ram:
fit_level, text = "too_tight", "Zu groß (OOM)"
elif req_gb > usable_ram * 0.8:
fit_level, text = "marginal", "Könnte knapp werden"
else:
fit_level, text = "perfect", "Passt perfekt"
return {"level": fit_level, "text": text, "req_gb": round(req_gb, 1), "tps": round(tps, 0)}
def extract_params_b(name: str) -> float:
"""Parametergröße (Mrd.) aus Repo-/Dateiname. 8x7B (MoE) → 56."""
moe = re.search(r"(\d+)x(\d+(?:\.\d+)?)[bB]", name)
if moe:
return float(moe.group(1)) * float(moe.group(2))
m = re.search(r"(\d+(?:\.\d+)?)[bB](?![a-zA-Z])", name)
return float(m.group(1)) if m else 7.0
_NICE_CTX = [2048, 4096, 8192, 16384, 32768, 49152, 65536, 98304, 131072]
def max_ctx_for(params_b: float, quant: str, sys_ram_gb: float) -> int:
"""Größter 'schöner' Kontext, der komfortabel passt (80 % des nutzbaren RAM)."""
bpp = QUANT_BYTES_PER_PARAM.get(quant.upper(), 0.65)
weights = params_b * bpp
usable = max(sys_ram_gb - 4.0, 0) * 0.8
ctx_budget = usable - weights
if ctx_budget <= 0:
return 2048
per_8k = (max(params_b, 7) / 7) * 0.8
raw_ctx = (ctx_budget / per_8k) * 8192
best = _NICE_CTX[0]
for c in _NICE_CTX:
if c <= raw_ctx:
best = c
return best
def recommend_ctx(params_b: float, quant: str, sys_ram_gb: float) -> dict:
ctx = max_ctx_for(params_b, quant, sys_ram_gb)
k = ctx // 1024
return {"ctx": ctx, "k": k,
"note": f"Bis ~{k}k Kontext passt komfortabel auf deine Hardware ({round(sys_ram_gb)} GB)."}
+69
View File
@@ -0,0 +1,69 @@
"""
Routing-Gateway-Service: liest/schreibt die LiteLLM-Config und prüft die
Erreichbarkeit. MC verwaltet damit die Modell-Zuordnung (welcher llama-swap-Alias
ist fast/heavy/vision/coder) und die Routing-Regeln.
"""
import httpx
from config import GATEWAY_CONFIG_PATH, GATEWAY_URL, yaml
def read_gateway_config() -> dict:
if not GATEWAY_CONFIG_PATH.exists():
return {"model_list": [], "litellm_settings": {}, "router_settings": {}}
with GATEWAY_CONFIG_PATH.open("r", encoding="utf-8") as f:
return yaml.load(f) or {}
def write_gateway_config(cfg: dict) -> None:
import os
GATEWAY_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = GATEWAY_CONFIG_PATH.with_name(GATEWAY_CONFIG_PATH.name + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
yaml.dump(cfg, f)
os.replace(tmp, GATEWAY_CONFIG_PATH)
def routing_summary() -> dict:
"""Kompakte Sicht für die UI: welcher Backend-Alias steckt hinter welchem
Gateway-Modellnamen + die Fallback-Ketten."""
cfg = read_gateway_config()
routes = []
for entry in cfg.get("model_list") or []:
params = entry.get("litellm_params") or {}
routes.append({
"name": entry.get("model_name"),
"target": str(params.get("model", "")),
"api_base": params.get("api_base"),
})
settings = cfg.get("litellm_settings") or {}
return {
"routes": routes,
"fallbacks": settings.get("fallbacks") or [],
"context_window_fallbacks": settings.get("context_window_fallbacks") or [],
}
def set_route(name: str, target_alias: str, api_base: str = "http://127.0.0.1:8080/v1") -> None:
"""Einen Gateway-Modellnamen (z.B. 'fast') auf einen llama-swap-Alias mappen."""
cfg = read_gateway_config()
ml = cfg.setdefault("model_list", [])
params = {"model": f"openai/{target_alias}", "api_base": api_base, "api_key": "sk-noauth"}
for entry in ml:
if entry.get("model_name") == name:
entry["litellm_params"] = params
break
else:
ml.append({"model_name": name, "litellm_params": params})
write_gateway_config(cfg)
def gateway_reachable() -> bool:
try:
with httpx.Client(timeout=3.0) as c:
# LiteLLM hat /health/liveliness; /v1/models tut's auch.
r = c.get(f"{GATEWAY_URL}/v1/models")
return r.status_code in (200, 401)
except Exception:
return False
+111 -21
View File
@@ -1,21 +1,20 @@
"""
Engine-Service: liest die llama-swap config.yaml (read-only in Phase 0) und
spricht die llama-swap-API (/v1/models, /running). Schreiblogik (Modelle
installieren, Gruppen/Routing verwalten) kommt in Phase 1.
Engine-Service: liest/schreibt die llama-swap config.yaml und spricht die
llama-swap-API. Portiert & erweitert aus Mission Control v1.
Logik portiert aus Mission Control v1 (llamaswap.py + routers/models.py),
auf das Nötigste reduziert.
NEU in 2.0: `groups` für Ko-Residenz (schnell + schwer gleichzeitig geladen,
`swap:false`) → Multi-Model-Delegation ohne Nachlade-Latenz.
"""
import os
import re
import httpx
from ruamel.yaml.scalarstring import LiteralScalarString
from config import CONFIG_PATH, LLAMA_SWAP_URL, yaml
from config import CMD_TEMPLATE, CONFIG_PATH, DEFAULT_TTL, LLAMA_SWAP_URL, yaml
# Kanonische Rollen (vereinheitlicht ggü. v1: kein manager/reviewer mehr).
# Eine Quelle der Wahrheit — Capability-Erkennung läuft separat über model_caps (Phase 1).
ROLE_IDS = {"vision", "coder", "reasoning", "agent", "scout"}
_CTX_RE = re.compile(r"-(?:c|-ctx-size)\s+(\d+)")
@@ -23,9 +22,8 @@ _PATH_RE = re.compile(r"-(?:m|-model)\s+([^\s]+)")
_QUANT_RE = re.compile(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|fp16|bf16)\.gguf", re.IGNORECASE)
# --- Lesen -------------------------------------------------------------------
def read_config() -> dict:
"""llama-swap config.yaml laden. Existiert sie nicht (z.B. lokaler Dev-PC
ohne Engine), wird ein leeres Modell-Set zurückgegeben statt zu werfen."""
if not CONFIG_PATH.exists():
return {"models": {}}
with CONFIG_PATH.open("r", encoding="utf-8") as f:
@@ -36,17 +34,11 @@ def read_config() -> dict:
def _parse_model(name: str, spec: dict) -> dict:
"""Ein config.yaml-Modell in ein flaches UI-Objekt übersetzen."""
spec = spec or {}
cmd = str(spec.get("cmd", "")).strip()
ctx = int(m.group(1)) if (m := _CTX_RE.search(cmd)) else None
ctx = None
if (m := _CTX_RE.search(cmd)):
ctx = int(m.group(1))
path = ""
filename = ""
quant = ""
path = filename = quant = ""
size_bytes = None
if (m := _PATH_RE.search(cmd)):
path = m.group(1).replace("'", "").replace('"', "")
@@ -60,9 +52,9 @@ def _parse_model(name: str, spec: dict) -> dict:
if isinstance(aliases, str):
aliases = [aliases]
aliases = [str(a) for a in aliases]
# Rolle = erster Alias; Legacy-Fallback: Key selbst ist eine Rolle.
role = aliases[0].lower() if aliases else (name.lower() if name.lower() in ROLE_IDS else None)
from services.caps import capabilities
return {
"name": name,
"role": role,
@@ -71,24 +63,122 @@ def _parse_model(name: str, spec: dict) -> dict:
"ctx": ctx,
"ttl": spec.get("ttl"),
"cmd": cmd,
"gguf_path": path,
"filename": filename,
"quant": quant,
"size_bytes": size_bytes,
# "incomplete" = Eintrag ohne hinterlegtes Modell (-m), z.B. Platzhalter.
"incomplete": not path,
"capabilities": capabilities(
name=filename or name, cmd=cmd,
gguf_path=(path if (path and os.path.exists(path)) else ""),
),
}
def list_models() -> list[dict]:
"""Alle in der config.yaml konfigurierten Modelle (read-only)."""
cfg = read_config()
return [_parse_model(name, spec) for name, spec in (cfg.get("models") or {}).items()]
def engine_reachable() -> bool:
"""Ist die llama-swap-API erreichbar?"""
try:
with httpx.Client(timeout=3.0) as c:
return c.get(f"{LLAMA_SWAP_URL}/v1/models").status_code == 200
except Exception:
return False
# --- Schreiben ---------------------------------------------------------------
def model_id_from_path(model_path: str) -> str:
"""Sprechende Modell-ID (= API-Name) aus dem GGUF-Pfad: Repo-Ordnername ohne
'-GGUF'. Fallback: Dateiname ohne Quant-Suffix."""
d = os.path.basename(os.path.dirname(model_path))
name = re.sub(r"[-_]?GGUF$", "", d, flags=re.I).strip("-_")
if not name:
fn = re.sub(r"\.gguf$", "", os.path.basename(model_path), flags=re.I)
fn = re.sub(r"-\d+-of-\d+$", "", fn)
name = re.sub(r"[-_](Q\d[\w]*|IQ\d[\w]*|F16|BF16|FP16|F32)$", "", fn, flags=re.I)
return name or "modell"
def set_role_alias(cfg: dict, model_id: str, role: str | None) -> None:
"""Rolle als eindeutigen llama-swap-`aliases`-Eintrag setzen (vorher bei allen
anderen Modellen entfernen). role=None/leer entfernt den Alias."""
models = cfg.get("models") or {}
role = (role or "").strip().lower()
if role:
for mid, spec in models.items():
if mid == model_id or not isinstance(spec, dict):
continue
al = [a for a in (spec.get("aliases") or []) if str(a).lower() != role]
if al:
spec["aliases"] = al
else:
spec.pop("aliases", None)
spec = models.get(model_id)
if isinstance(spec, dict):
if role and role != model_id.lower():
spec["aliases"] = [role]
else:
spec.pop("aliases", None)
def _augment_vision(cmd: str, model_path: str, mmproj_path: str | None) -> str:
"""Vision-Modelle brauchen --mmproj <projektor> und --jinja."""
if mmproj_path:
if "--mmproj" not in cmd:
cmd += f" --mmproj {mmproj_path}"
if "--jinja" not in cmd:
cmd += " --jinja"
return cmd
def write_config(cfg: dict) -> None:
"""Atomar schreiben (tmp + os.replace), damit llama-swap mit -watch-config nie
eine halbe Datei sieht. Fehlende Schreibrechte → klare Meldung."""
try:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
tmp = CONFIG_PATH.with_name(CONFIG_PATH.name + ".tmp")
with tmp.open("w", encoding="utf-8") as f:
yaml.dump(cfg, f)
os.replace(tmp, CONFIG_PATH)
except PermissionError as exc:
raise PermissionError(
f"Mission Control darf '{CONFIG_PATH}' nicht schreiben. "
f"Einmalig: sudo chown -R hitonabi:hitonabi {CONFIG_PATH.parent}"
) from exc
def register_model(model_path: str, role: str | None = None, ctx: int = 8192,
ttl: int | None = None, mmproj_path: str | None = None,
jinja: bool = False) -> str:
"""Ein GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias). Gibt die
Modell-ID zurück. jinja=True erzwingt --jinja (Tool-Calling, z.B. fürs Agent-Hirn)."""
cfg = read_config()
model_id = model_id_from_path(model_path)
cmd = CMD_TEMPLATE.replace("{model}", model_path).replace("{ctx}", str(ctx))
cmd = _augment_vision(cmd, model_path, mmproj_path)
if jinja and "--jinja" not in cmd:
cmd += " --jinja"
cfg.setdefault("models", {})[model_id] = {
"cmd": LiteralScalarString(cmd + "\n"),
"ttl": ttl if ttl is not None else DEFAULT_TTL,
}
set_role_alias(cfg, model_id, role)
write_config(cfg)
return model_id
# --- Groups (Ko-Residenz) ----------------------------------------------------
def set_group(group: str, members: list[str], swap: bool = False, persist: bool = False) -> None:
"""llama-swap-`groups`-Eintrag setzen. swap=False → alle Mitglieder dürfen
GLEICHZEITIG laufen (Ko-Residenz, keine Nachlade-Latenz). persist=True →
Mitglieder werden nie automatisch entladen."""
cfg = read_config()
groups = cfg.setdefault("groups", {})
groups[group] = {"swap": swap, "persist": persist, "members": list(members)}
write_config(cfg)
def list_groups() -> dict:
return read_config().get("groups") or {}
+27
View File
@@ -0,0 +1,27 @@
"""
Vertrauenswürdige Quellen + Kategorien für die automatische Modell-Entdeckung.
Portiert aus Mission Control v1 (sources.py). Rollen sind die EINE Quelle der
Wahrheit (vereinheitlicht): vision · coder · reasoning · agent · scout.
"""
# HF-Orgs, die zuverlässig aktuelle, hochwertige GGUF-Quants veröffentlichen.
TRUSTED_AUTHORS = ["unsloth", "bartowski", "ggml-org", "lmstudio-community"]
# Kategorien (Reihenfolge = Anzeige + Zuordnungs-Priorität). Ein Modell wird der
# ERSTEN Kategorie zugeordnet, deren Stichwort im Repo-Namen vorkommt; sonst „scout".
# Die `role` ist zugleich der Alias-Vorschlag und gehört zu ROLE_IDS.
CATEGORIES = [
{"role": "vision", "title": "Bilder verstehen", "icon": "eye",
"kw": ["-vl-", "-vl", "vision", "llava", "multimodal", "-mm-", "pixtral"]},
{"role": "coder", "title": "Coden & Programmieren", "icon": "code",
"kw": ["coder", "-code-", "code-", "codestral", "starcoder"]},
{"role": "reasoning", "title": "Nachdenken & Logik", "icon": "pulse",
"kw": ["-r1", "deepseek-r1", "reasoning", "qwq", "magistral", "-think", "thinking", "-o1"]},
{"role": "agent", "title": "Agenten & Tool-Use", "icon": "layers",
"kw": ["hermes", "-tool", "command-r", "watt", "-fc-", "function"]},
{"role": "scout", "title": "Allrounder & Chat", "icon": "compass",
"kw": []}, # Fallback: instruct/chat-Modelle
]
# Repo-Namensteile, die bei der Entdeckung übersprungen werden (Roh-/Spezialformate).
SKIP_TOKENS = ["-base", "-bnb-", "-gptq", "-awq", "-fp8", "draft", "tokenizer"]
+46
View File
@@ -0,0 +1,46 @@
# Mission Control 2.0 — Status & Resume-Guide
> So machst du jederzeit nahtlos weiter. Der vollständige Architektur-Plan liegt in
> `C:\Users\TobisPC\.claude\plans\piped-cuddling-sloth.md` (genehmigt).
## Wo das Projekt lebt
- **Code:** `F:\Coding Stuff\mission-control-2` (Windows-Dev-PC) + Gitea-Remote
`https://git.tobisniceshomelab.ddnsfree.com/Hitonabi/mission-control-v2` (Branch `main`).
- **v1** (`F:\Coding Stuff\mission-control`) bleibt unangetastet bis zum Cutover.
- **Box** (Bosgame, `192.168.178.151`): noch **nicht** deployt (Phase 0 step 5 offen).
## Phasen-Fortschritt
- [x] **Phase 0 — Gerüst:** FastAPI (`/api/health`,`/api/models`) + React/shadcn-Shell (Cmd+K, Dark, PWA). Verifiziert.
- [x] **Phase 1 — Engine + Routing:** Compute-Module (fit/caps/sources), Discover (live HF), Engine-Write
(register + groups/Ko-Residenz), LiteLLM-Gateway-Config + Service, Frontend Modelle&Routing
(Caps-Chips, Fit, Discover-Tab, Routing-View). Lokal verifiziert (Backend-Smoke + Frontend-Build + Browser).
- [ ] **Phase 2 — System/OS + Connect** (Metriken, Dienste, Update/Self-Update, Connect-Snippets).
- [ ] **Phase 3 — Memory + MCP** (Governance-UI, mcp_memory.py portiert, mcp_mc.py neu).
- [ ] **Phase 4 — Hermes-Schicht** (hermes-webui Dienst, Brain=auto, Tools/MCP verdrahten). **Braucht Box.**
- [ ] **Phase 5 — Betrieb/Observability/Politur** (Backup, LiteLLM-Traces, Health).
- [ ] **Phase 6 — Cutover** (/opt → v2, v1 aus). **Braucht Box.**
- Offen aus Phase 0/1: **Box-Deploy** (Gitea-Repo da; Box-Setup als systemd-USER-Dienst noch offen),
LiteLLM **Complexity-Auto-Router** gegen installierte Version verifizieren.
## Lokal entwickeln/verifizieren
```bash
# Backend
cd backend && .venv/Scripts/python -m uvicorn app:app --port 9000
# Frontend (Dev)
cd frontend && npm run dev # http://localhost:5173 (proxyt /api → :9000)
# Frontend (Build → wird vom Backend ausgeliefert, committet)
cd frontend && npm run build
```
## API (Stand Phase 1)
- `GET /api/health` — Status + engine/gateway reachable
- `GET /api/models` — installierte Modelle inkl. `capabilities`
- `GET /api/discover[?force=1]` — beste Modelle je Kategorie (live HF, Fit, Caps, ⭐recommended)
- `GET /api/fit?params_b=&quant=&ctx=&name=` — Hardware-Fit-Schätzung
- `POST /api/models/register` — GGUF als llama-swap-Modell eintragen (cmd + Rolle-Alias, optional jinja)
- `GET/PUT /api/groups` — llama-swap-`groups` (Ko-Residenz `swap:false`)
- `GET /api/routing`, `PUT /api/routing/route` — LiteLLM-Gateway-Mapping
## Nächster sinnvoller Schritt
Phase 2 (System/OS + Connect) lokal bauen — **oder** Box-Deploy einrichten (systemd-USER-Dienst,
sudo-frei). Beim Box-Deploy: `deploy/deploy.sh` vorher auf User-Dienst umstellen (kein /opt/sudo).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CP2tEA8E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGVxdi48.css">
<script type="module" crossorigin src="/assets/index-BeAucPZJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-NuZDhqZy.css">
</head>
<body>
<div id="root"></div>
+5 -1
View File
@@ -3,6 +3,7 @@ import { Command as CommandIcon } from "lucide-react"
import { NAV, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { ModelsView } from "@/views/ModelsView"
import { RoutingView } from "@/views/RoutingView"
import { Placeholder } from "@/views/Placeholder"
import { api, type Health } from "@/lib/api"
import { cn } from "@/lib/utils"
@@ -88,7 +89,10 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-6">
{view === "models" && <ModelsView />}
{view !== "models" && <Placeholder title={active.label} hint={active.hint} />}
{view === "routing" && <RoutingView />}
{view !== "models" && view !== "routing" && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
import type { Capabilities } from "@/lib/api"
function Chip({ children, tone = "muted" }: { children: React.ReactNode; tone?: "muted" | "primary" | "warn" }) {
const tones = {
muted: "bg-muted text-muted-foreground",
primary: "bg-primary/15 text-primary",
warn: "bg-amber-500/15 text-amber-500",
}
return (
<span className={`rounded px-1.5 py-0.5 text-[11px] font-medium ${tones[tone]}`}>{children}</span>
)
}
export function CapsChips({ caps }: { caps?: Capabilities }) {
if (!caps) return null
return (
<span className="inline-flex flex-wrap gap-1">
{caps.coder && <Chip>💻 Code</Chip>}
{caps.vision && <Chip>👁 Bild</Chip>}
{caps.reasoning && <Chip>🧠 Reason</Chip>}
{caps.moe && <Chip tone="primary">🧩 MoE{caps.active_b ? `·${caps.active_b}b` : ""}</Chip>}
{caps.tools === "yes" && <Chip tone="primary">🛠 Tools</Chip>}
{caps.tools === "likely" && <Chip tone="warn">🛠 Tools?</Chip>}
{caps.embedding && <Chip>🔢 Embed</Chip>}
</span>
)
}
+58
View File
@@ -9,6 +9,26 @@ export async function api<T = unknown>(path: string, init?: RequestInit): Promis
return res.json() as Promise<T>
}
export interface Capabilities {
moe: boolean
active_b: number | null
tools: "yes" | "likely" | "no"
vision: boolean
coder: boolean
reasoning: boolean
embedding: boolean
ctx: number | null
params_b: number | null
arch: string | null
}
export interface Fit {
level: "perfect" | "marginal" | "too_tight"
text: string
req_gb: number
tps: number
}
export interface ModelInfo {
name: string
role: string | null
@@ -17,14 +37,52 @@ export interface ModelInfo {
ctx: number | null
ttl: number | null
cmd: string
gguf_path: string
filename: string
quant: string
size_bytes: number | null
incomplete: boolean
capabilities: Capabilities
}
export interface DiscoverModel {
name: string
author: string
repo: string
role: string
params_b: number
quant: string
tags: string[]
downloads: number
fit: Fit
optimal_ctx: number
caps: Capabilities
}
export interface DiscoverCategory {
role: string
title: string
icon: string
models: DiscoverModel[]
recommended: string | null
}
export interface DiscoverResp {
updated: number
categories: DiscoverCategory[]
sys_ram_gb: number
}
export interface RoutingResp {
routes: { name: string; target: string; api_base: string | null }[]
fallbacks: Record<string, string[]>[]
context_window_fallbacks: Record<string, string[]>[]
gateway_reachable: boolean
}
export interface Health {
status: string
version: string
engine_reachable: boolean
gateway_reachable: boolean
}
+136 -61
View File
@@ -1,18 +1,31 @@
import { useEffect, useState } from "react"
import { api, type ModelInfo } from "@/lib/api"
import { api, type DiscoverResp, type Fit, type ModelInfo } from "@/lib/api"
import { CapsChips } from "@/components/CapsChips"
import { cn } from "@/lib/utils"
function fmtSize(b: number | null) {
if (!b) return "—"
const gb = b / 1024 ** 3
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(b / 1024 ** 2).toFixed(0)} MB`
}
function fmtCtx(c: number | null) {
if (!c) return "—"
return `${Math.round(c / 1024)}k`
return c ? `${Math.round(c / 1024)}k` : "—"
}
export function ModelsView() {
function FitBadge({ fit }: { fit: Fit }) {
const tone = {
perfect: "bg-emerald-500/15 text-emerald-500",
marginal: "bg-amber-500/15 text-amber-500",
too_tight: "bg-red-500/15 text-red-500",
}[fit.level]
return (
<span className={cn("rounded px-1.5 py-0.5 text-[11px] font-medium", tone)}>
{fit.text} · ~{fit.req_gb} GB
</span>
)
}
function Installed() {
const [models, setModels] = useState<ModelInfo[]>([])
const [error, setError] = useState("")
const [loading, setLoading] = useState(true)
@@ -24,71 +37,133 @@ export function ModelsView() {
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="text-sm text-muted-foreground">Lade</div>
if (error)
return (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
Engine nicht erreichbar oder keine Config gefunden ({error}).
</div>
)
return (
<div className="overflow-hidden rounded-xl border border-border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Modell</th>
<th className="px-4 py-2 font-medium">Rolle</th>
<th className="px-4 py-2 font-medium">Fähigkeiten</th>
<th className="px-4 py-2 font-medium">Kontext</th>
<th className="px-4 py-2 font-medium">Größe</th>
</tr>
</thead>
<tbody>
{models.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
Keine Modelle konfiguriert.
</td>
</tr>
)}
{models.map((m) => (
<tr key={m.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-medium">{m.name}</td>
<td className="px-4 py-2.5">
{m.role ? (
<span className="rounded-md bg-primary/15 px-2 py-0.5 text-xs text-primary">{m.role}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-2.5">
<CapsChips caps={m.capabilities} />
</td>
<td className="px-4 py-2.5 text-muted-foreground">{fmtCtx(m.ctx)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{fmtSize(m.size_bytes)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function Discover() {
const [data, setData] = useState<DiscoverResp | null>(null)
const [error, setError] = useState("")
const [loading, setLoading] = useState(true)
useEffect(() => {
api<DiscoverResp>("/api/discover")
.then(setData)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false))
}, [])
if (loading) return <div className="text-sm text-muted-foreground">Suche aktuelle Modelle</div>
if (error || !data)
return (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
Modell-Quellen gerade nicht erreichbar ({error}).
</div>
)
return (
<div className="space-y-6">
<div className="text-xs text-muted-foreground">
Live von HuggingFace · Hardware-Fit für ~{data.sys_ram_gb} GB · = beste Wahl je Kategorie
</div>
{data.categories.map((cat) => (
<div key={cat.role} className="space-y-2">
<h3 className="text-sm font-semibold">{cat.title}</h3>
<div className="grid gap-2 sm:grid-cols-2">
{cat.models.map((m) => (
<div key={m.repo} className="rounded-lg border border-border bg-card p-3">
<div className="flex items-center gap-2">
{cat.recommended === m.repo && <span title="beste Wahl"></span>}
<span className="truncate text-sm font-medium">{m.name}</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">{m.author}</div>
<div className="mt-2 flex flex-wrap items-center gap-1">
<CapsChips caps={m.caps} />
<FitBadge fit={m.fit} />
</div>
</div>
))}
</div>
</div>
))}
</div>
)
}
export function ModelsView() {
const [tab, setTab] = useState<"installed" | "discover">("installed")
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Modelle &amp; Routing</h1>
<p className="text-sm text-muted-foreground">
In llama-swap konfigurierte Modelle (Phase 0: read-only). Installieren, Gruppen &amp;
Auto-Routing folgen in Phase 1.
Installierte Modelle (mit Fähigkeiten) und aktuell beste Modelle für deine Hardware.
</p>
</div>
{loading && <div className="text-sm text-muted-foreground">Lade</div>}
{error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
Engine nicht erreichbar oder keine Config gefunden ({error}).
</div>
)}
<div className="inline-flex rounded-lg border border-border bg-card p-0.5 text-sm">
{(["installed", "discover"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={cn(
"rounded-md px-3 py-1.5 transition-colors",
tab === t ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground",
)}
>
{t === "installed" ? "Installiert" : "Modelle finden"}
</button>
))}
</div>
{!loading && !error && (
<div className="overflow-hidden rounded-xl border border-border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Modell</th>
<th className="px-4 py-2 font-medium">Rolle</th>
<th className="px-4 py-2 font-medium">Kontext</th>
<th className="px-4 py-2 font-medium">Quant</th>
<th className="px-4 py-2 font-medium">Größe</th>
</tr>
</thead>
<tbody>
{models.length === 0 && (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
Keine Modelle konfiguriert.
</td>
</tr>
)}
{models.map((m) => (
<tr key={m.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-medium">
{m.name}
{m.incomplete && (
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
unvollständig
</span>
)}
</td>
<td className="px-4 py-2.5">
{m.role ? (
<span className="rounded-md bg-primary/15 px-2 py-0.5 text-xs text-primary">
{m.role}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-2.5 text-muted-foreground">{fmtCtx(m.ctx)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{m.quant || "—"}</td>
<td className="px-4 py-2.5 text-muted-foreground">{fmtSize(m.size_bytes)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{tab === "installed" ? <Installed /> : <Discover />}
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useEffect, useState } from "react"
import { api, type RoutingResp } from "@/lib/api"
import { cn } from "@/lib/utils"
export function RoutingView() {
const [data, setData] = useState<RoutingResp | null>(null)
const [error, setError] = useState("")
useEffect(() => {
api<RoutingResp>("/api/routing").then(setData).catch((e) => setError(String(e)))
}, [])
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-semibold">Routing</h1>
<p className="text-sm text-muted-foreground">
Der LiteLLM-Gateway bündelt alle Modelle zu einem Endpunkt. <code>auto</code> = schnell im
Alltag, eskaliert bei Bedarf auf <code>heavy</code>. Gilt für Hermes <em>und</em> Vibe Coding.
</p>
</div>
{error && (
<div className="rounded-md border border-border bg-card p-3 text-sm text-muted-foreground">
Gateway-Config nicht lesbar ({error}).
</div>
)}
{data && (
<>
<div className="flex items-center gap-2 text-sm">
<span
className={cn(
"h-2 w-2 rounded-full",
data.gateway_reachable ? "bg-emerald-500" : "bg-amber-500",
)}
/>
Gateway {data.gateway_reachable ? "online" : "offline (Config wird trotzdem angezeigt)"}
</div>
<div className="overflow-hidden rounded-xl border border-border bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground">
<th className="px-4 py-2 font-medium">Gateway-Modell</th>
<th className="px-4 py-2 font-medium"> Backend (llama-swap)</th>
</tr>
</thead>
<tbody>
{data.routes.map((r) => (
<tr key={r.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-medium">{r.name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-muted-foreground">{r.target}</td>
</tr>
))}
</tbody>
</table>
</div>
{data.fallbacks.length > 0 && (
<div className="rounded-xl border border-border bg-card p-4 text-sm">
<div className="mb-2 font-medium">Eskalation (Fallbacks)</div>
<ul className="space-y-1 text-muted-foreground">
{data.fallbacks.map((f, i) => {
const [k, v] = Object.entries(f)[0]
return (
<li key={i}>
<code>{k}</code> <code>{v.join(", ")}</code>
</li>
)
})}
</ul>
</div>
)}
</>
)}
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
# LiteLLM-Gateway-Config für Mission Control 2.0
# ----------------------------------------------------------------------------
# EIN OpenAI-Endpunkt für ALLE Konsumenten (Hermes + Vibe-Coding-IDEs).
# Hinter dem Gateway sitzt llama-swap (:8080), das die llama-swap-Rollen-Aliase
# (fast/heavy/vision/coder) bei Bedarf lädt — fast+heavy sind via llama-swap
# `groups` (swap:false) ko-resident, daher Delegation ohne Nachlade-Latenz.
#
# Wird von Mission Control verwaltet (services/gateway.py). Start auf der Box:
# litellm --config /opt/mission-control-2/gateway/config.yaml --port 4000
#
# HINWEIS (auf der Box verifizieren): Der KOMPLEXITÄTS-Auto-Router ("model: auto"
# wählt pro Anfrage schnell↔schwer nach Schwierigkeit) ist LiteLLM-versionsabhängig.
# Baseline unten: "auto" == fast, eskaliert per Fallback auf heavy (bei Fehler/
# Kontext-Overflow). Den echten Complexity-Router (docs.litellm.ai/docs/proxy/
# auto_routing) in Phase „Routing-Feinschliff" gegen die installierte Version setzen.
model_list:
# Schnelles Alltags-Hirn (llama-swap-Alias "fast")
- model_name: fast
litellm_params:
model: openai/fast
api_base: http://127.0.0.1:8080/v1
api_key: sk-noauth
# Schweres Reasoning-Hirn (llama-swap-Alias "heavy")
- model_name: heavy
litellm_params:
model: openai/heavy
api_base: http://127.0.0.1:8080/v1
api_key: sk-noauth
# Vision (llama-swap-Alias "vision")
- model_name: vision
litellm_params:
model: openai/vision
api_base: http://127.0.0.1:8080/v1
api_key: sk-noauth
# Coder (llama-swap-Alias "coder")
- model_name: coder
litellm_params:
model: openai/coder
api_base: http://127.0.0.1:8080/v1
api_key: sk-noauth
# "auto" — Baseline: == fast, eskaliert per Fallback auf heavy.
- model_name: auto
litellm_params:
model: openai/fast
api_base: http://127.0.0.1:8080/v1
api_key: sk-noauth
litellm_settings:
# Bei Fehler/Kontext-Overflow von "auto"/"fast" auf "heavy" eskalieren.
fallbacks:
- auto: ["heavy"]
- fast: ["heavy"]
context_window_fallbacks:
- auto: ["heavy"]
- fast: ["heavy"]
num_retries: 1
request_timeout: 600
router_settings:
routing_strategy: simple-shuffle