237 lines
9.3 KiB
Python
237 lines
9.3 KiB
Python
"""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, graph, skills
|
|
from .ledger import BudgetErschoepft, budget_pruefen, log_call # noqa: F401
|
|
|
|
|
|
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 = ""
|
|
cap: bool = False # stop=max_tokens — strukturiert, kein String-Match
|
|
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 config.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')})",
|
|
cap=daten.get("stop_reason") == "max_tokens",
|
|
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 = graph.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 res.cap:
|
|
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
|