bilder: Hirn und Coder sehen selbst - ueber Bild-Zwillinge, Text bleibt mit Draft schnell
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>
This commit is contained in:
Hitonabi
2026-09-24 13:02:06 +02:00
co-authored by Claude Opus 5.5
parent 2cc1e1cc47
commit 91aa16eee1
17 changed files with 497 additions and 230 deletions
+89 -71
View File
@@ -1,12 +1,15 @@
import hashlib
import json
import logging
import os
from collections import OrderedDict
import httpx
from config import LLAMA_SWAP_URL
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse
from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image
from services.router_logic import VISION_CAPABLE, bild_ziel, bilder_aufteilen, choose_for_lane, has_image
from services.routing_policy import load_policy
log = logging.getLogger(__name__)
@@ -22,15 +25,15 @@ _LANG_DIRECTIVE = os.environ.get(
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
# Deckel für die Bild-Weiche. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
# Deckel für die Bild-Beschreibungen. Ohne ihn wächst die Wartezeit linear mit der Bildzahl:
# je Bild bis zu 2 Versuche à _BILD_TIMEOUT_S, und der Client hängt so lange am offenen
# Request. Über _BILD_MAX Bilder wird gar nicht erst beschrieben — der Aufrufer fällt
# dann auf die normale Vision-Umleitung zurück (ehrlich langsam statt scheinbar hängend).
# Request. Sind mehr als _BILD_MAX Bilder NEU zu beschreiben, bleiben alle Bilder drin und die
# Anfrage geht an den Bild-Zwilling (ehrlich langsam statt scheinbar hängend).
_BILD_TIMEOUT_S = float(os.environ.get("MC_CODER_IMAGE_TIMEOUT_S", "240"))
_BILD_MAX = int(os.environ.get("MC_CODER_IMAGE_MAX", "4"))
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
# Beschreibung älterer Bilder: Prompt bewusst auf wörtliche Wiedergabe von Code/Fehlermeldungen
# getrimmt — das schnelle Modell arbeitet in den Folgeschritten nur noch mit diesem Text.
_BILD_BESCHREIB_PROMPT = os.environ.get(
"MC_CODER_IMAGE_PROMPT",
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
@@ -70,61 +73,74 @@ def _apply_no_think_marker(body: dict) -> None:
body["chat_template_kwargs"] = {"enable_thinking": False}
def _ist_coder_alias(alias: str) -> bool:
"""Coder-Ziele (coder, rohe Qwen-Coder-IDs) bekommen Bild-BESCHREIBUNGEN statt der
stumpfen Vision-Umleitung — die Code-Frage bleibt beim Spezialisten."""
return "coder" in (alias or "").lower()
# Bild-Weiche v3 (24.09.2026). llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500
# „failed to process speculative batch“, b11057 und b11157 geprüft). Darum haben Hirn und Coder je einen
# Bild-Zwilling: dieselben Gewichte mit Bild-Projektor, ohne Draft (vision, coder-bild).
# • Bild im aktuellen Schritt → der Zwilling der Rolle sieht es selbst, auch mitten in einer Agenten-Aufgabe.
# • Bild aus früheren Schritten → einmal von vision beschrieben (je Bild gemerkt) und als Text mitgeschickt,
# damit der Rest der Aufgabe wieder beim schnellen Modell mit Draft läuft.
_BESCHREIBUNGEN: OrderedDict[str, str] = OrderedDict()
_BESCHREIBUNGEN_MAX = 64
async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
"""Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
def _bild_schluessel(part: dict) -> str:
return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest()
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
(gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
Vision-Umleitung als Fallback.
"""
fundstellen: list[tuple[dict, int, dict]] = []
for m in body.get("messages") or []:
if not isinstance(m, dict) or not isinstance(m.get("content"), list):
continue
for i, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
fundstellen.append((m, i, part))
if len(fundstellen) > _BILD_MAX:
log.warning("Bild-Weiche v2: %s Bilder (Deckel %s) — Request geht an das Vision-Modell "
"statt einzeln beschrieben zu werden", len(fundstellen), _BILD_MAX)
async def _beschreibe(part: dict, vision_alias: str) -> str:
"""Beschreibung eines Bild-Parts; Hermes und OpenCode schicken den ganzen Verlauf mit jedem Schritt
neu, darum wird jedes Bild nur einmal beschrieben."""
schluessel = _bild_schluessel(part)
if schluessel in _BESCHREIBUNGEN:
_BESCHREIBUNGEN.move_to_end(schluessel)
return _BESCHREIBUNGEN[schluessel]
frage = {
"model": vision_alias,
"stream": False,
"max_tokens": 700,
# Denken aus: sonst frisst die Denkphase das Token-Budget und die Beschreibung bleibt leer.
"chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "user", "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
}
text = ""
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach Sekunden trotz
# timeout=240 — llama-swap kappt beim Modell-Swap gern alte Keep-Alive-Sockets) + 1 Retry.
for versuch in (1, 2):
try:
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if text.strip():
break
log.warning("Bild-Weiche (Versuch %s): Beschreibung leer/HTTP %s%.200s",
versuch, r.status_code, r.text)
except Exception:
log.warning("Bild-Weiche (Versuch %s): Beschreibung scheiterte", versuch, exc_info=True)
text = text.strip()
if text:
_BESCHREIBUNGEN[schluessel] = text
while len(_BESCHREIBUNGEN) > _BESCHREIBUNGEN_MAX:
_BESCHREIBUNGEN.popitem(last=False)
return text
async def _alte_bilder_beschreiben(alt: list[tuple[dict, int]], vision_alias: str) -> bool:
"""Bilder aus früheren Schritten durch ihre Beschreibung ersetzen. False = ging nicht (zu viele neue
oder eine Beschreibung scheiterte) — dann bleiben alle Bilder drin."""
neu = {_bild_schluessel(msg["content"][idx]) for msg, idx in alt} - set(_BESCHREIBUNGEN)
if len(neu) > _BILD_MAX:
log.warning("Bild-Weiche: %s ältere Bilder neu zu beschreiben (Deckel %s) — Anfrage geht mit allen "
"Bildern an den Bild-Zwilling", len(neu), _BILD_MAX)
return False
for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
frage = {
"model": vision_alias,
"stream": False,
"max_tokens": 700,
"messages": [{"role": "user",
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
}
# Eigener Kurzzeit-Client statt des gepoolten (Befund 15.07.: ReadTimeout nach
# Sekunden trotz timeout=240 — llama-swap kappt beim Modell-Swap gern alte
# Keep-Alive-Sockets, der Pool reicht sie trotzdem wieder aus) + 1 Retry.
text = ""
for versuch in (1, 2):
try:
async with httpx.AsyncClient(timeout=_BILD_TIMEOUT_S) as c:
r = await c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage)
if r.status_code == 200:
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
if text.strip():
break
log.warning("Bild-Weiche v2 (Versuch %s): Beschreibung leer/HTTP %s%.200s",
versuch, r.status_code, r.text)
except Exception:
log.warning("Bild-Weiche v2 (Versuch %s): Vision-Aufruf scheiterte", versuch, exc_info=True)
if not text.strip():
texte = []
for msg, idx in alt:
text = await _beschreibe(msg["content"][idx], vision_alias)
if not text:
return False
msg["content"][idx] = {"type": "text", "text": (
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
f"{text.strip()}]")}
texte.append((msg, idx, text))
for msg, idx, text in texte:
msg["content"][idx] = {"type": "text", "text": f"[Bild aus einem früheren Schritt — so sah es aus:\n{text}]"}
return True
@@ -233,25 +249,27 @@ async def _proxy(path: str, request: Request):
pol = load_policy()
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
# sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder
# so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
# Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
# Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
angefragt = alias
# Bild-Weiche v3 (siehe oben): Bild im aktuellen Schritt → Bild-Zwilling der Rolle; ältere Bilder
# Beschreibung als Text, die Anfrage bleibt beim schnellen Modell.
vision_alias = pol.get("vision")
if vision_alias and alias not in VISION_CAPABLE and has_image(body):
if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
routed = {"x-mc-routed-to": alias,
"x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
"x-mc-lane": routed.get("x-mc-lane", "-")}
else:
alias = vision_alias
frisch, alt = bilder_aufteilen(body)
beschrieben = bool(alt) and await _alte_bilder_beschreiben(alt, vision_alias)
lane = routed.get("x-mc-lane", "-")
if frisch or not beschrieben:
alias = bild_ziel(alias, vision_alias, pol.get("coder_vision") or None)
body["model"] = alias
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell",
"x-mc-lane": routed.get("x-mc-lane", "-")}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild im aktuellen Schritt -> Bild-Zwilling",
"x-mc-lane": lane}
else:
routed = {"x-mc-routed-to": alias,
"x-mc-route-reason": "aeltere Bilder beschrieben -> bleibt beim schnellen Modell",
"x-mc-lane": lane}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt) — auch wenn die
# Anfrage wegen eines Bildes beim Hirn-Zwilling landet (gleiches Hirn, gleiche Spur).
if pol["fast_no_think"] and angefragt == pol["fast"] and "chat_template_kwargs" not in body:
body["chat_template_kwargs"] = {"enable_thinking": False}
_inject_language(body, alias)
url = f"{LLAMA_SWAP_URL}{path}"
+24 -3
View File
@@ -101,6 +101,7 @@ HF_BASIS = "https://huggingface.co"
ROLLEN = ("hirn", "coder")
ALIAS = {"hirn": "hermes", "coder": "coder"} # llama-swap-Alias der Rolle heute
ZWILLING = {"hirn": "vision", "coder": "coder-bild"} # Bild-Zwilling der Rolle (seit 24.09.)
DISCOVER_ROLLEN = {"coder": "coder", "fast": "hirn", "hermes": "hirn"}
PARALLEL = {"hirn": 2, "coder": 1} # Slots im Betrieb, wie heute
KANDIDAT_FELDER = ("id", "name", "rolle", "repo", "datei", "groesse_gb", "passt", "eng", "quelle", "status",
@@ -1068,14 +1069,34 @@ class _Nachtwaechter(threading.Thread):
self._halt.set()
def _bild_zwilling(rolle: str, heute: dict) -> dict | None:
"""Der Bild-Zwilling der Rolle: trägt ZWILLING[rolle], hat einen Projektor und dieselben Gewichte."""
pfad = heute.get("gguf_path")
try:
modelle = llamaswap.list_models()
except Exception:
return None
for m in modelle:
if (ZWILLING[rolle] in {str(a).lower() for a in m.get("aliases") or []}
and "--mmproj" in str(m.get("cmd") or "") and pfad and m.get("gguf_path") == pfad):
return m
return None
def _baseline(rolle: str, frist: float) -> dict | None:
"""Werte des heutigen Modells der Rolle (Cache, höchstens monatlich neu gemessen)."""
"""Werte des heutigen Modells der Rolle (Cache, höchstens monatlich neu gemessen). Die Bild-Probe
macht der Bild-Zwilling, wenn es einen gibt — so zählt „versteht Bilder“ nicht als Vorteil eines
Kandidaten, obwohl das heutige Modell über seinen Zwilling längst sieht."""
heute = _heutige_modelle().get(rolle)
if not heute:
return None
befehl = str(heute.get("cmd") or "")
kennung = f"{heute['name']}|{hashlib.sha1(befehl.encode()).hexdigest()[:10]}"
return _pruefstand().baseline(rolle, str(BASELINE_PATH), kennung=kennung, bild="--mmproj" in befehl,
zwilling = _bild_zwilling(rolle, heute)
zwilling_befehl = str((zwilling or {}).get("cmd") or "")
kennung = f"{heute['name']}|{hashlib.sha1((befehl + zwilling_befehl).encode()).hexdigest()[:10]}"
return _pruefstand().baseline(rolle, str(BASELINE_PATH), kennung=kennung,
bild="--mmproj" in befehl or zwilling is not None,
bild_modell=ZWILLING[rolle] if zwilling else None,
frist=frist, max_alter_tage=BASELINE_TAGE, modell=ALIAS[rolle],
protokoll=_protokoll)
+34 -1
View File
@@ -51,10 +51,43 @@ _CODE_HINT = re.compile(
# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet.
VISION_CAPABLE = {"vision", "scout"}
# Seit 24.09.2026 sind vision und coder-bild die Bild-Zwillinge von Hirn und Coder (gleiche Gewichte
# plus Bild-Projektor, ohne Draft — llama.cpp kann Draft und Bild nicht zusammen, HTTP 500).
VISION_CAPABLE = {"vision", "coder-bild", "scout"}
IMAGE_PART_TYPES = {"image_url", "input_image", "image"}
def ist_coder_alias(alias: str) -> bool:
"""Coder-Ziele: coder, heavy (derselbe Coder) und rohe Coder-IDs."""
a = (alias or "").lower()
return "coder" in a or a == "heavy"
def bilder_aufteilen(body: dict) -> tuple[list[tuple[dict, int]], list[tuple[dict, int]]]:
"""Bild-Parts in (frisch, alt) teilen. Frisch = nach der letzten Antwort des Modells (der Schritt,
um den es gerade geht: neue Nutzer-Nachricht oder Werkzeug-Ergebnis). Alt = davor — das Modell hat
sie schon gesehen und seine Schlüsse im Verlauf. Jeder Eintrag ist (Nachricht, Index im content)."""
msgs = [m for m in body.get("messages") or [] if isinstance(m, dict)]
letzte_antwort = max((i for i, m in enumerate(msgs) if m.get("role") == "assistant"), default=-1)
frisch: list[tuple[dict, int]] = []
alt: list[tuple[dict, int]] = []
for i, m in enumerate(msgs):
if not isinstance(m.get("content"), list):
continue
for j, part in enumerate(m["content"]):
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
(frisch if i > letzte_antwort else alt).append((m, j))
return frisch, alt
def bild_ziel(alias: str, vision_alias: str, coder_vision_alias: str | None) -> str:
"""Bild-Zwilling für eine Anfrage mit frischem Bild: Coder-Ziele an den Coder-Zwilling (falls es ihn
gibt), alles andere an den Hirn-Zwilling (vision)."""
if coder_vision_alias and ist_coder_alias(alias):
return coder_vision_alias
return vision_alias
def has_image(body: dict) -> bool:
"""True, wenn irgendeine Nachricht einen Bild-Part enthaelt (OpenAI-multimodaler
content: eine Liste mit einem {"type": "image_url"|"input_image"|"image", ...}-Teil)."""
+135 -131
View File
@@ -1,131 +1,135 @@
"""
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}
"""
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}
+100
View File
@@ -0,0 +1,100 @@
"""Bild-Weiche v3 (24.09.2026): frische Bilder an den Bild-Zwilling, ältere als Beschreibung."""
import asyncio
import copy
from typing import ClassVar
from routers import gateway_proxy
from services.router_logic import bild_ziel, bilder_aufteilen, ist_coder_alias
BILD_A = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}
BILD_B = {"type": "image_url", "image_url": {"url": "data:image/png;base64,BBBB"}}
def verlauf() -> dict:
"""Agenten-Ablauf: Bild A im ersten Schritt, danach Werkzeug-Aufruf, jetzt Bild B als Ergebnis."""
return {"model": "coder", "messages": [
{"role": "system", "content": "Du bist ein Coding-Agent."},
{"role": "user", "content": [BILD_A, {"type": "text", "text": "Was zeigt der Fehler?"}]},
{"role": "assistant", "content": "", "tool_calls": [
{"id": "c1", "type": "function", "function": {"name": "screenshot", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "Bildschirmfoto folgt."},
{"role": "user", "content": [BILD_B, {"type": "text", "text": "Bildschirmfoto aus screenshot."}]},
]}
def test_frisch_ist_was_nach_der_letzten_antwort_kommt():
frisch, alt = bilder_aufteilen(verlauf())
assert [m["content"][i] for m, i in frisch] == [BILD_B]
assert [m["content"][i] for m, i in alt] == [BILD_A]
def test_ohne_antwort_sind_alle_bilder_frisch():
frisch, alt = bilder_aufteilen({"messages": [{"role": "user", "content": [BILD_A, BILD_B]}]})
assert len(frisch) == 2 and alt == []
def test_coder_ziele_gehen_an_den_coder_zwilling_alles_andere_an_vision():
assert ist_coder_alias("coder") and ist_coder_alias("heavy") and not ist_coder_alias("fast")
assert bild_ziel("coder", "vision", "coder-bild") == "coder-bild"
assert bild_ziel("heavy", "vision", "coder-bild") == "coder-bild"
assert bild_ziel("fast", "vision", "coder-bild") == "vision"
assert bild_ziel("coder", "vision", None) == "vision" # ohne Coder-Zwilling: Hirn-Zwilling
class _FalscherClient:
"""Ersetzt httpx.AsyncClient: zählt Beschreibungs-Anfragen und antwortet mit fester Beschreibung."""
anfragen: ClassVar[list] = []
def __init__(self, *a, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def post(self, url, json):
_FalscherClient.anfragen.append(json)
class Antwort:
status_code = 200
text = ""
@staticmethod
def json():
return {"choices": [{"message": {"content": "Fehlermeldung: PORT 4711 belegt"}}]}
return Antwort()
def test_aeltere_bilder_werden_einmal_beschrieben_und_gemerkt(monkeypatch):
monkeypatch.setattr(gateway_proxy.httpx, "AsyncClient", _FalscherClient)
monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict())
_FalscherClient.anfragen = []
body = verlauf()
_, alt = bilder_aufteilen(body)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True
ersetzt = body["messages"][1]["content"][0]
assert ersetzt["type"] == "text" and "PORT 4711" in ersetzt["text"]
assert body["messages"][4]["content"][0] == BILD_B # das frische Bild bleibt ein Bild
anfrage = _FalscherClient.anfragen[0]
assert anfrage["model"] == "vision" and anfrage["chat_template_kwargs"] == {"enable_thinking": False}
# Nächster Schritt schickt denselben Verlauf erneut: aus dem Gedächtnis, keine zweite Anfrage.
nochmal = verlauf()
_, alt = bilder_aufteilen(nochmal)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is True
assert len(_FalscherClient.anfragen) == 1
def test_zu_viele_neue_bilder_bleiben_drin(monkeypatch):
monkeypatch.setattr(gateway_proxy, "_BESCHREIBUNGEN", gateway_proxy.OrderedDict())
monkeypatch.setattr(gateway_proxy, "_BILD_MAX", 1)
body = {"messages": [{"role": "user", "content": [BILD_A, BILD_B]}, {"role": "assistant", "content": "ok"},
{"role": "user", "content": "und jetzt?"}]}
vorher = copy.deepcopy(body)
_, alt = bilder_aufteilen(body)
assert asyncio.run(gateway_proxy._alte_bilder_beschreiben(alt, "vision")) is False
assert body == vorher
+20
View File
@@ -634,3 +634,23 @@ def test_baseline_misst_neu_wenn_sich_die_proben_geaendert_haben(ps, tmp_path, m
def test_kein_ablenker_klingt_nach_plattenplatz(ps):
namen = {w["function"]["name"] for w in ps.WERKZEUGE_HIRN}
assert "system_speicher" not in namen and "system_arbeitsspeicher" in namen
def test_baseline_misst_bilder_ueber_den_zwilling(monkeypatch, ps):
"""Seit 24.09. sieht das heutige Hirn über seinen Bild-Zwilling (vision) — die Baseline muss das wissen."""
hirn = {"name": "Qwen3.6-35B-A3B", "aliases": ["hermes", "fast"], "gguf_path": "/m/hirn.gguf",
"cmd": "llama-server -m /m/hirn.gguf --spec-type draft-dflash"}
zwilling = {"name": "Qwen3.6-35B-A3B-Bild", "aliases": ["vision"], "gguf_path": "/m/hirn.gguf",
"cmd": "llama-server -m /m/hirn.gguf --mmproj /m/mmproj.gguf"}
fremd = {"name": "Qwen3-VL", "aliases": [], "gguf_path": "/m/vl.gguf", "cmd": "llama-server -m /m/vl.gguf --mmproj x"}
monkeypatch.setattr(radar.llamaswap, "list_models", lambda: [hirn, zwilling, fremd])
aufrufe = []
monkeypatch.setattr(ps, "baseline", lambda rolle, pfad, **kw: aufrufe.append(kw) or {"werte": {}})
radar._baseline("hirn", time.time() + 60)
assert aufrufe[0]["bild"] is True and aufrufe[0]["bild_modell"] == "vision"
# Ohne Zwilling (Projektor auf anderen Gewichten zählt nicht): keine Bild-Probe.
monkeypatch.setattr(radar.llamaswap, "list_models", lambda: [hirn, fremd])
radar._baseline("hirn", time.time() + 60)
assert aufrufe[1]["bild"] is False and aufrufe[1]["bild_modell"] is None
assert aufrufe[0]["kennung"] != aufrufe[1]["kennung"] # neuer Zwilling → neue Baseline