From 5d02e7af91f31edfa4c6f1b1232046f28e8ed845 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Mon, 13 Jul 2026 22:42:08 +0200 Subject: [PATCH] Faden 11: Gateway-Bild-Weiche - Requests mit Bild automatisch an VL-30B Entscheid Weg A (vision-harness-verdikt): das MTP-Hirn (fast/hermes) bleibt schnell und bildunfaehig; Bild-Requests routet das MC2-Gateway automatisch ans Vision-Modell - kein manueller Modellwechsel, Lucys Flow bleibt. router_logic.py: has_image(body) erkennt OpenAI-multimodalen content (image_url/input_image/image); _text_of zieht jetzt nur Text-Parts fuer das Komplexitaets-Routing (str() einer content-Liste haette die Zeichen-Schwelle verfaelscht). VISION_CAPABLE = {vision, scout}. routing_policy.py: neues UI-editierbares vision-Alias (Default env MC_ROUTE_VISION = "vision"; leer = Weiche aus), darf wie coder_lite leer sein. gateway_proxy.py _proxy: nach der Alias-Wahl - wenn ein Bild dabei ist und das Ziel nicht bildfaehig - Override auf das vision-Alias (auch bei explizitem model=hermes; genau Lucys Fall). Header x-mc-route-reason. Verifiziert (Unit): Bild+hermes->vision, Bild+auto->vision, Text->fast, Bild+scout->scout (schon bildfaehig), has_image korrekt. Co-Authored-By: Claude Opus 4.8 --- backend/routers/gateway_proxy.py | 14 ++++++++++-- backend/services/router_logic.py | 34 +++++++++++++++++++++++++++++- backend/services/routing_policy.py | 6 +++++- 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/backend/routers/gateway_proxy.py b/backend/routers/gateway_proxy.py index 69ceeb7..e75a807 100644 --- a/backend/routers/gateway_proxy.py +++ b/backend/routers/gateway_proxy.py @@ -5,7 +5,7 @@ from fastapi.responses import JSONResponse, StreamingResponse from config import LLAMA_SWAP_URL from services.gateway_stream import record_stream_chunk, record_usage -from services.router_logic import choose_for_lane +from services.router_logic import VISION_CAPABLE, choose_for_lane, has_image from services.routing_policy import load_policy router = APIRouter(prefix="/v1") @@ -99,8 +99,18 @@ async def _proxy(path: str, request: Request): else: alias = requested routed = {"x-mc-routed-to": requested} - # fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt). + pol = load_policy() + # 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). + vision_alias = pol.get("vision") + if vision_alias and alias not in VISION_CAPABLE and has_image(body): + alias = vision_alias + body["model"] = alias + 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: body["chat_template_kwargs"] = {"enable_thinking": False} _inject_language(body, alias) diff --git a/backend/services/router_logic.py b/backend/services/router_logic.py index 90fa715..b07c522 100644 --- a/backend/services/router_logic.py +++ b/backend/services/router_logic.py @@ -50,9 +50,41 @@ _CODE_HINT = re.compile( ) +# Bild-Weiche (Faden 11): Aliase, die selbst Bilder koennen — die werden NIE umgeroutet. +VISION_CAPABLE = {"vision", "scout"} +_IMAGE_PART_TYPES = {"image_url", "input_image", "image"} + + +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 [] - return "\n".join(str(m.get("content") or "") for m in msgs) + # 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]: diff --git a/backend/services/routing_policy.py b/backend/services/routing_policy.py index 6137e23..4040636 100644 --- a/backend/services/routing_policy.py +++ b/backend/services/routing_policy.py @@ -30,6 +30,9 @@ DEFAULTS: dict = { "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"), @@ -41,6 +44,7 @@ FIELDS: list[dict] = [ {"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"}, @@ -77,7 +81,7 @@ def _coerce(patch: dict) -> dict: out[k] = bool(v) else: # str sv = str(v).strip() - if k != "coder_lite" and not sv: + if k not in ("coder_lite", "vision") and not sv: raise ValueError(f"{k} darf nicht leer sein") out[k] = sv return out