Files
mission-control-v2/backend/routers/gateway_proxy.py
T
HitonabiandClaude Opus 5 84dc34c0a3 fix(stack): Mem0 restlos ausgebaut + vier stille Defekte behoben
Systemaudit vom 27.08.2026. Alle Befunde gemessen, nicht vermutet.

VIER STILLE DEFEKTE

1. mc2-steward startete seit Wochen nicht (live: 207.609 Neustarts).
   steward.py importierte services.memory, das beim Mem0-Ausbau geloescht
   wurde -> ImportError bei jedem Start. Re-Warm- und Health-Waechter
   waren damit tot.

2. Jedes Hermes-Update wurde automatisch zurueckgerollt.
   hermes-postcheck.sh prueft vier Dinge, die es seit dem 07.08. nicht mehr
   gibt (Sidecar :8765, /api/memory, memory.provider, mc2-memory-Plugin).
   Die Checks konnten nicht gruen werden -> autoupdate.sh wertete jedes
   Update als rot und rollte es zurueck. Checks ersatzlos entfernt; der
   Tool-Smoke laeuft ohnehin durch den echten Agenten.

3. 7 von 12 Skills waren per Knopfdruck nicht startbar.
   deploy.sh kopiert Skills mit tr '-' '_' nach ~/.hermes/skills/,
   routers/skills.py gab Hermes aber den Ordnernamen MIT Bindestrich.
   Der Knopf meldete Erfolg, ausgefuehrt wurde nichts. Neu: _hermes_name().

4. deploy.sh warf bei jedem Deploy die Live-Modellkonfiguration weg.
   MC2 schreibt /etc/llama-swap/config.yaml selbst; die Repo-Datei ist nur
   ein Abzug (ihm fehlt u.a. kritiker/Devstral). Jetzt: erst sichern, Diff
   zeigen, dann kopieren. MC_DEPLOY_SKIP_SWAP_CONFIG=1 ueberspringt.

MEM0-AUSBAU VOLLENDET (Kriterium 3: 17 -> 0 Dateien)
- mem0_service/, mcp/mcp_memory.py und hermes/plugins/mc2-memory entfernt;
  das Plugin schickte bei JEDEM Turn zwei 404-Requests an tote Routen.
- MEMORY_DB/MEM0_SERVICE_URL, _mem0_reachable(), MC_MEMORY_DB und
  MC_MEM_DEDUPE_ENABLED aus Config/Router/Unit entfernt.
- mem0_ms war strukturell tot (park("retrieve") wird nirgends mehr
  aufgerufen) -> aus Backend, API-Typ und Latenzkarte entfernt.
- Verbinden-Tab: tote Gedaechtnis-MCP-Leitung raus, Status-Kachel bleibt.
- AGENTS.md beschrieb Mem0 noch als aktiv - korrigiert.

GATEWAY-ROBUSTHEIT
- _proxy gab bei ungueltigen Payloads HTTP 500 (gemessen 5/5: Rohtext,
  leerer Body, JSON-Liste, JSON-String, null) -> jetzt 5/5 HTTP 400.
- Bild-Weiche ohne Deckel: 10 Bilder x 2 Versuche x 240 s hielten den
  Client bis zu 80 min. Neu: MC_CODER_IMAGE_MAX (4), Rueckfall auf die
  Vision-Umleitung.
- /v1/models: nicht-JSON von der Engine gab 500 -> jetzt 502.

UNITS UND DEPLOY
- mc2-steward.service, dessen warmset-Drop-in und voice-service.service
  fehlten im Repo, obwohl maintenance.py und stack-postcheck.sh sie
  voraussetzen. 1:1 von der laufenden Box uebernommen.
- deploy.sh startete mc2-steward nie neu; restore.sh liess mc2-gateway und
  mc2-steward mit alter Config weiterlaufen. Beide ergaenzt.

