"""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