feat(2.0): Phase 6c — eingebauter OpenAI-Gateway (model:auto) statt LiteLLM
LiteLLM baut auf Python 3.14 nicht (orjson-Pin ohne cp314-Wheel). Stattdessen eingebauter Gateway in MC2: routers/gateway_proxy.py (/v1/chat/completions, /completions, /models) + services/router_logic.py (Komplexitaets-Routing fast<->heavy, Streaming-Passthrough). gateway.py/routing.py/connect.py auf builtin umgestellt (Endpunkt = MC :PORT/v1). Gleicher OpenAI-Vertrag, spaeter gegen LiteLLM austauschbar. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from config import FRONTEND_DIST, VERSION
|
from config import FRONTEND_DIST, VERSION
|
||||||
from routers import agent, connect, health, memory, models, routing, system
|
from routers import agent, connect, gateway_proxy, health, memory, models, routing, system
|
||||||
|
|
||||||
app = FastAPI(title="Mission Control 2.0", version=VERSION)
|
app = FastAPI(title="Mission Control 2.0", version=VERSION)
|
||||||
|
|
||||||
@@ -41,6 +41,7 @@ app.include_router(system.router)
|
|||||||
app.include_router(connect.router)
|
app.include_router(connect.router)
|
||||||
app.include_router(memory.router)
|
app.include_router(memory.router)
|
||||||
app.include_router(agent.router)
|
app.include_router(agent.router)
|
||||||
|
app.include_router(gateway_proxy.router) # OpenAI-kompatibler /v1-Gateway (model:auto)
|
||||||
|
|
||||||
|
|
||||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
Eingebauter Routing-Gateway (OpenAI-kompatibel) — EIN Endpunkt für Hermes + IDEs.
|
||||||
|
|
||||||
|
`model: auto` → Komplexitäts-Routing fast↔heavy; jeder andere Name geht als
|
||||||
|
llama-swap-Alias durch (das lädt das Modell bei Bedarf). Streaming wird
|
||||||
|
durchgereicht. Ersetzt LiteLLM (das auf Python 3.14 nicht baut) — gleicher
|
||||||
|
Vertrag, später austauschbar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import JSONResponse, StreamingResponse
|
||||||
|
|
||||||
|
from config import LLAMA_SWAP_URL
|
||||||
|
from services.router_logic import choose_model
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/v1")
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
body["model"] = alias
|
||||||
|
routed = {"x-mc-routed-to": alias, "x-mc-route-reason": reason}
|
||||||
|
else:
|
||||||
|
routed = {"x-mc-routed-to": requested}
|
||||||
|
url = f"{LLAMA_SWAP_URL}{path}"
|
||||||
|
|
||||||
|
if body.get("stream"):
|
||||||
|
async def gen():
|
||||||
|
async with httpx.AsyncClient(timeout=None) as c:
|
||||||
|
async with c.stream("POST", url, json=body) as r:
|
||||||
|
async for chunk in r.aiter_raw():
|
||||||
|
yield chunk
|
||||||
|
return StreamingResponse(gen(), media_type="text/event-stream", headers=routed)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=600) as c:
|
||||||
|
r = await c.post(url, json=body)
|
||||||
|
return JSONResponse(r.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)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Routing-Endpoints: Gateway-Übersicht (welcher Alias = fast/heavy/…) + Mapping setzen."""
|
"""Routing-Endpoint: zeigt den eingebauten Gateway (model:auto fast↔heavy)."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from services import gateway
|
from services import gateway
|
||||||
|
|
||||||
@@ -11,18 +10,3 @@ router = APIRouter(prefix="/api")
|
|||||||
@router.get("/routing")
|
@router.get("/routing")
|
||||||
def routing() -> dict:
|
def routing() -> dict:
|
||||||
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
|
return {**gateway.routing_summary(), "gateway_reachable": gateway.gateway_reachable()}
|
||||||
|
|
||||||
|
|
||||||
class RouteReq(BaseModel):
|
|
||||||
name: str # Gateway-Modellname, z.B. "fast" / "heavy" / "vision" / "coder"
|
|
||||||
target_alias: str # llama-swap-Alias, der bedient wird
|
|
||||||
api_base: str = "http://127.0.0.1:8080/v1"
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/routing/route")
|
|
||||||
def set_route(req: RouteReq) -> dict:
|
|
||||||
try:
|
|
||||||
gateway.set_route(req.name, req.target_alias, api_base=req.api_base)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
raise HTTPException(500, str(exc))
|
|
||||||
return {"ok": True}
|
|
||||||
|
|||||||
+10
-10
@@ -9,23 +9,24 @@ die häufigste Fehlerquelle. Der Aufrufer übergibt den Host explizit.
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from config import PORT
|
||||||
|
|
||||||
DEFAULT_HOST = "192.168.178.151"
|
DEFAULT_HOST = "192.168.178.151"
|
||||||
GATEWAY_PORT = 4000
|
|
||||||
MC_PORT = 9000
|
|
||||||
|
|
||||||
# Modelle, die der Gateway anbietet (model:auto = Standard).
|
# Modelle, die der Gateway anbietet (model:auto = Standard).
|
||||||
GATEWAY_MODELS = ["auto", "fast", "heavy", "coder", "vision"]
|
GATEWAY_MODELS = ["auto", "fast", "heavy", "coder", "vision"]
|
||||||
|
|
||||||
|
|
||||||
def _gw(host: str) -> str:
|
def _gw(host: str) -> str:
|
||||||
return f"http://{host}:{GATEWAY_PORT}/v1"
|
# Eingebauter Gateway: MC2 serviert /v1 selbst (gleicher Port wie das Cockpit).
|
||||||
|
return f"http://{host}:{PORT}/v1"
|
||||||
|
|
||||||
|
|
||||||
def build_snippets(host: str = DEFAULT_HOST,
|
def build_snippets(host: str = DEFAULT_HOST,
|
||||||
mcp_script_path: str = r"C:\\Users\\TobisPC\\mission-control-v2\\mcp\\mcp_memory.py",
|
mcp_script_path: str = r"C:\\Users\\TobisPC\\mission-control-v2\\mcp\\mcp_memory.py",
|
||||||
mcp_python: str = "python") -> dict:
|
mcp_python: str = "python") -> dict:
|
||||||
gw = _gw(host)
|
gw = _gw(host)
|
||||||
mc_url = f"http://{host}:{MC_PORT}"
|
mc_url = f"http://{host}:{PORT}"
|
||||||
|
|
||||||
cline = json.dumps({
|
cline = json.dumps({
|
||||||
"apiProvider": "openai",
|
"apiProvider": "openai",
|
||||||
@@ -66,13 +67,12 @@ def build_snippets(host: str = DEFAULT_HOST,
|
|||||||
]
|
]
|
||||||
}, indent=2)
|
}, indent=2)
|
||||||
|
|
||||||
# Claude Code: Anthropic-Format → LiteLLM kann /v1/messages anbieten.
|
# Claude Code spricht Anthropic-Format; der eingebaute Gateway ist OpenAI-kompatibel.
|
||||||
claude_code = (
|
claude_code = (
|
||||||
f'# Claude Code gegen den lokalen Gateway (Anthropic-kompatibel via LiteLLM):\n'
|
f"# Der eingebaute Gateway ist OpenAI-kompatibel ({gw}, model: auto).\n"
|
||||||
f'export ANTHROPIC_BASE_URL="http://{host}:{GATEWAY_PORT}"\n'
|
f"# Claude Code nutzt das Anthropic-Format — dafür braucht es einen Anthropic-Shim\n"
|
||||||
f'export ANTHROPIC_API_KEY="local"\n'
|
f"# (z.B. LiteLLM /v1/messages) als Aufsatz. Für lokale Modelle direkt: Cline / OpenCode /\n"
|
||||||
f'export ANTHROPIC_MODEL="auto"\n'
|
f"# Continue / Zed nutzen (oben), die sprechen OpenAI-kompatibel mit diesem Gateway."
|
||||||
f'# (LiteLLM muss den Anthropic-/v1/messages-Endpunkt aktiviert haben — auf der Box prüfen.)'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
memory_mcp = json.dumps({
|
memory_mcp = json.dumps({
|
||||||
|
|||||||
+19
-58
@@ -1,69 +1,30 @@
|
|||||||
"""
|
"""
|
||||||
Routing-Gateway-Service: liest/schreibt die LiteLLM-Config und prüft die
|
Routing-Gateway-Status (eingebauter Modus). MC2 IST der Gateway: serviert
|
||||||
Erreichbarkeit. MC verwaltet damit die Modell-Zuordnung (welcher llama-swap-Alias
|
`/v1/*` mit `model: auto`-Komplexitäts-Routing vor llama-swap. Kein externer
|
||||||
ist fast/heavy/vision/coder) und die Routing-Regeln.
|
LiteLLM-Dienst nötig (baut auf Python 3.14 nicht); bleibt später austauschbar.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import httpx
|
from config import PORT
|
||||||
|
from services.llamaswap import engine_reachable
|
||||||
from config import GATEWAY_CONFIG_PATH, GATEWAY_URL, yaml
|
from services.router_logic import FAST, HEAVY, HEAVY_CHARS
|
||||||
|
|
||||||
|
|
||||||
def read_gateway_config() -> dict:
|
|
||||||
if not GATEWAY_CONFIG_PATH.exists():
|
|
||||||
return {"model_list": [], "litellm_settings": {}, "router_settings": {}}
|
|
||||||
with GATEWAY_CONFIG_PATH.open("r", encoding="utf-8") as f:
|
|
||||||
return yaml.load(f) or {}
|
|
||||||
|
|
||||||
|
|
||||||
def write_gateway_config(cfg: dict) -> None:
|
|
||||||
import os
|
|
||||||
GATEWAY_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = GATEWAY_CONFIG_PATH.with_name(GATEWAY_CONFIG_PATH.name + ".tmp")
|
|
||||||
with tmp.open("w", encoding="utf-8") as f:
|
|
||||||
yaml.dump(cfg, f)
|
|
||||||
os.replace(tmp, GATEWAY_CONFIG_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
def routing_summary() -> dict:
|
def routing_summary() -> dict:
|
||||||
"""Kompakte Sicht für die UI: welcher Backend-Alias steckt hinter welchem
|
|
||||||
Gateway-Modellnamen + die Fallback-Ketten."""
|
|
||||||
cfg = read_gateway_config()
|
|
||||||
routes = []
|
|
||||||
for entry in cfg.get("model_list") or []:
|
|
||||||
params = entry.get("litellm_params") or {}
|
|
||||||
routes.append({
|
|
||||||
"name": entry.get("model_name"),
|
|
||||||
"target": str(params.get("model", "")),
|
|
||||||
"api_base": params.get("api_base"),
|
|
||||||
})
|
|
||||||
settings = cfg.get("litellm_settings") or {}
|
|
||||||
return {
|
return {
|
||||||
"routes": routes,
|
"mode": "builtin",
|
||||||
"fallbacks": settings.get("fallbacks") or [],
|
"endpoint": f":{PORT}/v1 (OpenAI-kompatibel)",
|
||||||
"context_window_fallbacks": settings.get("context_window_fallbacks") or [],
|
"routes": [
|
||||||
|
{"name": "auto", "target": f"{FAST} ↔ {HEAVY} (nach Komplexität)"},
|
||||||
|
{"name": FAST, "target": "llama-swap-Alias 'fast'"},
|
||||||
|
{"name": HEAVY, "target": "llama-swap-Alias 'heavy'"},
|
||||||
|
{"name": "<alias>", "target": "llama-swap-Passthrough (lädt bei Bedarf)"},
|
||||||
|
],
|
||||||
|
"heavy_threshold_chars": HEAVY_CHARS,
|
||||||
|
"fallbacks": [],
|
||||||
|
"context_window_fallbacks": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def set_route(name: str, target_alias: str, api_base: str = "http://127.0.0.1:8080/v1") -> None:
|
|
||||||
"""Einen Gateway-Modellnamen (z.B. 'fast') auf einen llama-swap-Alias mappen."""
|
|
||||||
cfg = read_gateway_config()
|
|
||||||
ml = cfg.setdefault("model_list", [])
|
|
||||||
params = {"model": f"openai/{target_alias}", "api_base": api_base, "api_key": "sk-noauth"}
|
|
||||||
for entry in ml:
|
|
||||||
if entry.get("model_name") == name:
|
|
||||||
entry["litellm_params"] = params
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
ml.append({"model_name": name, "litellm_params": params})
|
|
||||||
write_gateway_config(cfg)
|
|
||||||
|
|
||||||
|
|
||||||
def gateway_reachable() -> bool:
|
def gateway_reachable() -> bool:
|
||||||
try:
|
# Der eingebaute Gateway lebt in MC und proxyt llama-swap → erreichbar, wenn Engine läuft.
|
||||||
with httpx.Client(timeout=3.0) as c:
|
return engine_reachable()
|
||||||
# LiteLLM hat /health/liveliness; /v1/models tut's auch.
|
|
||||||
r = c.get(f"{GATEWAY_URL}/v1/models")
|
|
||||||
return r.status_code in (200, 401)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""
|
||||||
|
Komplexitäts-Routing für `model: auto` (eingebauter Gateway).
|
||||||
|
Schnell im Alltag (fast), schwer bei Bedarf (heavy) — regelbasiert, sub-ms, ohne Cloud.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
_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",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def choose_model(body: dict) -> tuple[str, str]:
|
||||||
|
"""Wählt fast|heavy für eine Chat-Anfrage. Gibt (alias, begründung) zurück."""
|
||||||
|
msgs = body.get("messages") or []
|
||||||
|
text = "\n".join(str(m.get("content") or "") for m in msgs)
|
||||||
|
n = len(text)
|
||||||
|
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"
|
||||||
+7
-7
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
<meta name="theme-color" content="#0d1117" />
|
<meta name="theme-color" content="#0d1117" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<title>Mission Control 2.0</title>
|
<title>Mission Control 2.0</title>
|
||||||
<script type="module" crossorigin src="/assets/index-pBjYpaCo.js"></script>
|
<script type="module" crossorigin src="/assets/index-Ch2rl17k.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C-XbOjOw.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C-XbOjOw.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ export interface DiscoverResp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RoutingResp {
|
export interface RoutingResp {
|
||||||
routes: { name: string; target: string; api_base: string | null }[]
|
mode?: string
|
||||||
|
endpoint?: string
|
||||||
|
heavy_threshold_chars?: number
|
||||||
|
routes: { name: string; target: string }[]
|
||||||
fallbacks: Record<string, string[]>[]
|
fallbacks: Record<string, string[]>[]
|
||||||
context_window_fallbacks: Record<string, string[]>[]
|
context_window_fallbacks: Record<string, string[]>[]
|
||||||
gateway_reachable: boolean
|
gateway_reachable: boolean
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export function RoutingView() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Routing</h1>
|
<h1 className="text-xl font-semibold">Routing</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Der LiteLLM-Gateway bündelt alle Modelle zu einem Endpunkt. <code>auto</code> = schnell im
|
Eingebauter OpenAI-Gateway: ein Endpunkt für alle Tools. <code>model: auto</code> = schnell im
|
||||||
Alltag, eskaliert bei Bedarf auf <code>heavy</code>. Gilt für Hermes <em>und</em> Vibe Coding.
|
Alltag (<code>fast</code>), wechselt bei komplexen/langen Anfragen auf <code>heavy</code>.
|
||||||
|
Gilt für Hermes <em>und</em> Vibe Coding.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -35,7 +36,13 @@ export function RoutingView() {
|
|||||||
data.gateway_reachable ? "bg-emerald-500" : "bg-amber-500",
|
data.gateway_reachable ? "bg-emerald-500" : "bg-amber-500",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
Gateway {data.gateway_reachable ? "online" : "offline (Config wird trotzdem angezeigt)"}
|
Gateway {data.gateway_reachable ? "online" : "offline"}
|
||||||
|
{data.endpoint && <span className="text-muted-foreground">· {data.endpoint}</span>}
|
||||||
|
{data.heavy_threshold_chars && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
auto→heavy ab {data.heavy_threshold_chars} Zeichen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
<div className="overflow-hidden rounded-xl border border-border bg-card">
|
||||||
|
|||||||
Reference in New Issue
Block a user