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:
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -2,3 +2,4 @@ fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
httpx>=0.27
|
||||
ruamel.yaml>=0.18
|
||||
psutil>=5.9
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)."}
|
||||
@@ -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
@@ -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 {}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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).
|
||||
+140
File diff suppressed because one or more lines are too long
-1
File diff suppressed because one or more lines are too long
-140
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 & Routing</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
In llama-swap konfigurierte Modelle (Phase 0: read-only). Installieren, Gruppen &
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user