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 VISION_CAPABLE, bild_ziel, bilder_aufteilen, 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-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. 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")) # 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, " "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} # 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 def _bild_schluessel(part: dict) -> str: return hashlib.sha1(json.dumps(part, sort_keys=True).encode("utf-8")).hexdigest() 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 texte = [] for msg, idx in alt: text = await _beschreibe(msg["content"][idx], vision_alias) if not text: return False 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 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}) def _upstream_fehler(text: str, status: int = 502, headers: dict | None = None) -> JSONResponse: """Fehler im OpenAI-Format statt eines nackten 500 (24.09.2026): Hermes, OpenChamber und Lucy können damit umgehen und zeigen den Grund an.""" return JSONResponse({"error": {"message": text, "type": "upstream_error"}}, status_code=status, headers=headers) @router.get("/models") async def models(request: Request): client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) try: r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) except httpx.HTTPError as exc: return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}") 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) 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): 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 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}" if body.get("stream"): req = client.build_request("POST", url, json=body, timeout=None) try: r = await client.send(req, stream=True) except httpx.HTTPError as exc: return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed) 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) try: r = await client.post(url, json=body, timeout=600.0) except httpx.HTTPError as exc: return _upstream_fehler(f"Engine nicht erreichbar: {exc.__class__.__name__}", headers=routed) try: resp_json = r.json() except ValueError: log.warning("%s: Engine-Antwort ist kein JSON (HTTP %s): %.200s", path, r.status_code, r.text) return _upstream_fehler(f"Engine lieferte keine gültige Antwort (HTTP {r.status_code}).", headers=routed) 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)