902867be3f
Der agent-Slot zog bisher aus der duennen Name-Keyword-Kategorie und blieb oft leer. Jetzt poolt build_dynamic_recipes fuer role==agent ueber ALLE discover-Kategorien und filtert via model_caps.capabilities (HF-Tags) auf tool-faehige Modelle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
625 lines
25 KiB
Python
625 lines
25 KiB
Python
"""
|
|
Cookbook Router: Verbindet die HuggingFace API mit der Odysseus-Hardware-Berechnung.
|
|
"""
|
|
|
|
import httpx
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
import psutil
|
|
|
|
from ruamel.yaml.scalarstring import LiteralScalarString
|
|
|
|
from auth import auth
|
|
from hw_math import evaluate_fit, max_ctx_for, extract_params_b, extract_active_params_b
|
|
from config import (MODELS_DIR, CMD_TEMPLATE, DEFAULT_TTL, HF_DOWNLOAD_ENV, USER_RECIPES_PATH,
|
|
DISCOVER_CACHE_PATH, DISCOVER_TTL, hf_bin)
|
|
from llamaswap import read_config, write_config, model_id_from_path, set_role_alias
|
|
from jobengine import start_job, JOBS, attach_download_progress
|
|
from recipes import RECIPES, UPGRADES, RECIPE_TEMPLATES, ROLE_WHY
|
|
from sources import TRUSTED_AUTHORS, CATEGORIES, SKIP_TOKENS
|
|
|
|
router = APIRouter(prefix="/api/cookbook", dependencies=[Depends(auth)])
|
|
|
|
_FIT_ORDER = {"perfect": 0, "marginal": 1, "too_tight": 2}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Eigene Setups (vom Nutzer) — persistente JSON-Datei, gleiches Schema wie RECIPES.
|
|
# ---------------------------------------------------------------------------
|
|
def load_user_recipes() -> list:
|
|
try:
|
|
if USER_RECIPES_PATH.exists():
|
|
return json.loads(USER_RECIPES_PATH.read_text(encoding="utf-8")) or []
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
return []
|
|
|
|
|
|
def save_user_recipes(items: list) -> None:
|
|
USER_RECIPES_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = USER_RECIPES_PATH.with_name(USER_RECIPES_PATH.name + ".tmp")
|
|
tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
os.replace(tmp, USER_RECIPES_PATH)
|
|
|
|
|
|
def _is_moe(name: str) -> bool:
|
|
low = (name or "").lower()
|
|
return (extract_active_params_b(name) is not None
|
|
or "mixtral" in low or bool(re.search(r"\d+x\d+\.?\d*b", low)))
|
|
|
|
|
|
def _tool_capable(m: dict) -> bool:
|
|
"""Tool-Fähigkeit eines (noch nicht installierten) Modells aus Name + HF-Tags."""
|
|
from model_caps import capabilities
|
|
c = capabilities(name=m.get("repo", ""), hf={"tags": m.get("tags", [])})
|
|
return c["tools"] != "no"
|
|
|
|
|
|
def _safe_discover(ram_gb: float) -> dict | None:
|
|
"""discover-Daten aus Cache (oder live), ohne zu werfen — 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: # noqa: BLE001
|
|
return cached
|
|
|
|
|
|
def build_dynamic_recipes(disc: dict) -> list:
|
|
"""Fuellt die kuratierten Rollen-Slot-Templates LIVE aus discover.
|
|
Pro Slot das beste passende Modell (perfect>marginal, MoE bevorzugt fuer APU,
|
|
dann Beliebtheit); dedupe ueber Slots; leere Slots werden still ausgelassen."""
|
|
cats = {c["role"]: c for c in (disc.get("categories") or [])}
|
|
all_models = [m for c in cats.values() for m in (c.get("models") or [])]
|
|
out = []
|
|
for tpl in RECIPE_TEMPLATES:
|
|
used: set[str] = set()
|
|
models = []
|
|
for role in tpl["slots"]:
|
|
# "agent" = bestes TOOL-FÄHIGES Modell (kategorieübergreifend, via caps),
|
|
# nicht die dünne Name-Keyword-Kategorie. Sonst: Modelle der Rolle.
|
|
if role == "agent":
|
|
pool = [m for m in all_models if _tool_capable(m)]
|
|
else:
|
|
cat = cats.get(role)
|
|
pool = (cat.get("models") if cat else []) or []
|
|
cands = [m for m in pool
|
|
if m["fit"]["level"] != "too_tight" and m["repo"] not in used]
|
|
if not cands:
|
|
continue
|
|
cands.sort(key=lambda m: (_FIT_ORDER[m["fit"]["level"]],
|
|
0 if _is_moe(m["repo"]) else 1,
|
|
-int(m.get("downloads") or 0)))
|
|
pick = cands[0]
|
|
used.add(pick["repo"])
|
|
models.append({
|
|
"role": role, "name": pick["name"], "repo": pick["repo"],
|
|
"params_b": pick["params_b"], "quant": pick.get("quant", "Q4_K_M"),
|
|
"tags": pick.get("tags", []), "why": ROLE_WHY.get(role, ""),
|
|
})
|
|
if models:
|
|
out.append({"id": tpl["id"], "title": tpl["title"], "icon": tpl["icon"],
|
|
"desc": tpl["desc"], "models": models, "auto": True})
|
|
return out
|
|
|
|
|
|
def all_recipes() -> list:
|
|
"""Auto-komponierte Setups (live aus discover, Hardware-aware) + eigene Setups.
|
|
Fallback auf die statischen RECIPES, wenn discover gerade nichts liefert."""
|
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
|
disc = _safe_discover(ram_gb)
|
|
base = build_dynamic_recipes(disc) if disc else []
|
|
if not base:
|
|
base = list(RECIPES)
|
|
return base + load_user_recipes()
|
|
|
|
class AnalyzeRequest(BaseModel):
|
|
repo_id: str
|
|
ctx: int = 8192
|
|
|
|
class EvaluateRequest(BaseModel):
|
|
params_b: float
|
|
quant: str
|
|
ctx: int
|
|
|
|
class InstallRecipeReq(BaseModel):
|
|
recipe_id: str
|
|
hf_token: str | None = None
|
|
|
|
class InstallModelReq(BaseModel):
|
|
repo: str
|
|
role: str
|
|
params_b: float
|
|
quant: str = "Q4_K_M"
|
|
file: str | None = None # konkrete GGUF-Datei; None → _pick_gguf wählt automatisch
|
|
ctx: int | None = None # None → max_ctx_for (Hardware-Optimum)
|
|
hf_token: str | None = None
|
|
|
|
|
|
class UserRecipeModelReq(BaseModel):
|
|
repo: str
|
|
role: str
|
|
quant: str = "Q4_K_M"
|
|
name: str | None = None
|
|
why: str | None = None
|
|
|
|
|
|
class UserRecipeReq(BaseModel):
|
|
title: str
|
|
desc: str = ""
|
|
icon: str = "box"
|
|
models: list[UserRecipeModelReq]
|
|
|
|
def extract_quant(filename: str) -> str:
|
|
m = re.search(r"(Q\d_[A-Z0-9_]+|IQ\d_[A-Z0-9_]+|FP16|BF16)", filename, re.IGNORECASE)
|
|
return m.group(1).upper() if m else "Q4_K_M"
|
|
|
|
@router.post("/analyze")
|
|
async def analyze_repo(req: AnalyzeRequest):
|
|
"""Holt die GGUF Dateien von HuggingFace und berechnet den Hardware-Fit."""
|
|
url = f"https://huggingface.co/api/models/{req.repo_id}/tree/main"
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
try:
|
|
resp = await client.get(url, timeout=10.0)
|
|
resp.raise_for_status()
|
|
tree = resp.json()
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=f"HuggingFace Fehler: {str(e)}")
|
|
|
|
gguf_files = [f["path"] for f in tree if f.get("path", "").endswith(".gguf")]
|
|
|
|
if not gguf_files:
|
|
return {"files": []}
|
|
|
|
params_b = extract_params_b(req.repo_id)
|
|
|
|
# Ermittle RAM des Systems (da APU = Shared Memory)
|
|
ram_gb = psutil.virtual_memory().total / (1024**3)
|
|
|
|
results = []
|
|
for f in gguf_files:
|
|
quant = extract_quant(f)
|
|
fit = evaluate_fit(params_b, quant, req.ctx, ram_gb, name=req.repo_id)
|
|
|
|
# Priority-Score, um den besten Fit an oberste Stelle zu setzen.
|
|
# "Q4_K_M" ist oft der Sweetspot.
|
|
priority = 0
|
|
if fit["level"] == "perfect":
|
|
priority += 10
|
|
if quant == "Q4_K_M": priority += 5
|
|
elif quant.startswith("Q4"): priority += 4
|
|
elif quant.startswith("Q5"): priority += 3
|
|
|
|
results.append({
|
|
"filename": f,
|
|
"quant": quant,
|
|
"fit": fit,
|
|
"optimal_ctx": max_ctx_for(params_b, quant, ram_gb),
|
|
"priority": priority
|
|
})
|
|
|
|
# Sortieren: Highest priority first, dann nach tps (schnellste zuerst)
|
|
results.sort(key=lambda x: (x["priority"], x["fit"]["tps"]), reverse=True)
|
|
|
|
return {
|
|
"repo": req.repo_id,
|
|
"params_b": params_b,
|
|
"sys_ram_gb": round(ram_gb, 1),
|
|
"files": results
|
|
}
|
|
|
|
@router.post("/evaluate")
|
|
def evaluate_single(req: EvaluateRequest):
|
|
ram_gb = psutil.virtual_memory().total / (1024**3)
|
|
fit = evaluate_fit(req.params_b, req.quant, req.ctx, ram_gb)
|
|
fit["optimal_ctx"] = max_ctx_for(req.params_b, req.quant, ram_gb)
|
|
return fit
|
|
|
|
|
|
@router.get("/recipes")
|
|
def recipes():
|
|
"""Use-Case-Setups mit Hardware-Fit pro Modell + Stack-Gesamturteil. Da llama-swap nur EIN
|
|
Modell gleichzeitig lädt, ist das Stack-Urteil der schlechteste (= größte) Einzel-Fit."""
|
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
|
out = []
|
|
for r in all_recipes():
|
|
models, worst = [], "perfect"
|
|
for m in r["models"]:
|
|
fit = evaluate_fit(m["params_b"], m["quant"], 8192, ram_gb, name=m.get("name", ""))
|
|
models.append({**m, "fit": fit, "optimal_ctx": max_ctx_for(m["params_b"], m["quant"], ram_gb)})
|
|
if _FIT_ORDER[fit["level"]] > _FIT_ORDER[worst]:
|
|
worst = fit["level"]
|
|
out.append({**r, "models": models, "fit_level": worst, "user": bool(r.get("user"))})
|
|
# „Beste Wahl": das reichste KURATIERTE Setup, das komplett auf die Hardware passt.
|
|
fitting = [r for r in out if not r["user"] and r["fit_level"] != "too_tight"]
|
|
rec = max(fitting, key=lambda r: len(r["models"]), default=None) if fitting else None
|
|
return {"recipes": out, "sys_ram_gb": round(ram_gb, 1), "recommended_id": rec["id"] if rec else None}
|
|
|
|
|
|
@router.post("/user-recipe")
|
|
def create_user_recipe(req: UserRecipeReq):
|
|
"""Eigenes Cookbook-Setup anlegen. params_b wird aus dem Repo-Namen abgeleitet, damit der
|
|
Nutzer nur Repo + Rolle + Quant angeben muss; Fit/Kontext kommen wie immer zur Laufzeit."""
|
|
if not req.title.strip():
|
|
raise HTTPException(400, "Bitte einen Titel angeben.")
|
|
models = []
|
|
for m in req.models:
|
|
if not m.repo.strip():
|
|
continue
|
|
models.append({
|
|
"role": (m.role or "modell").strip(),
|
|
"name": (m.name or m.repo.split("/")[-1]).strip(),
|
|
"repo": m.repo.strip(),
|
|
"params_b": extract_params_b(m.repo),
|
|
"quant": (m.quant or "Q4_K_M").strip(),
|
|
"why": (m.why or "").strip(),
|
|
})
|
|
if not models:
|
|
raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
|
|
items = load_user_recipes()
|
|
base = "user-" + (re.sub(r"[^a-z0-9]+", "-", req.title.lower()).strip("-") or "setup")
|
|
rid, n = base, 2
|
|
existing = {r.get("id") for r in items} | {r["id"] for r in RECIPES}
|
|
while rid in existing:
|
|
rid, n = f"{base}-{n}", n + 1
|
|
items.append({"id": rid, "title": req.title.strip(), "icon": (req.icon or "box").strip(),
|
|
"desc": req.desc.strip(), "models": models, "user": True})
|
|
save_user_recipes(items)
|
|
return {"ok": True, "id": rid}
|
|
|
|
|
|
@router.put("/user-recipe/{recipe_id}")
|
|
def update_user_recipe(recipe_id: str, req: UserRecipeReq):
|
|
"""Bestehendes eigenes Setup aktualisieren (Titel, Beschreibung, Modelle)."""
|
|
items = load_user_recipes()
|
|
idx = next((i for i, r in enumerate(items) if r.get("id") == recipe_id), None)
|
|
if idx is None:
|
|
raise HTTPException(404, "Eigenes Setup nicht gefunden.")
|
|
if not req.title.strip():
|
|
raise HTTPException(400, "Bitte einen Titel angeben.")
|
|
models = []
|
|
for m in req.models:
|
|
if not m.repo.strip():
|
|
continue
|
|
models.append({
|
|
"role": (m.role or "modell").strip(),
|
|
"name": (m.name or m.repo.split("/")[-1]).strip(),
|
|
"repo": m.repo.strip(),
|
|
"params_b": extract_params_b(m.repo),
|
|
"quant": (m.quant or "Q4_K_M").strip(),
|
|
"why": (m.why or "").strip(),
|
|
})
|
|
if not models:
|
|
raise HTTPException(400, "Mindestens ein Modell (Repo) angeben.")
|
|
items[idx] = {**items[idx], "title": req.title.strip(), "icon": (req.icon or "box").strip(),
|
|
"desc": req.desc.strip(), "models": models}
|
|
save_user_recipes(items)
|
|
return {"ok": True, "id": recipe_id}
|
|
|
|
|
|
@router.delete("/user-recipe/{recipe_id}")
|
|
def delete_user_recipe(recipe_id: str):
|
|
items = load_user_recipes()
|
|
kept = [r for r in items if r.get("id") != recipe_id]
|
|
if len(kept) == len(items):
|
|
raise HTTPException(404, "Eigenes Setup nicht gefunden.")
|
|
save_user_recipes(kept)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.post("/install-recipe")
|
|
def install_recipe(req: InstallRecipeReq):
|
|
"""Komplettes Setup installieren: jedes Modell als Download-Job starten UND sofort mit
|
|
optimalem (gedeckeltem) Kontext in die config.yaml eintragen. llama-swap (-watch-config)
|
|
übernimmt es, sobald die Datei da ist."""
|
|
recipe = next((r for r in all_recipes() if r["id"] == req.recipe_id), None)
|
|
if not recipe:
|
|
raise HTTPException(404, "Setup nicht gefunden.")
|
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
|
env = dict(HF_DOWNLOAD_ENV)
|
|
if req.hf_token:
|
|
env["HF_TOKEN"] = req.hf_token
|
|
cfg = read_config()
|
|
job_ids = []
|
|
for m in recipe["models"]:
|
|
file = _pick_gguf(m["repo"], m.get("quant", "Q4_K_M"))
|
|
if not file:
|
|
continue # kein GGUF im Repo gefunden -> Modell ueberspringen (Rest installiert trotzdem)
|
|
# Vision-Modelle: mmproj-Projektor mitholen (nötig für --mmproj in llama.cpp).
|
|
mmproj = _pick_mmproj(m["repo"])
|
|
target = MODELS_DIR / m["repo"].split("/")[-1]
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
args = [hf_bin(), "download", m["repo"], file]
|
|
if mmproj:
|
|
args.append(mmproj) # hf-cli unterstützt mehrere Dateien in einem Aufruf
|
|
args += ["--local-dir", str(target)]
|
|
jid = start_job(args, f"download {m['name']}", env=env)
|
|
JOBS[jid]["result_path"] = str(target / file)
|
|
attach_download_progress(jid, str(target), hf_file_size(m["repo"], file))
|
|
job_ids.append(jid)
|
|
# Eintrag jetzt schon schreiben — optimaler Kontext für die Hardware.
|
|
ctx = max_ctx_for(m["params_b"], m["quant"], ram_gb)
|
|
path = str(target / file)
|
|
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
|
|
if mmproj:
|
|
cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja"
|
|
# Schluessel = sprechender Modellname; Rolle (aus dem Rezept) als Alias.
|
|
mid = model_id_from_path(path)
|
|
cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
|
|
set_role_alias(cfg, mid, m["role"])
|
|
write_config(cfg)
|
|
return {"job_ids": job_ids, "count": len(job_ids)}
|
|
|
|
|
|
def hf_file_size(repo: str, file: str) -> int:
|
|
"""Groesse einer Datei im HF-Repo (Bytes) fuer die Fortschrittsanzeige. 0 wenn unbekannt.
|
|
GGUFs sind LFS -> ggf. unter 'lfs.size'."""
|
|
try:
|
|
with httpx.Client(timeout=10.0) as c:
|
|
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
|
|
for f in tree:
|
|
if isinstance(f, dict) and f.get("path") == file:
|
|
return int(f.get("size") or (f.get("lfs") or {}).get("size") or 0)
|
|
except Exception: # noqa: BLE001
|
|
return 0
|
|
return 0
|
|
|
|
|
|
def _pick_gguf(repo: str, quant: str = "Q4_K_M") -> str | None:
|
|
"""Beste GGUF-Datei eines Repos auflösen: bevorzugt gewünschten Quant, keine Split-Teile."""
|
|
try:
|
|
with httpx.Client(timeout=10.0) as c:
|
|
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
ggufs = [f["path"] for f in tree if isinstance(f, dict) and str(f.get("path", "")).endswith(".gguf")]
|
|
if not ggufs:
|
|
return None
|
|
pref = [g for g in ggufs if quant.lower() in g.lower() and "-of-" not in g]
|
|
nosplit = [g for g in ggufs if "-of-" not in g]
|
|
return (pref or nosplit or ggufs)[0]
|
|
|
|
|
|
def _pick_mmproj(repo: str) -> str | None:
|
|
"""Sucht den mmproj-Projektor (Vision-Modelle) im Repo — F16/BF16 bevorzugt.
|
|
Gibt None zurueck, wenn kein Projektor vorhanden (= kein Vision-Modell)."""
|
|
try:
|
|
with httpx.Client(timeout=10.0) as c:
|
|
tree = c.get(f"https://huggingface.co/api/models/{repo}/tree/main").json()
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
candidates = [
|
|
f["path"] for f in tree
|
|
if isinstance(f, dict) and "mmproj" in str(f.get("path", "")).lower()
|
|
and str(f.get("path", "")).endswith(".gguf")
|
|
]
|
|
if not candidates:
|
|
return None
|
|
# F16/BF16 hat die beste Projektor-Qualität; nimm das erste wenn keins passt.
|
|
pref = [c for c in candidates if "f16" in c.lower() or "bf16" in c.lower()]
|
|
return (pref or candidates)[0]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Automatische Modell-Entdeckung ("aktuell beste Modelle", live von HF + Cache).
|
|
# ---------------------------------------------------------------------------
|
|
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" # Fallback: Allrounder
|
|
|
|
|
|
def _installed_index() -> dict:
|
|
"""alias -> kleingeschriebene cmd-Zeile der installierten Modelle (fuer Bestandsabgleich)."""
|
|
return {alias: str(spec.get("cmd", "")).lower()
|
|
for alias, spec in (read_config().get("models") or {}).items()}
|
|
|
|
|
|
def _model_installed(repo: str, idx: dict) -> str | None:
|
|
"""Liefert den Alias, unter dem dieses Repo schon installiert ist — sonst None."""
|
|
base = repo.split("/")[-1].lower()
|
|
# GGUF-Repos heissen meist '<modell>-GGUF' -> auch ohne Suffix vergleichen
|
|
stem = base[:-5] if base.endswith("-gguf") else base
|
|
for alias, cmd in idx.items():
|
|
if base in cmd or (stem and stem in cmd):
|
|
return alias
|
|
return None
|
|
|
|
|
|
def _model_requirements(role: str, quant: str, fit: dict) -> list[str]:
|
|
"""Ehrliche Voraussetzungen/Hinweise pro Modell (Klartext fuer Anfaenger)."""
|
|
reqs = []
|
|
if role == "vision":
|
|
reqs.append("Bild-Modell: Projektor (mmproj-F16) wird automatisch mitgeladen und --jinja gesetzt.")
|
|
if quant.upper() in ("FP16", "BF16", "F16", "F32"):
|
|
reqs.append("Unquantisiert — sehr groß. Eine Q4/Q5-Variante ist meist die bessere Wahl.")
|
|
if fit["level"] == "too_tight":
|
|
reqs.append("Größer als dein Speicher — nur mit kleinerem Quant realistisch.")
|
|
elif fit["level"] == "marginal":
|
|
reqs.append("Läuft, wird aber knapp — andere Programme währenddessen besser schließen.")
|
|
return reqs
|
|
|
|
|
|
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: # noqa: BLE001
|
|
return []
|
|
|
|
|
|
def refresh_discover(ram_gb: float) -> dict:
|
|
"""Quellen live abfragen, kategorisieren, nach Hardware-Fit + Beliebtheit ranken
|
|
und cachen. Wirft nur, wenn KEINE einzige 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 = {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 fuer die Fit-Einschaetzung
|
|
fit = evaluate_fit(params_b, quant, 8192, ram_gb, name=rid)
|
|
by_cat[role].append({
|
|
"name": rid.split("/")[-1], "author": rid.split("/")[0], "repo": rid,
|
|
"role": role, "params_b": params_b, "quant": quant,
|
|
"tags": [str(t) for t in (m.get("tags") or [])], # HF-Tags für Capability-Erkennung (Phase 10)
|
|
"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),
|
|
})
|
|
|
|
cats = []
|
|
for c in CATEGORIES:
|
|
items = by_cat[c["role"]]
|
|
# Passendes zuerst (perfect/marginal vor too_tight), dann nach Downloads.
|
|
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})
|
|
|
|
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: # noqa: BLE001
|
|
pass # Cache ist nur Beschleunigung — Entdeckung funktioniert auch ohne Schreibrecht
|
|
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: # noqa: BLE001
|
|
pass
|
|
return None
|
|
|
|
|
|
@router.get("/discover")
|
|
def discover(force: bool = False):
|
|
"""Aktuell beste Modelle je Kategorie — live aus den Quellen (gecacht, TTL).
|
|
Markiert pro Modell, ob es schon installiert ist, und nennt Voraussetzungen."""
|
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
|
cached = _load_discover()
|
|
fresh = bool(cached) and (time.time() - cached.get("updated", 0) < DISCOVER_TTL)
|
|
if fresh and not force:
|
|
data = cached
|
|
else:
|
|
try:
|
|
data = refresh_discover(ram_gb)
|
|
except Exception: # noqa: BLE001
|
|
if cached:
|
|
data = cached # Quellen down -> alter Cache ist besser als nichts
|
|
else:
|
|
raise HTTPException(502, "Modell-Quellen gerade nicht erreichbar — später erneut versuchen.")
|
|
# Bestand + Voraussetzungen frisch dazurechnen (aendern sich unabhaengig vom Cache).
|
|
idx = _installed_index()
|
|
out = []
|
|
for c in data["categories"]:
|
|
ms = [{**m, "installed": _model_installed(m["repo"], idx),
|
|
"requirements": _model_requirements(m["role"], m.get("quant", "Q4_K_M"), m["fit"])}
|
|
for m in c["models"]]
|
|
# „Beste Wahl" je Kategorie: das lauffähige Modell mit dem besten Fit (perfect vor
|
|
# marginal), bei Gleichstand das meistgeladene. Zu große Modelle kommen nie in Frage.
|
|
ranked = sorted([m for m in ms if m["fit"]["level"] != "too_tight"],
|
|
key=lambda m: (_FIT_ORDER[m["fit"]["level"]], -int(m.get("downloads") or 0)))
|
|
out.append({**c, "models": ms, "recommended": ranked[0]["repo"] if ranked else None})
|
|
return {"updated": data["updated"], "categories": out,
|
|
"sys_ram_gb": round(ram_gb, 1), "stale": not fresh}
|
|
|
|
|
|
def compute_upgrades(ram_gb):
|
|
"""Liste relevanter Modell-Upgrades für die installierten Modelle (UPGRADES-Map)."""
|
|
installed = (read_config().get("models") or {})
|
|
out, seen = [], set()
|
|
for up in UPGRADES:
|
|
new_base = up["repo"].split("/")[-1].lower()
|
|
if any(new_base in str(s.get("cmd", "")).lower() for s in installed.values()):
|
|
continue # neueres Modell schon installiert
|
|
old = None
|
|
for alias, spec in installed.items():
|
|
hay = (alias + " " + str(spec.get("cmd", ""))).lower()
|
|
if any(k in hay for k in up["match"]):
|
|
old = alias
|
|
break
|
|
if not old or up["repo"] in seen:
|
|
continue
|
|
seen.add(up["repo"])
|
|
out.append({
|
|
"name": up["name"], "repo": up["repo"], "params_b": up["params_b"], "quant": up["quant"],
|
|
"why": up["why"], "old": old, "role": old,
|
|
"fit": evaluate_fit(up["params_b"], up["quant"], 8192, ram_gb, name=up.get("name", "")),
|
|
"optimal_ctx": max_ctx_for(up["params_b"], up["quant"], ram_gb),
|
|
})
|
|
return out
|
|
|
|
|
|
@router.get("/upgrades")
|
|
def upgrades():
|
|
return {"upgrades": compute_upgrades(psutil.virtual_memory().total / (1024 ** 3))}
|
|
|
|
|
|
@router.post("/install-model")
|
|
def install_model(req: InstallModelReq):
|
|
"""Ein einzelnes Modell installieren (Download + Einpflegen unter 'role', optimaler ctx).
|
|
Wenn 'file' übergeben wird, wird genau diese GGUF-Datei geladen (Profi-Suche-Auswahl);
|
|
andernfalls wählt _pick_gguf automatisch die beste Variante für den gewünschten Quant."""
|
|
file = req.file or _pick_gguf(req.repo, req.quant)
|
|
if not file:
|
|
raise HTTPException(404, "Keine GGUF-Datei im Repo gefunden.")
|
|
mmproj = _pick_mmproj(req.repo)
|
|
ram_gb = psutil.virtual_memory().total / (1024 ** 3)
|
|
env = dict(HF_DOWNLOAD_ENV)
|
|
if req.hf_token:
|
|
env["HF_TOKEN"] = req.hf_token
|
|
target = MODELS_DIR / req.repo.split("/")[-1]
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
args = [hf_bin(), "download", req.repo, file]
|
|
if mmproj:
|
|
args.append(mmproj)
|
|
args += ["--local-dir", str(target)]
|
|
jid = start_job(args, f"download {req.repo.split('/')[-1]}", env=env)
|
|
JOBS[jid]["result_path"] = str(target / file)
|
|
attach_download_progress(jid, str(target), hf_file_size(req.repo, file))
|
|
cfg = read_config()
|
|
ctx = req.ctx if req.ctx else max_ctx_for(req.params_b, req.quant, ram_gb)
|
|
path = str(target / file)
|
|
cmd = CMD_TEMPLATE.replace("{model}", path).replace("{ctx}", str(ctx))
|
|
if mmproj:
|
|
cmd = cmd.rstrip() + f" --mmproj {target / mmproj} --jinja"
|
|
mid = model_id_from_path(path)
|
|
cfg["models"][mid] = {"cmd": LiteralScalarString(cmd + "\n"), "ttl": DEFAULT_TTL}
|
|
set_role_alias(cfg, mid, req.role)
|
|
write_config(cfg)
|
|
return {"job_id": jid, "model_id": mid}
|
|
|