Bild-Weiche v2: stummen Fallback-except durch Logging ersetzen (Diagnose :9010)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-15 15:22:46 +02:00
parent 0eb2b37abc
commit 202e97333a
+225 -219
View File
@@ -1,219 +1,225 @@
import os import logging
import os
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse, StreamingResponse 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 config import LLAMA_SWAP_URL
from services.router_logic import IMAGE_PART_TYPES, VISION_CAPABLE, choose_for_lane, has_image from services.gateway_stream import record_stream_chunk, record_usage, warn_truncation
from services.routing_policy import load_policy 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")
log = logging.getLogger(__name__)
# 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. router = APIRouter(prefix="/v1")
_LANG_DIRECTIVE = os.environ.get(
"MC_GATEWAY_LANG_DIRECTIVE", # Antwortsprache für IDE-/Lane-Traffic: die Coding-Modelle antworten sonst englisch
"Antworte dem Nutzer grundsätzlich auf Deutsch (Erklärungen, Pläne, Rückfragen, " # (User-Anforderung 03.07.2026). Leerer String (MC_GATEWAY_LANG_DIRECTIVE="") schaltet ab.
"Zusammenfassungen) — auch wenn die Frage oder Tool-Anweisungen englisch sind. " _LANG_DIRECTIVE = os.environ.get(
"Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.") "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. "
# Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von "Quellcode, Bezeichner und Shell-Befehle bleiben unverändert.")
# Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
_BILD_BESCHREIB_PROMPT = os.environ.get(
"MC_CODER_IMAGE_PROMPT", # Bild-Beschreibung für Coder-Ziele: Prompt bewusst auf wörtliche Wiedergabe von
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, " # Code/Fehlermeldungen getrimmt — der Coder arbeitet nur mit diesem Text weiter.
"Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. " _BILD_BESCHREIB_PROMPT = os.environ.get(
"Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.") "MC_CODER_IMAGE_PROMPT",
"Beschreibe dieses Bild vollstaendig und praezise auf Deutsch: sichtbarer Text, "
"Code, Zahlen/Daten, UI-Elemente, Layout, Farben und alles Auffaellige. "
def _ist_coder_alias(alias: str) -> bool: "Sichtbaren Code und Fehlermeldungen gib WOERTLICH wieder.")
"""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() 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."""
async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool: return "coder" in (alias or "").lower()
"""Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle async def _bilder_fuer_coder_beschreiben(body: dict, client, vision_alias: str) -> bool:
(gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07. """Ersetzt jeden Bild-Part durch eine Text-Beschreibung vom Vision-Modell.
regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale Idee aus einem verwaisten, nie verdrahteten Patch in der Hermes-Quelle
Vision-Umleitung als Fallback. (gateway/platforms/api_server.py, 07/2026) — bei der Projekt-Review 15.07.
""" regelkonform hierher umgezogen (Archiv: docs/archiv/). True = alle Bilder
fundstellen: list[tuple[dict, int, dict]] = [] ersetzt; False = eine Analyse scheiterte, Aufrufer nutzt die normale
for m in body.get("messages") or []: Vision-Umleitung als Fallback.
if not isinstance(m, dict) or not isinstance(m.get("content"), list): """
continue fundstellen: list[tuple[dict, int, dict]] = []
for i, part in enumerate(m["content"]): for m in body.get("messages") or []:
if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES: if not isinstance(m, dict) or not isinstance(m.get("content"), list):
fundstellen.append((m, i, part)) continue
for nr, (msg, idx, part) in enumerate(fundstellen, start=1): for i, part in enumerate(m["content"]):
frage = { if isinstance(part, dict) and part.get("type") in IMAGE_PART_TYPES:
"model": vision_alias, fundstellen.append((m, i, part))
"stream": False, for nr, (msg, idx, part) in enumerate(fundstellen, start=1):
"max_tokens": 700, frage = {
"messages": [{"role": "user", "model": vision_alias,
"content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}], "stream": False,
} "max_tokens": 700,
try: "messages": [{"role": "user",
# Grosszuegiger Timeout: VL-30B ist on-demand (Kaltladen ~30 s) + Beschreibung ~25 s. "content": [part, {"type": "text", "text": _BILD_BESCHREIB_PROMPT}]}],
r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage, timeout=240.0) }
text = "" try:
if r.status_code == 200: # Grosszuegiger Timeout: VL-30B ist on-demand (Kaltladen ~30 s) + Beschreibung ~25 s.
text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or "" r = await client.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json=frage, timeout=240.0)
if not text.strip(): text = ""
return False if r.status_code == 200:
except Exception: text = ((r.json().get("choices") or [{}])[0].get("message") or {}).get("content") or ""
return False if not text.strip():
msg["content"][idx] = {"type": "text", "text": ( log.warning("Bild-Weiche v2: Beschreibung leer/fehlgeschlagen (HTTP %s) — Fallback Umleitung. Body: %.200s",
f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n" r.status_code, r.text)
f"{text.strip()}]")} return False
return True except Exception:
log.warning("Bild-Weiche v2: Vision-Aufruf scheiterte — Fallback Umleitung", exc_info=True)
return False
def _inject_language(body: dict, alias: str) -> None: msg["content"][idx] = {"type": "text", "text": (
"""Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates f"[Bild {nr}: dem Request lag ein Bild bei — {vision_alias} beschreibt es so:\n"
erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys f"{text.strip()}]")}
Persona (SOUL.md) regelt die Sprache selbst.""" return True
if not _LANG_DIRECTIVE or alias == "hermes":
return
msgs = body.get("messages") def _inject_language(body: dict, alias: str) -> None:
if not isinstance(msgs, list): """Deutsch-Direktive anhängen. An die ERSTE System-Message (viele Chat-Templates
return erwarten nur eine), sonst als neue System-Message. `hermes` ausgenommen — Lucys
first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None) Persona (SOUL.md) regelt die Sprache selbst."""
if first_sys is None: if not _LANG_DIRECTIVE or alias == "hermes":
msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE}) return
elif isinstance(first_sys.get("content"), str): msgs = body.get("messages")
first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE if not isinstance(msgs, list):
elif isinstance(first_sys.get("content"), list): return
first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE}) first_sys = next((m for m in msgs if isinstance(m, dict) and m.get("role") == "system"), None)
if first_sys is None:
@router.get("/models") msgs.insert(0, {"role": "system", "content": _LANG_DIRECTIVE})
async def models(request: Request): elif isinstance(first_sys.get("content"), str):
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) first_sys["content"] = first_sys["content"].rstrip() + "\n\n" + _LANG_DIRECTIVE
r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0) elif isinstance(first_sys.get("content"), list):
data = r.json() first_sys["content"].append({"type": "text", "text": _LANG_DIRECTIVE})
# 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 @router.get("/models")
# mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste. async def models(request: Request):
# Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
# budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende r = await client.get(f"{LLAMA_SWAP_URL}/v1/models", timeout=10.0)
# max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.). data = r.json()
# Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf, # Aufgeräumt (07/2026): Die Router-Lanes (coding/chat) werden NICHT mehr als „Modell"
# Clients fragen aber genau damit an → als eigene Einträge einblenden. # angeboten — sie verwirrten (coding vs. coder) und seit dem Zed-Aus wählt sie niemand
ctx_map: dict[str, int] = {} # mehr. Das Routing bleibt erhalten (siehe _proxy), sie stehen nur nicht mehr in der Liste.
alias_entries: list[dict] = [] # Kontextlänge je Modell mitliefern (aus der llama-swap-Config geparst). Ohne sie
aliased_raw: set[str] = set() # rohe Modellnamen, die ein Klarnamen-Alias schon zeigt # budgetieren Clients blind — Hermes-Subagents nahmen 256k an, schickten passende
try: # max_tokens und rissen damit den echten Server-Kontext (Radar-Lauf 02.07.).
from services import llamaswap # Rollen-Aliase (heavy/coder/hermes …) tauchen bei llama-swap NICHT als Einträge auf,
for m in llamaswap.list_models(): # Clients fragen aber genau damit an → als eigene Einträge einblenden.
ctx = m.get("ctx") ctx_map: dict[str, int] = {}
# --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die alias_entries: list[dict] = []
# Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot: aliased_raw: set[str] = set() # rohe Modellnamen, die ein Klarnamen-Alias schon zeigt
# Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene try:
# Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens). from services import llamaswap
slots = m.get("parallel_slots") or 1 for m in llamaswap.list_models():
if ctx and slots > 1: ctx = m.get("ctx")
ctx = ctx // slots # --parallel teilt den Server-Kontext HART auf die Slots auf — ohne die
if ctx: # Division budgetieren Clients (v. a. Hermes) gegen 131k, real sind 65k/Slot:
for api_id in m.get("api_ids", []): # Kompaktierung feuert nie, llama-server kappt Prompt/Antwort → abgerissene
ctx_map[api_id] = ctx # Tool-Calls + Retry-Schleifen (Log 07.07., Session bei ~52k Tokens).
aliases = m.get("aliases", []) slots = m.get("parallel_slots") or 1
if aliases: if ctx and slots > 1:
aliased_raw.add(m["name"]) ctx = ctx // slots
for alias in aliases: if ctx:
entry = {"id": alias, "object": "model", "owned_by": "mc2-alias", for api_id in m.get("api_ids", []):
"description": f"Alias für {m['name']}"} ctx_map[api_id] = ctx
if ctx: aliases = m.get("aliases", [])
entry["context_length"] = ctx if aliases:
alias_entries.append(entry) aliased_raw.add(m["name"])
except Exception: for alias in aliases:
pass entry = {"id": alias, "object": "model", "owned_by": "mc2-alias",
if isinstance(data, dict) and isinstance(data.get("data"), list): "description": f"Alias für {m['name']}"}
for entry in data["data"]: if ctx:
if (ctx := ctx_map.get(entry.get("id"))): entry["context_length"] = ctx
entry.setdefault("context_length", ctx) alias_entries.append(entry)
# Rohe Doppel-IDs ausblenden, wenn ein Klarnamen-Alias sie schon zeigt → in der except Exception:
# Liste erscheinen nur die freundlichen Rollen-Namen. Routing per roher ID bleibt. pass
raw = [e for e in data["data"] if e.get("id") not in aliased_raw] if isinstance(data, dict) and isinstance(data.get("data"), list):
data["data"] = alias_entries + raw for entry in data["data"]:
return JSONResponse(data, status_code=r.status_code) 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
async def _proxy(path: str, request: Request): # Liste erscheinen nur die freundlichen Rollen-Namen. Routing per roher ID bleibt.
body = await request.json() raw = [e for e in data["data"] if e.get("id") not in aliased_raw]
requested = str(body.get("model") or "auto") data["data"] = alias_entries + raw
if requested.lower() in ("auto", "chat", "coding"): return JSONResponse(data, status_code=r.status_code)
lane = requested.lower()
alias, reason = choose_for_lane(lane, body)
body["model"] = alias async def _proxy(path: str, request: Request):
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane} body = await request.json()
else: requested = str(body.get("model") or "auto")
alias = requested if requested.lower() in ("auto", "chat", "coding"):
routed = {"x-mc-routed-to": requested} lane = requested.lower()
alias, reason = choose_for_lane(lane, body)
pol = load_policy() body["model"] = alias
client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan) routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane}
# Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten, else:
# sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder — alias = requested
# so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A). routed = {"x-mc-routed-to": requested}
# 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). pol = load_policy()
vision_alias = pol.get("vision") client = request.app.state.gw_client # geteilter Keep-Alive-Client (siehe app.py lifespan)
if vision_alias and alias not in VISION_CAPABLE and has_image(body): # Bild-Weiche (Faden 11): Requests mit Bild-Anhang automatisch ans Vision-Modell umleiten,
if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias): # sofern das Ziel nicht ohnehin bildfähig ist. Das MTP-Hirn (fast/hermes) kann keine Bilder —
routed = {"x-mc-routed-to": alias, # so bleibt Lucy schnell, ohne dass jemand manuell das Modell wechselt (Entscheid Weg A).
"x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder", # Coder-Sonderweg (15.07.): Coder-Ziele behalten den Request — die Bilder werden vorab vom
"x-mc-lane": routed.get("x-mc-lane", "-")} # Vision-Modell BESCHRIEBEN und als Text injiziert (Screenshot-Debugging bleibt beim Coder).
else: vision_alias = pol.get("vision")
alias = vision_alias if vision_alias and alias not in VISION_CAPABLE and has_image(body):
body["model"] = alias if _ist_coder_alias(alias) and await _bilder_fuer_coder_beschreiben(body, client, vision_alias):
# HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.). routed = {"x-mc-routed-to": alias,
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell", "x-mc-route-reason": "Bild beschrieben (Vision) -> bleibt beim Coder",
"x-mc-lane": routed.get("x-mc-lane", "-")} "x-mc-lane": routed.get("x-mc-lane", "-")}
# fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt). else:
if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body: alias = vision_alias
body["chat_template_kwargs"] = {"enable_thinking": False} body["model"] = alias
_inject_language(body, alias) # HTTP-Header-Werte muessen latin-1 sein — kein '→' o.ae. (sonst 500, 13.07.).
url = f"{LLAMA_SWAP_URL}{path}" routed = {"x-mc-routed-to": alias, "x-mc-route-reason": "Bild erkannt -> Vision-Modell",
if body.get("stream"): "x-mc-lane": routed.get("x-mc-lane", "-")}
req = client.build_request("POST", url, json=body, timeout=None) # fast-Spur: Thinking aus für flotte Antworten (sofern Client es nicht selbst setzt).
r = await client.send(req, stream=True) if pol["fast_no_think"] and alias == pol["fast"] and "chat_template_kwargs" not in body:
if r.status_code != 200: body["chat_template_kwargs"] = {"enable_thinking": False}
await r.aread() _inject_language(body, alias)
try: url = f"{LLAMA_SWAP_URL}{path}"
resp_json = r.json() if body.get("stream"):
except Exception: req = client.build_request("POST", url, json=body, timeout=None)
resp_json = {"error": {"message": r.text, "type": "upstream_error"}} r = await client.send(req, stream=True)
return JSONResponse(resp_json, status_code=r.status_code, headers=routed) if r.status_code != 200:
await r.aread()
async def gen(): try:
try: resp_json = r.json()
async for chunk in r.aiter_raw(): except Exception:
record_stream_chunk(chunk, alias) resp_json = {"error": {"message": r.text, "type": "upstream_error"}}
yield chunk return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
finally:
await r.aclose() async def gen():
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed) try:
async for chunk in r.aiter_raw():
r = await client.post(url, json=body, timeout=600.0) record_stream_chunk(chunk, alias)
resp_json = r.json() yield chunk
if isinstance(resp_json, dict): finally:
record_usage(resp_json.get("usage"), alias) await r.aclose()
if any(isinstance(c, dict) and c.get("finish_reason") == "length" return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
for c in resp_json.get("choices") or []):
warn_truncation(alias) r = await client.post(url, json=body, timeout=600.0)
return JSONResponse(resp_json, status_code=r.status_code, headers=routed) resp_json = r.json()
if isinstance(resp_json, dict):
record_usage(resp_json.get("usage"), alias)
@router.post("/chat/completions") if any(isinstance(c, dict) and c.get("finish_reason") == "length"
async def chat_completions(request: Request): for c in resp_json.get("choices") or []):
return await _proxy("/v1/chat/completions", request) warn_truncation(alias)
return JSONResponse(resp_json, status_code=r.status_code, headers=routed)
@router.post("/completions")
async def completions(request: Request): @router.post("/chat/completions")
return await _proxy("/v1/completions", request) 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)