Files
mission-control-v2/backend/routers/gateway_proxy.py
T
HitonabiandClaude Opus 5.5 91aa16eee1
Ampel / ampel (push) Failing after 21s
bilder: Hirn und Coder sehen selbst - ueber Bild-Zwillinge, Text bleibt mit Draft schnell
llama.cpp kann Draft-Beschleunigung und Bilder nicht zusammen (HTTP 500 "failed to process
speculative batch", b11057 und b11157 geprueft; speculative.n_max=0 je Anfrage hilft nicht).
Darum bekommen Hirn und Coder je einen Bild-Zwilling: gleiche Gewichte plus Projektor, ohne
Draft (vision, coder-bild), in einer eigenen llama-swap-Gruppe, die den Coder nicht verdraengt.
Probe 24.09.: beide 8/8 Bildmerkmale; Hirn-Zwilling 68 t/s, Coder-Zwilling 12,5 t/s.

Bild-Weiche v3 im Gateway: Bild im aktuellen Schritt geht an den Zwilling der Rolle, aeltere
Bilder werden einmal beschrieben (gemerkt) und als Text mitgeschickt, damit der Rest einer
Agenten-Aufgabe wieder beim schnellen Modell laeuft. Qwen3-VL gibt "vision" ab, der Coder
verliert den Projektor, der mit Draft nur HTTP 500 lieferte.

Radar misst die Bildfaehigkeit des heutigen Modells ueber dessen Zwilling (sonst gewaenne
jeder bildfaehige Kandidat mit "versteht Bilder"). Pruefstand: Coder darf vor dem Aendern
lesen (Version 3). Modelle-Seite zeigt "Bilder: ja" ueber den Zwilling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 13:02:06 +02:00

314 lines
16 KiB
Python

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})
@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)
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)
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)