FRONTEND
- useEigenleben rief /api/eigenleben - existiert im Backend nicht und wurde
  nirgends genutzt. Samt Typen entfernt.
- Anleitung beschrieb einen Gedaechtnis-Tab, den es nicht gibt.
- Abgeglichen: alle uebrigen 63 Frontend-Aufrufe treffen echte Routen, alle
  5 SSE-Invalidation-Keys sind gemappt, keine ungefangenen Promises.

Gates: compileall gruen - ruff "All checks passed" - tsc gruen - vite build
gruen (dist aktualisiert) - Importe app/steward/gateway_app gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:58:42 +02:00

296 lines
15 KiB
Python

import logging
import os
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.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.")
# Deckel für die Bild-Weiche. 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).
_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.
_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.")
# Lucy-Voice-Schnellspur (16.07.): Lucys Sprach-Turns tragen diesen Marker im System-Prompt.
# Er schaltet das Qwen-Denken für GENAU diese Requests ab (gemessen direkt am Hirn: 9,9 s -> 0,8 s
# bis zur Antwort; reasoning_content 2500 Zeichen -> 0) — die „Hirn"-Latenz der Voice-Turns war
# fast vollständig Reasoning-Generierung VOR dem ersten sprechbaren Wort. Der Marker wird VOR dem
# Modell aus allen Messages entfernt (konstanter Text -> Prefix-Cache bleibt stabil). Requests ohne
# Marker (Crons, Telegram, Werkstatt) bleiben unangetastet. Leerer Env-Wert schaltet die Spur ab.
_NO_THINK_MARKER = os.environ.get("MC_NO_THINK_MARKER", "[[MC:NO_THINK]]")
def _apply_no_think_marker(body: dict) -> None:
"""Marker aus allen Message-Inhalten strippen; war er da, Thinking abschalten
(sofern der Client chat_template_kwargs nicht selbst gesetzt hat)."""
if not _NO_THINK_MARKER:
return
found = False
for m in body.get("messages") or []:
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str) and _NO_THINK_MARKER in c:
m["content"] = c.replace(_NO_THINK_MARKER, "").strip()
found = True
elif isinstance(c, list):
for part in c:
if (isinstance(part, dict) and isinstance(part.get("text"), str)
and _NO_THINK_MARKER in part["text"]):
part["text"] = part["text"].replace(_NO_THINK_MARKER, "").strip()
found = True
if found and "chat_template_kwargs" not in body:
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()
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))
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)
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():
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)
try:
data = r.json()
except ValueError:
# Engine antwortet, aber nicht mit JSON (Startphase/Fehlerseite) — ehrlicher 502
# statt eines 500ers aus dem Parser.
log.warning("/v1/models: Engine-Antwort ist kein JSON (HTTP %s): %.200s", r.status_code, r.text)
return JSONResponse(
{"error": {"message": "Engine lieferte keine gültige Modell-Liste.",
"type": "upstream_error"}}, status_code=502)
# 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):
# Ungültige Payloads gehören dem Client, nicht dem Server: ohne diese Prüfung wirft
# request.json() bzw. body.get(...) durch und der Aufrufer bekommt einen 500er
# (gemessen 27.08.2026: Rohtext, leerer Body, JSON-Liste, JSON-String und null — 5/5 mal 500).
try:
body = await request.json()
except Exception:
return JSONResponse(
{"error": {"message": "Ungültiger Request-Body: JSON erwartet.",
"type": "invalid_request_error"}}, status_code=400)
if not isinstance(body, dict):
return JSONResponse(
{"error": {"message": "Ungültiger Request-Body: JSON-Objekt erwartet, "
f"'{type(body).__name__}' bekommen.",
"type": "invalid_request_error"}}, status_code=400)
_apply_no_think_marker(body) # Lucy-Voice-Schnellspur: Marker strippen + Thinking aus
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, body.get("max_tokens"))
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, body.get("max_tokens"))
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)