""" Lane-Routing für den eingebauten MC2-Gateway (:9001/v1). Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell: - **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`. - **coding**: Code-Arbeit → `coder`; riesiger/architektonischer Kontext → `heavy`; triviale Kurzfrage ohne Code → `fast` (Tempo). Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases liegen in einer UI-editierbaren Policy (routing_policy.py, hot-reload; Env = Defaults). Lucy läuft NICHT hierüber — die ist der Hermes-Agent (:8642), eigene Ebene. """ import re from services.routing_policy import load_policy # Modell-Aliases & Zeichen-Schwellen liegen jetzt in der UI-editierbaren Policy # (routing_policy.py) und kommen pro Request via load_policy() (hot-reload). Die Env-Vars # sind dort die Defaults. Die Regex-Keyword-Listen unten bleiben bewusst im Code. LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat _HEAVY_KW = re.compile( r"\b(beweis|prove|theorem|komplex|complex|schwierig|" r"think\s*hard|reason\s*carefully|tief\s*nachdenk|optimi[sz]e|" r"root\s*cause|analy[sz]e\s+deeply|step[-\s]?by[-\s]?step)\b", re.IGNORECASE, ) # Coding-spezifische „das ist groß/architektonisch" Signale → heavy statt coder. _CODING_HEAVY_KW = re.compile( r"\b(architekt|architect|system[-\s]?design|refactor\s+the\s+(whole|entire)|" r"ganze[ns]?\s+(architektur|codebase|projekt)|migrat\w+\s+(the\s+)?(whole|entire|gesamte)|" r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b", re.IGNORECASE, ) # Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet. # 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).""" for m in body.get("messages") or []: if not isinstance(m, dict): continue content = m.get("content") if isinstance(content, list): for part in content: if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES: return True return False def _text_of(body: dict) -> str: msgs = body.get("messages") or [] # Multimodaler content ist eine Liste — nur die Text-Parts fuers Komplexitaets-Routing # zusammenziehen (ein dict/Liste als str() wuerde die Zeichen-Schwelle verfaelschen). out = [] for m in msgs: if not isinstance(m, dict): continue c = m.get("content") if isinstance(c, str): out.append(c) elif isinstance(c, list): for part in c: if isinstance(part, dict) and part.get("type") == "text": out.append(str(part.get("text") or "")) return "\n".join(out) def _route_chat(text: str, n: int) -> tuple[str, str]: p = load_policy() if n > p["heavy_chars"]: return p["heavy"], f"langer Kontext ({n} > {p['heavy_chars']} Zeichen)" if _HEAVY_KW.search(text): return p["heavy"], "Komplexitäts-Schlüsselwort erkannt" return p["fast"], "Standard" def _route_coding(text: str, n: int) -> tuple[str, str]: # Agentisches Coden (OpenCode/RooCode/…) bleibt IMMER beim dedizierten Coder — NIE heavy/fast # (das sind Allzweck-Modelle, schwächer bei Code). Die Qwen-Coder packen 256K–1M Kontext selbst, # langer Repo-Kontext ist bei Agenten der Normalfall und darf NICHT zu heavy umrouten. # (Phase 2b: warme schnelle Coder-Stufe coder_lite als Default + coder als Eskalation.) p = load_policy() if p["coder_lite"] and not _CODING_HEAVY_KW.search(text) and n <= p["coding_escalate_chars"]: return p["coder_lite"], "Coding (schneller Coder)" return p["coder"], "Coding -> starker Coder" def choose_for_lane(lane: str, body: dict) -> tuple[str, str]: """Wählt das echte Modell-Alias für eine Lane. Gibt (alias, begründung) zurück.""" lane = LANE_ALIASES.get((lane or "chat").lower(), (lane or "chat").lower()) text = _text_of(body) n = len(text) if lane == "coding": return _route_coding(text, n) return _route_chat(text, n) # chat + alles Unbekannte