This commit is contained in:
team3
2026-07-23 16:07:36 +02:00
commit cb68cd671b
88 changed files with 10029 additions and 0 deletions

238
backend/llm.py Normal file
View File

@@ -0,0 +1,238 @@
"""MiniMax-Client (R3/R9). Fehlerklassen strikt getrennt: Infra (429/Timeout)
→ 3 Retries mit Backoff 8/16/32 s + Jitter, erschöpft → LaufPause (fail-closed).
Inhalt (leer/kaputt) → begrenzte Restarts, dann None. cap (stop=max_tokens)
→ SOFORT None, kein Retry (deterministisch — Aufrufer halbiert den Chunk).
Ein 429 drosselt ALLE Calls (globaler Cooldown + adaptive Breite)."""
import asyncio
import random
import time
from dataclasses import dataclass, field
import httpx
from . import config, db, skills
from .ledger import BudgetErschoepft, budget_pruefen, log_call # noqa: F401
_INFRA_MARKER = ("429", "rate limit", "rate_limit", "timeout", "timed out",
"connection", "network", "overloaded", "unavailable",
'"status_code":1002', '"status_code":1041', '"status_code":2045')
class LaufPause(Exception):
"""Infrastruktur erschöpft — Lauf pausieren, nie fail-open weiterrechnen."""
class ManuellePause(LaufPause):
"""Nutzer hat pausiert — Wartende brechen sofort ab, Laufende laufen aus."""
@dataclass
class ApiErgebnis:
rc: int
text: str = ""
err: str = ""
tokens: dict = field(default_factory=dict)
# ── globale Drossel (ein 429 bremst alle) ──
_sem = asyncio.Semaphore(config.MAX_PARALLEL_LLM)
_cooldown_bis = 0.0
_breite = config.MAX_PARALLEL_LLM
_inflight = 0
_erfolge = 0
def drossel_melden(sekunden: float) -> None:
global _cooldown_bis, _breite, _erfolge
_cooldown_bis = max(_cooldown_bis, time.monotonic() + sekunden)
_breite = max(config.DROSSEL_MIN_BREITE, _breite // 2)
_erfolge = 0
def _erfolg_melden() -> None:
global _breite, _erfolge
_erfolge += 1
if _erfolge >= config.DROSSEL_ERFOLGE_JE_PLUS and _breite < config.MAX_PARALLEL_LLM:
_breite += 1
_erfolge = 0
_naechster_start = 0.0
async def _slot() -> float:
"""Cooldown abwarten, dann Takt (1 Start je TAKT_SEKUNDEN) ODER — bei
Takt 0 — adaptive Breite. Rückgabe: Wartezeit in Sekunden."""
global _inflight, _naechster_start
t0 = time.monotonic()
while time.monotonic() < _cooldown_bis:
await asyncio.sleep(min(1.0, _cooldown_bis - time.monotonic()))
if config.TAKT_SEKUNDEN > 0:
while True:
jetzt = time.monotonic()
if jetzt >= _naechster_start:
_naechster_start = max(jetzt, _naechster_start) + config.TAKT_SEKUNDEN
break
await asyncio.sleep(min(0.2, _naechster_start - jetzt))
else:
while _inflight >= _breite:
await asyncio.sleep(0.2)
_inflight += 1
return time.monotonic() - t0
def _slot_frei() -> None:
global _inflight
_inflight = max(0, _inflight - 1)
def _ist_infra(err: str) -> bool:
e = err.lower()
return any(m in e for m in _INFRA_MARKER)
async def _api(prompt: str, role: str, timeout: float) -> ApiErgebnis:
opts = dict(config.ROLLEN.get(role) or config.ROLLEN["judge"])
body = {"model": opts.pop("model"), "max_tokens": config.LLM_MAX_TOKENS,
"messages": [{"role": "user", "content": prompt}],
"temperature": opts.pop("temperature"),
"thinking": {"type": opts.pop("thinking")}}
headers = {"x-api-key": __import__("os").environ.get("MINIMAX_API_KEY", ""),
"anthropic-version": "2023-06-01"}
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(timeout, connect=config.FETCH_CONNECT_S)) as client:
resp = await asyncio.wait_for(
client.post(config.LLM_ENDPOINT, json=body, headers=headers),
timeout=timeout)
except (httpx.TimeoutException, asyncio.TimeoutError):
return ApiErgebnis(1, err="timeout")
except httpx.HTTPError as e:
return ApiErgebnis(1, err=f"{type(e).__name__}: {e}")
if resp.status_code == 429:
try:
nach = float(resp.headers.get("retry-after", "15"))
except ValueError:
nach = 15.0
drossel_melden(min(nach, 60.0))
return ApiErgebnis(1, err="HTTP 429: rate limited")
if resp.status_code != 200:
return ApiErgebnis(1, err=f"HTTP {resp.status_code}: {resp.text[:300]}")
daten = resp.json()
text = "".join(b.get("text", "") for b in daten.get("content", [])
if b.get("type") == "text")
u = daten.get("usage") or {}
tokens = {"input": int(u.get("input_tokens") or 0),
"output": int(u.get("output_tokens") or 0),
"cache_read": int(u.get("cache_read_input_tokens") or 0),
"cache_write": int(u.get("cache_creation_input_tokens") or 0)}
if _ist_infra(resp.text[:500]) and not text.strip():
return ApiErgebnis(1, err=resp.text[:300], tokens=tokens)
if not text.strip():
return ApiErgebnis(1, err=f"leere Antwort (stop={daten.get('stop_reason')})",
tokens=tokens)
_erfolg_melden()
return ApiErgebnis(0, text=text, tokens=tokens)
async def _versuch(prompt: str, role: str, timeout: float) -> tuple[ApiErgebnis, bool]:
"""Ein Versuch inkl. Hedge-Zwilling (Lektion 4/5). → (ergebnis, war_hedge)."""
wait_s = await _slot()
try:
haupt = asyncio.ensure_future(_api(prompt, role, timeout))
schwelle = max(config.HEDGE_NACH_S, timeout / 2) if config.HEDGE_NACH_S else None
if schwelle is None:
return await haupt, False
fertig, _ = await asyncio.wait({haupt}, timeout=schwelle)
if fertig:
return haupt.result(), False
zwilling = asyncio.ensure_future(_api(prompt, role, timeout))
fertig, offen = await asyncio.wait({haupt, zwilling},
return_when=asyncio.FIRST_COMPLETED)
gewinner = fertig.pop()
for t in offen:
t.cancel() # Verlierer wird vom Aufrufer als hedge_cancel verbucht
return gewinner.result(), True
finally:
_slot_frei()
# wait_s in Meta des Aufrufers — hier via Attribut zurückgeben
_versuch.last_wait = wait_s # type: ignore[attr-defined]
async def call(*, run_id: int, stufe: str, knoten: str, item: str,
skill_namen: list[str], werte: dict, role: str = "judge",
n: int = 0) -> str | None:
"""Zentraler LLM-Engpass. Rückgabe: Antworttext oder None (Inhaltsfehler/cap).
Wirft LaufPause (Infra erschöpft/manuell) oder BudgetErschoepft."""
prompt, skill_hash, paare = skills.komponieren(skill_namen, werte)
timeout = config.timeout_fuer(knoten, n)
infra_rest = config.INFRA_MAX_RETRIES
inhalt_rest = config.INHALT_MAX_RESTARTS
while True:
run = db.one("SELECT status FROM runs WHERE id=?", run_id)
if run and run["status"] != "running":
raise ManuellePause("Lauf nicht mehr running")
budget_pruefen(run_id)
if config.FAKE:
from . import fakes
t0 = time.monotonic()
rc, text, err = fakes.antwort(prompt)
log_call(run_id, stufe=stufe, knoten=knoten, item=item,
skills_liste=[p[0] for p in paare], skill_hash=skill_hash,
model="fake", role=role, status="ok" if rc == 0 else "error",
dur_ms=int((time.monotonic() - t0) * 1000),
meta={"err": err[:200]} if err else {},
prompt=prompt, antwort=text or err)
if rc != 0:
raise RuntimeError(f"fake: {err}")
return text
t0 = time.monotonic()
status, err, tokens, text = "error", "", {}, ""
hedge = False
try:
res, hedge = await _versuch(prompt, role, timeout)
tokens, err, text = res.tokens, res.err, res.text
if res.rc == 0:
status = "ok"
elif "stop=max_tokens" in err:
status = "cap"
elif _ist_infra(err):
status = "infra"
else:
status = "error"
except asyncio.CancelledError:
status, err = "pause", "abbruch"
raise
finally:
log_call(run_id, stufe=stufe, knoten=knoten, item=item,
skills_liste=[p[0] for p in paare], skill_hash=skill_hash,
model=config.ROLLEN.get(role, {}).get("model", ""), role=role,
status=status, dur_ms=int((time.monotonic() - t0) * 1000),
wait_ms=int(getattr(_versuch, "last_wait", 0) * 1000),
tokens=tokens, meta={"err": err[:300]} if err else {},
prompt=prompt, antwort=text or err)
if hedge: # Verlierer sichtbar machen — sonst fehlen Tokens/Kennzahlen
log_call(run_id, stufe=stufe, knoten=knoten, item=f"{item}-hedge",
skill_hash=skill_hash, role=role, status="hedge_cancel")
budget_pruefen(run_id)
if status == "ok":
return text
if status == "cap":
return None # deterministisch: gleicher Prompt läuft wieder ins Cap
if status == "infra":
infra_rest -= 1
if infra_rest < 0:
raise LaufPause(f"Infra erschöpft: {err[:120]}")
pause = config.INFRA_BACKOFF_BASE * 2 ** (
config.INFRA_MAX_RETRIES - 1 - infra_rest)
pause *= 1 + random.random() * 0.25 # Jitter
drossel_melden(pause)
await asyncio.sleep(pause)
continue
inhalt_rest -= 1
if inhalt_rest < 0:
return None