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:
@@ -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"]
|
||||
Reference in New Issue
Block a user