""" UI-editierbare Routing-Policy für die Gateway-Lanes (coding/chat). Persistiert als JSON unter MC_ROUTING_POLICY_PATH (Default MODELS_DIR/mc2-routing.json — gleiche Konvention wie mc2-discover.json). **Hot-reload:** load_policy() liest die Datei nur bei Änderung neu (mtime-Cache) → UI-Edits greifen ohne Dienst-Neustart. Die Env-Vars (bisher einzige Stellschraube in router_logic.py) bleiben als Defaults/Fallback erhalten. Bewusst NICHT editierbar (v1): die Regex-Keyword-Listen (heavy/coding-heavy/code-hint) — die bleiben in router_logic.py im Code. """ import json import os import threading from pathlib import Path from config import MODELS_DIR POLICY_PATH = Path(os.environ.get("MC_ROUTING_POLICY_PATH", str(MODELS_DIR / "mc2-routing.json"))) def _env_bool(name: str, default: str) -> bool: return os.environ.get(name, default) not in ("0", "false", "") # Defaults aus den Env-Vars — Quelle der Wahrheit, solange keine Policy-Datei existiert. DEFAULTS: dict = { "fast": os.environ.get("MC_ROUTE_FAST", "fast"), "heavy": os.environ.get("MC_ROUTE_HEAVY", "heavy"), "coder": os.environ.get("MC_ROUTE_CODER", "coder"), "coder_lite": os.environ.get("MC_ROUTE_CODER_LITE", ""), # Bild-Weiche (Faden 11): Requests mit Bild-Anhang werden automatisch hierhin geroutet # (die MTP-Hirn-Config kann keine Bilder). "" schaltet die Weiche ab (Passthrough). "vision": os.environ.get("MC_ROUTE_VISION", "vision"), "heavy_chars": int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")), "coding_escalate_chars": int(os.environ.get("MC_CODING_ESCALATE_CHARS", "120000")), "fast_no_think": _env_bool("MC_FAST_NO_THINK", "1"), } # Feld-Spezifikation für die UI (Typ + Grenzen + Label). Treibt Editor & Validierung. FIELDS: list[dict] = [ {"key": "fast", "label": "fast-Alias (chat: Standard)", "type": "str"}, {"key": "heavy", "label": "heavy-Alias (chat: lang/komplex)", "type": "str"}, {"key": "coder", "label": "coder-Alias (coding: stark / Eskalation)", "type": "str"}, {"key": "coder_lite", "label": "coder-lite-Alias (coding: schneller Default; leer = aus)", "type": "str"}, {"key": "vision", "label": "vision-Alias (Bild-Weiche: Requests mit Bild; leer = aus)", "type": "str"}, {"key": "heavy_chars", "label": "chat → heavy ab N Zeichen", "type": "int", "min": 500, "max": 1_000_000}, {"key": "coding_escalate_chars", "label": "coding → starker Coder ab N Zeichen", "type": "int", "min": 1000, "max": 4_000_000}, {"key": "fast_no_think", "label": "fast-Spur: Thinking aus (flotte Antworten)", "type": "bool"}, ] _LOCK = threading.Lock() _CACHE: dict = {"mtime": None, "policy": None} def _read_file() -> dict: try: with open(POLICY_PATH, "r", encoding="utf-8") as f: data = json.load(f) return data if isinstance(data, dict) else {} except (FileNotFoundError, json.JSONDecodeError, OSError): return {} def _coerce(patch: dict) -> dict: """Nur bekannte Keys, typ-/bereichsvalidiert. Wirft ValueError bei ungültigen Werten.""" spec = {f["key"]: f for f in FIELDS} out: dict = {} for k, v in (patch or {}).items(): f = spec.get(k) if not f: continue # unbekannte Keys still verwerfen if f["type"] == "int": iv = int(v) lo, hi = f.get("min", 1), f.get("max", 10**9) if not (lo <= iv <= hi): raise ValueError(f"{k}={iv} außerhalb [{lo}, {hi}]") out[k] = iv elif f["type"] == "bool": out[k] = bool(v) else: # str sv = str(v).strip() if k not in ("coder_lite", "vision") and not sv: raise ValueError(f"{k} darf nicht leer sein") out[k] = sv return out def _coerce_safe(patch: dict) -> dict: """Wie _coerce, aber schluckt Fehler — kaputte Datei darf den Betrieb nicht stoppen.""" try: return _coerce(patch) except (ValueError, TypeError): return {} def load_policy() -> dict: """Aktuelle Policy (Datei über DEFAULTS gemerged). Hot-reload via mtime-Cache, pro Request billig.""" try: mtime = POLICY_PATH.stat().st_mtime except OSError: mtime = None with _LOCK: if _CACHE["policy"] is None or _CACHE["mtime"] != mtime: merged = {**DEFAULTS} if mtime is not None: merged.update(_coerce_safe(_read_file())) _CACHE["mtime"] = mtime _CACHE["policy"] = merged return dict(_CACHE["policy"]) def save_policy(patch: dict) -> dict: """Validiert + persistiert atomar. Gibt die neue, vollständige Policy zurück.""" clean = _coerce(patch) # wirft bei ungültigem Input with _LOCK: current = {**DEFAULTS, **_coerce_safe(_read_file()), **clean} POLICY_PATH.parent.mkdir(parents=True, exist_ok=True) tmp = POLICY_PATH.with_suffix(".json.tmp") with open(tmp, "w", encoding="utf-8") as f: json.dump(current, f, ensure_ascii=False, indent=2) os.replace(tmp, POLICY_PATH) _CACHE["mtime"] = None # nächster load_policy() lädt frisch _CACHE["policy"] = None return current def policy_meta() -> dict: """Für den UI-Editor: aktuelle Werte + Defaults (für „Zurücksetzen“) + Feld-Spezifikation.""" return {"policy": load_policy(), "defaults": dict(DEFAULTS), "fields": FIELDS}