Ampel / ampel (push) Failing after 21s
llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500 "failed to process speculative batch", b11057 und b11157 geprueft; speculative.n_max=0 je Anfrage hilft nicht). Darum bekommen Hirn und Coder je einen Bild-Zwilling: gleiche Gewichte plus Projektor, ohne Draft (vision, coder-bild), in einer eigenen llama-swap-Gruppe, die den Coder nicht verdraengt. Probe 24.09.: beide 8/8 Bildmerkmale; Hirn-Zwilling 68 t/s, Coder-Zwilling 12,5 t/s. Bild-Weiche v3 im Gateway: Bild im aktuellen Schritt geht an den Zwilling der Rolle, aeltere Bilder werden einmal beschrieben (gemerkt) und als Text mitgeschickt, damit der Rest einer Agenten-Aufgabe wieder beim schnellen Modell laeuft. Qwen3-VL gibt "vision" ab, der Coder verliert den Projektor, der mit Draft nur HTTP 500 lieferte. Radar misst die Bildfaehigkeit des heutigen Modells ueber dessen Zwilling (sonst gewaenne jeder bildfaehige Kandidat mit "versteht Bilder"). Pruefstand: Coder darf vor dem Aendern lesen (Version 3). Modelle-Seite zeigt "Bilder: ja" ueber den Zwilling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
5.7 KiB
Python
136 lines
5.7 KiB
Python
"""
|
|
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"),
|
|
# Seit 24.09.2026: Bild-Zwilling des Coders — Coder-Anfragen mit neuem Bild gehen hierhin statt an vision.
|
|
# "" = auch Coder-Bilder gehen an vision.
|
|
"coder_vision": os.environ.get("MC_ROUTE_CODER_VISION", "coder-bild"),
|
|
"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": "coder_vision", "label": "Bild-Zwilling des Coders (leer = Coder-Bilder an vision)", "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", "coder_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}
|