diff --git a/backend/routers/gateway_proxy.py b/backend/routers/gateway_proxy.py index e9f0f5a..9f8c8f8 100644 --- a/backend/routers/gateway_proxy.py +++ b/backend/routers/gateway_proxy.py @@ -4,25 +4,35 @@ from fastapi.responses import JSONResponse, StreamingResponse from config import LLAMA_SWAP_URL from services.gateway_stream import record_stream_chunk, record_usage -from services.router_logic import FAST, FAST_NO_THINK, choose_model +from services.router_logic import FAST, FAST_NO_THINK, LANES, choose_for_lane router = APIRouter(prefix="/v1") +# Virtuelle Lanes, die der Gateway zusätzlich zu den echten Modellen als „Modell" anbietet. +_LANE_LABELS = {"coding": "Coding (Router → coder/heavy/fast)", "chat": "Chat (Router → fast/heavy)"} + @router.get("/models") async def models(): async with httpx.AsyncClient(timeout=10) as c: r = await c.get(f"{LLAMA_SWAP_URL}/v1/models") - return JSONResponse(r.json(), status_code=r.status_code) + data = r.json() + # Lanes ganz oben einblenden, damit IDEs einfach „coding"/„chat" wählen können. + lanes = [{"id": lane, "object": "model", "owned_by": "mc2-router", + "description": _LANE_LABELS.get(lane, lane)} for lane in LANES] + if isinstance(data, dict) and isinstance(data.get("data"), list): + data["data"] = lanes + data["data"] + 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 == "auto": - alias, reason = choose_model(body) + 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} + routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason, "x-mc-lane": lane} else: alias = requested routed = {"x-mc-routed-to": requested} diff --git a/backend/services/router_logic.py b/backend/services/router_logic.py index 115e086..3c2ac43 100644 --- a/backend/services/router_logic.py +++ b/backend/services/router_logic.py @@ -1,33 +1,93 @@ """ -Komplexitäts-Routing für `model: auto` (eingebauter Gateway). -Schnell im Alltag (fast), schwer bei Bedarf (heavy) — regelbasiert, sub-ms, ohne Cloud. +Lane-Routing für den eingebauten MC2-Gateway (:9001/v1). + +Zwei virtuelle Lanes, die Clients/IDEs auswählen — der Router pickt das echte Modell: +- **chat** (= altes `auto`): Alltag → `fast`, schwer/lang → `heavy`. +- **coding**: Code-Arbeit → `coder` (Qwen3-Coder-Next); riesiger/architektonischer Kontext → `heavy`; + triviale Kurzfrage ohne Code → `fast` (Tempo). + +Regelbasiert, sub-ms, ohne Cloud. Schwellen/Aliases via Env überschreibbar (Phase 2: UI-editierbare +Policy-JSON). Lucy läuft NICHT hierüber — die ist der Hermes-Agent (:8642), eigene Ebene. """ import os import re +# Echte Modell-Aliases hinter den Lanes (Env-überschreibbar). FAST = os.environ.get("MC_ROUTE_FAST", "fast") HEAVY = os.environ.get("MC_ROUTE_HEAVY", "heavy") -HEAVY_CHARS = int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")) -# Thinking auf der fast-Spur ausschalten → flotte Alltags-Antworten (Qwen3.6 ist ein -# Reasoning-Modell). heavy behält Thinking für harte Aufgaben. Abschaltbar via Env. +CODER = os.environ.get("MC_ROUTE_CODER", "coder") + +# Schwellen (Zeichen). +HEAVY_CHARS = int(os.environ.get("MC_GATEWAY_HEAVY_CHARS", "8000")) # chat → heavy +CODING_HEAVY_CHARS = int(os.environ.get("MC_CODING_HEAVY_CHARS", "24000")) # coding → heavy +CODING_TRIVIAL_CHARS = int(os.environ.get("MC_CODING_TRIVIAL_CHARS", "240")) # coding → fast (nur ohne Code) + +# Thinking auf der fast-Spur aus → flotte Alltags-Antworten (Qwen3.6 ist ein Reasoning-Modell). FAST_NO_THINK = os.environ.get("MC_FAST_NO_THINK", "1") not in ("0", "false", "") +# Virtuelle Lanes, die im Gateway als „Modelle" sichtbar sind. +LANES = ["coding", "chat"] +LANE_ALIASES = {"auto": "chat"} # Rückwärtskompatibel: model:auto == chat + _HEAVY_KW = re.compile( - r"\b(beweis|prove|theorem|refactor|architect|komplex|complex|schwierig|" - r"think\s*hard|reason\s*carefully|tief\s*nachdenk|optimi[sz]e|algorithm|" - r"root\s*cause|debug|analy[sz]e\s+deeply|step[-\s]?by[-\s]?step)\b", + r"\b(beweis|prove|theorem|komplex|complex|schwierig|" + r"think\s*hard|reason\s*carefully|tief\s*nachdenk|optimi[sz]e|" + r"root\s*cause|analy[sz]e\s+deeply|step[-\s]?by[-\s]?step)\b", re.IGNORECASE, ) +# Coding-spezifische „das ist groß/architektonisch" Signale → heavy statt coder. +_CODING_HEAVY_KW = re.compile( + r"\b(architekt|architect|system[-\s]?design|refactor\s+the\s+(whole|entire)|" + r"ganze[ns]?\s+(architektur|codebase|projekt)|migrat\w+\s+(the\s+)?(whole|entire|gesamte)|" + r"entwirf\s+(eine\s+)?architektur|plane?\s+(die\s+)?architektur)\b", + re.IGNORECASE, +) +# Code-Indikatoren — verhindert, dass echte Code-Anfragen als „trivial" auf fast abrutschen. +# Bewusst breit (inkl. natürlichsprachiger Coding-Begriffe DE+EN): in der coding-Lane soll im Zweifel +# `coder` gewinnen; nur echte Nicht-Code-Kürze ("hallo", "wie spät") rutscht auf fast. +_CODE_HINT = re.compile( + r"```|\bdef \b|\bclass \b|\bimport \b|\bfunction\b|=>|;\s*$|" + r"\.(py|ts|tsx|js|jsx|go|rs|java|cpp|c|rb|php|sql)\b|/src/|traceback|stack\s*trace|" + r"\b(funktion|function|bug|fix|fehler|error|exception|implementier\w*|schreib\w*|" + r"code\w*|coden|test\w*|klasse|method\w*|methode|refactor\w*|kompil\w*|compile|" + r"build|deploy|debug|script|skript|api|endpoint|query|regex|json|yaml|" + r"npm|pip|git|docker|terminal|shell|command)\b", + re.IGNORECASE | re.MULTILINE, +) -def choose_model(body: dict) -> tuple[str, str]: - """Wählt fast|heavy für eine Chat-Anfrage. Gibt (alias, begründung) zurück.""" +def _text_of(body: dict) -> str: msgs = body.get("messages") or [] - text = "\n".join(str(m.get("content") or "") for m in msgs) - n = len(text) + return "\n".join(str(m.get("content") or "") for m in msgs) + + +def _route_chat(text: str, n: int) -> tuple[str, str]: if n > HEAVY_CHARS: return HEAVY, f"langer Kontext ({n} > {HEAVY_CHARS} Zeichen)" if _HEAVY_KW.search(text): return HEAVY, "Komplexitäts-Schlüsselwort erkannt" return FAST, "Standard" + + +def _route_coding(text: str, n: int) -> tuple[str, str]: + if n > CODING_HEAVY_CHARS or _CODING_HEAVY_KW.search(text): + return HEAVY, "großer/architektonischer Coding-Kontext" + if n < CODING_TRIVIAL_CHARS and not _CODE_HINT.search(text): + return FAST, "triviale Kurzfrage (kein Code)" + return CODER, "Coding-Standard" + + +def choose_for_lane(lane: str, body: dict) -> tuple[str, str]: + """Wählt das echte Modell-Alias für eine Lane. Gibt (alias, begründung) zurück.""" + lane = LANE_ALIASES.get((lane or "chat").lower(), (lane or "chat").lower()) + text = _text_of(body) + n = len(text) + if lane == "coding": + return _route_coding(text, n) + return _route_chat(text, n) # chat + alles Unbekannte + + +def choose_model(body: dict) -> tuple[str, str]: + """Rückwärtskompatibel: altes `model:auto` == chat-Lane.""" + return choose_for_lane("chat", body)