diff --git a/backend/routers/gateway_proxy.py b/backend/routers/gateway_proxy.py index 059de70..1183d82 100644 --- a/backend/routers/gateway_proxy.py +++ b/backend/routers/gateway_proxy.py @@ -1,219 +1,225 @@ -import os - -from fastapi import APIRouter, Request -from fastapi.responses import JSONResponse, StreamingResponse - -from config import LLAMA_SWAP_URL -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.routing_policy import load_policy - -router = APIRouter(prefix="/v1") - -# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch -# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab. -_LANG_DIRECTIVE = os.environ.get( - "MC_GATEWAY_LANG_DIRECTIVE", - "Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, " - "Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. " - "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") - - -# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von -# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter. -_BILD_BESCHREIB_PROMPT = os.environ.get( - "MC_CODER_IMAGE_PROMPT", - "Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, " - "Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. " - "Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.") - - -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() - - -async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool: - """Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell. - - 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)) - 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}]}], - } - try: - # Grosszuegiger Timeout: VL-30B ist on-demand (Kaltladen ~30 s) + Beschreibung ~25 s. - r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage, timeout=240.0) - text = "" - if r.status_code == 200: - text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or "" - if not text.strip(): - return False - except Exception: - 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()}]")} - return True - - -def _inject_language(body: dict, alias: str) -> None: - """Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates - erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys - Persona (SOUL.md) regelt die Sprache selbst.""" - if not _LANG_DIRECTIVE or alias == "hermes": - return - msgs = body.get("messages") - if not isinstance(msgs, list): - return - first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None) - if first_sys is None: - msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE}) - elif isinstance(first_sys.get("content"), str): - first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE - elif isinstance(first_sys.get("content"), list): - first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) - -@router.get("/models") -async def models(request: Request): - client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) - r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) - data = r.json() - # Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell" - # angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand - # mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste. - # Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie - # budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende - # max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.). - # Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf, - # Clients fragen aber genau damit an → als eigene Einträge einblenden. - ctx_map: dict[str, int] = {} - alias_entries: list[dict] = [] - aliased_raw: set[str] = set() # rohe Modellnamen, die ein Klarnamen-Alias schon zeigt - try: - from services import llamaswap - for m in llamaswap.list_models(): - ctx = m.get("ctx") - # --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die - # Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot: - # Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene - # Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens). - slots = m.get("parallel_slots") or 1 - if ctx and slots > 1: - ctx = ctx // slots - if ctx: - for api_id in m.get("api_ids", []): - ctx_map[api_id] = ctx - aliases = m.get("aliases", []) - if aliases: - aliased_raw.add(m["name"]) - for alias in aliases: - entry = {"id": alias, "object": "model", "owned_by": "mc2-alias", - "description": f"Alias für {m['name']}"} - if ctx: - entry["context_length"] = ctx - alias_entries.append(entry) - except Exception: - pass - if isinstance(data, dict) and isinstance(data.get("data"), list): - for entry in data["data"]: - if (ctx := ctx_map.get(entry.get("id"))): - entry.setdefault("context_length", ctx) - # Rohe Doppel-IDs ausblenden, wenn ein Klarnamen-Alias sie schon zeigt → in der - # Liste erscheinen nur die freundlichen Rollen-Namen. Routing per roher ID bleibt. - raw = [e for e in data["data"] if e.get("id") not in aliased_raw] - data["data"] = alias_entries + raw - return JSONResponse(data, status_code=r.status_code) - - -async def _proxy(path: str, request: Request): - body = await request.json() - requested = str(body.get("model") or "auto") - if requested.lower() in ("auto", "chat", "coding"): - lane = requested.lower() - alias, reason = choose_for_lane(lane, body) - body["model"] = alias - routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane} - else: - alias = requested - routed = {"x-mc-routed-to": requested} - - 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). - 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 - 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: - body["chat_template_kwargs"] = {"enable_thinking": False} - _inject_language(body, alias) - url = f"{LLAMA_SWAP_URL}{path}" - if body.get("stream"): - req = client.build_request("POST", url, json=body, timeout=None) - r = await client.send(req, stream=True) - if r.status_code != 200: - await r.aread() - try: - resp_json = r.json() - except Exception: - resp_json = {"error": {"message": r.text, "type": "upstream_error"}} - return JSONResponse(resp_json, status_code=r.status_code, headers=routed) - - async def gen(): - try: - async for chunk in r.aiter_raw(): - record_stream_chunk(chunk, alias) - yield chunk - finally: - await r.aclose() - return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) - - r = await client.post(url, json=body, timeout=600.0) - resp_json = r.json() - if isinstance(resp_json, dict): - record_usage(resp_json.get("usage"), alias) - if any(isinstance(c, dict) and c.get("finish_reason") == "length" - for c in resp_json.get("choices") or []): - warn_truncation(alias) - return JSONResponse(resp_json, status_code=r.status_code, headers=routed) - - -@router.post("/chat/completions") -async def chat_completions(request: Request): - return await _proxy("/v1/chat/completions", request) - - -@router.post("/completions") -async def completions(request: Request): - return await _proxy("/v1/completions", request) +import logging +import os + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from config import LLAMA_SWAP_URL +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.routing_policy import load_policy + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/v1") + +# Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch +# (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab. +_LANG_DIRECTIVE = os.environ.get( + "MC_GATEWAY_LANG_DIRECTIVE", + "Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, " + "Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. " + "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") + + +# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von +# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter. +_BILD_BESCHREIB_PROMPT = os.environ.get( + "MC_CODER_IMAGE_PROMPT", + "Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, " + "Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. " + "Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.") + + +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() + + +async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool: + """Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell. + + 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)) + 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}]}], + } + try: + # Grosszuegiger Timeout: VL-30B ist on-demand (Kaltladen ~30 s) + Beschreibung ~25 s. + r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage, timeout=240.0) + text = "" + if r.status_code == 200: + text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or "" + if not text.strip(): + log.warning("Bild-Weiche v2: Beschreibung leer/fehlgeschlagen (HTTP %s) — Fallback Umleitung. Body: %.200s", + r.status_code, r.text) + return False + except Exception: + log.warning("Bild-Weiche v2: Vision-Aufruf scheiterte — Fallback Umleitung", exc_info=True) + 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()}]")} + return True + + +def _inject_language(body: dict, alias: str) -> None: + """Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates + erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys + Persona (SOUL.md) regelt die Sprache selbst.""" + if not _LANG_DIRECTIVE or alias == "hermes": + return + msgs = body.get("messages") + if not isinstance(msgs, list): + return + first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None) + if first_sys is None: + msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE}) + elif isinstance(first_sys.get("content"), str): + first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE + elif isinstance(first_sys.get("content"), list): + first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) + +@router.get("/models") +async def models(request: Request): + client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) + r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) + data = r.json() + # Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell" + # angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand + # mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste. + # Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie + # budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende + # max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.). + # Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf, + # Clients fragen aber genau damit an → als eigene Einträge einblenden. + ctx_map: dict[str, int] = {} + alias_entries: list[dict] = [] + aliased_raw: set[str] = set() # rohe Modellnamen, die ein Klarnamen-Alias schon zeigt + try: + from services import llamaswap + for m in llamaswap.list_models(): + ctx = m.get("ctx") + # --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die + # Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot: + # Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene + # Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens). + slots = m.get("parallel_slots") or 1 + if ctx and slots > 1: + ctx = ctx // slots + if ctx: + for api_id in m.get("api_ids", []): + ctx_map[api_id] = ctx + aliases = m.get("aliases", []) + if aliases: + aliased_raw.add(m["name"]) + for alias in aliases: + entry = {"id": alias, "object": "model", "owned_by": "mc2-alias", + "description": f"Alias für {m['name']}"} + if ctx: + entry["context_length"] = ctx + alias_entries.append(entry) + except Exception: + pass + if isinstance(data, dict) and isinstance(data.get("data"), list): + for entry in data["data"]: + if (ctx := ctx_map.get(entry.get("id"))): + entry.setdefault("context_length", ctx) + # Rohe Doppel-IDs ausblenden, wenn ein Klarnamen-Alias sie schon zeigt → in der + # Liste erscheinen nur die freundlichen Rollen-Namen. Routing per roher ID bleibt. + raw = [e for e in data["data"] if e.get("id") not in aliased_raw] + data["data"] = alias_entries + raw + return JSONResponse(data, status_code=r.status_code) + + +async def _proxy(path: str, request: Request): + body = await request.json() + requested = str(body.get("model") or "auto") + if requested.lower() in ("auto", "chat", "coding"): + lane = requested.lower() + alias, reason = choose_for_lane(lane, body) + body["model"] = alias + routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane} + else: + alias = requested + routed = {"x-mc-routed-to": requested} + + 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). + 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 + 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: + body["chat_template_kwargs"] = {"enable_thinking": False} + _inject_language(body, alias) + url = f"{LLAMA_SWAP_URL}{path}" + if body.get("stream"): + req = client.build_request("POST", url, json=body, timeout=None) + r = await client.send(req, stream=True) + if r.status_code != 200: + await r.aread() + try: + resp_json = r.json() + except Exception: + resp_json = {"error": {"message": r.text, "type": "upstream_error"}} + return JSONResponse(resp_json, status_code=r.status_code, headers=routed) + + async def gen(): + try: + async for chunk in r.aiter_raw(): + record_stream_chunk(chunk, alias) + yield chunk + finally: + await r.aclose() + return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) + + r = await client.post(url, json=body, timeout=600.0) + resp_json = r.json() + if isinstance(resp_json, dict): + record_usage(resp_json.get("usage"), alias) + if any(isinstance(c, dict) and c.get("finish_reason") == "length" + for c in resp_json.get("choices") or []): + warn_truncation(alias) + return JSONResponse(resp_json, status_code=r.status_code, headers=routed) + + +@router.post("/chat/completions") +async def chat_completions(request: Request): + return await _proxy("/v1/chat/completions", request) + + +@router.post("/completions") +async def completions(request: Request): + return await _proxy("/v1/completions", request)