"""Zwei Wege zum Modell, je nach Aufgabe. `frage` geht über MiniMax im Anthropic-Messages-Format: die Prüfungen sind hundert Anfragen je Lauf und leben von der Temperaturstreuung — das muss billig und parallelisierbar sein. `frage_lokal` geht über die angemeldete Claude-CLI, dasselbe Abo wie die Agenten-Übergabe. Die Gespräche sind einzelne Aufrufe und leben von Urteilskraft; dort zählt Qualität, nicht Durchsatz. Welche Werkzeuge dabei offenstehen, entscheidet die Betriebsart, nicht das Modell: Planung liest den Projektcode, Recherche sucht im Web, und keine der beiden kann, was die andere darf. Die Prüfungen bekommen gar nichts — dort ist der Katalog die einzige zulässige Quelle. Kein Fallback zwischen beiden: fehlt ein Weg, bricht der Aufruf mit sichtbarer Meldung ab, statt still auf das schwächere Modell auszuweichen.""" import asyncio import json import httpx import config ZEITLIMIT = 180.0 MAX_TOKENS = 8000 API_VERSION = "2023-06-01" GRUNDLAGE = ( "claude", "-p", "--model", "opus", "--output-format", "json", # Spart ein Fünftel der Grundlast; was hier gilt, steht ohnehin im eigenen Prompt. "--exclude-dynamic-system-prompt-sections", # Ohne das erbt der Aufruf, was am Rechner sonst eingerichtet ist: MCP-Server, # Workflows, Hooks, ein fremder Antwortstil. Ein Gespräch über Feature-Karten hat # in keinem Postfach etwas zu suchen. "--safe-mode", ) # Was in keiner Betriebsart erlaubt ist. Beide Gespräche holen fremden Text in den # Kontext — das eine Suchtreffer, das andere Quelltext — und fremder Text darf keinen # Terminplan anlegen, keine Nachricht verschicken und keine Agenten starten. Was # künftig an Werkzeugen dazukommt, steht hier nicht: beim Nachziehen der CLI gehört # diese Liste geprüft. GESPERRT = ( "Write", "Edit", "NotebookEdit", "Bash", "Task", "TodoWrite", "Workflow", "Skill", "ReportFindings", "ScheduleWakeup", "DesignSync", "CronCreate", "CronDelete", "CronList", "RemoteTrigger", "PushNotification", "SendMessage", "Monitor", "EnterWorktree", "ExitWorktree", "TaskCreate", "TaskGet", "TaskList", "TaskOutput", "TaskStop", "TaskUpdate", ) def _kommando(erlaubt: tuple, gesperrt: tuple, zuege: int) -> tuple: """Ein Aufruf mit genau den Werkzeugen einer Betriebsart. `--allowed-tools` ist nicht bloß die Umkehrung von `--disallowed-tools`: ohne die Vorabfreigabe fragt die CLI je Suche und je Datei nach Erlaubnis und bekommt im Stapelbetrieb keine Antwort. Der Versuch endet dann als Absage, nicht als Fehler. `ToolSearch` steht nirgends: Suche, Abruf und Lesen sind nachladbar, ohne das Werkzeug käme das Modell gar nicht an sie heran. Was es nachlädt, ist trotzdem gesperrt — die Sperre wirkt beim Aufruf, nicht beim Suchen. """ return (*GRUNDLAGE, "--allowed-tools", *erlaubt, "--disallowed-tools", *GESPERRT, *gesperrt, "--max-turns", str(zuege)) # Planen heißt: an den Karten arbeiten, mit dem Code als Beleg. Das Web bleibt zu — # was das Projekt können soll, entscheidet die Person, nicht eine fremde Seite. PLANUNG_KOMMANDO = _kommando( ("Read", "Glob", "Grep"), ("WebSearch", "WebFetch"), 20) # Ohne eingestelltes Arbeitsverzeichnis gibt es nichts zu lesen. Lesen bliebe hier # nicht folgenlos: ohne gesetzten Ordner liefe die Suche im Serververzeichnis, und # das Modell beschriebe planer2 statt des Projekts. PLANUNG_OHNE_CODE = _kommando( (), ("Read", "Glob", "Grep", "WebSearch", "WebFetch"), 1) # Recherche geht nach draußen und nirgendwo sonst: kein Blick in Dateien, damit # nichts aus dem Projekt in einer Suchanfrage landet. RECHERCHE_KOMMANDO = _kommando( ("WebSearch", "WebFetch"), ("Read", "Glob", "Grep"), 24) # Opus denkt länger als MiniMax, der Prozessstart kostet extra, und Suchen wie # Codelesen legen mehrere Abrufe obendrauf. CHAT_ZEITLIMIT = 600.0 # Nur fürs Log: welcher Weg gerade lief. Ohne den Namen sagt eine Zeitangabe nichts, # denn dieselbe Sekundenzahl ist bei einem Zug teuer und bei zwanzig billig. WEGE = { PLANUNG_KOMMANDO: "planung+code", PLANUNG_OHNE_CODE: "planung", RECHERCHE_KOMMANDO: "recherche", } # Der Provider drosselt bei vielen gleichzeitigen Anfragen WIEDERHOLBAR = (429, 500, 502, 503, 504) WIEDERHOLUNGEN = 6 PAUSE = 2.0 # Länger als das zu warten hilft nicht mehr; dann ist die Drossel zu weit offen HOECHSTPAUSE = 60.0 class NichtKonfiguriert(ValueError): """Zugangsdaten fehlen. Eigener Typ, damit die Route das nicht mit „Prüfung läuft bereits" verwechselt — beides wäre sonst ein ValueError.""" def pruefe_konfiguration(): fehlend = [ name for name, wert in ( ("MINIMAX_API_KEY", config.MINIMAX_API_KEY), ("MINIMAX_MODELL", config.MINIMAX_MODELL), ("MINIMAX_URL", config.MINIMAX_URL), ) if not wert ] if fehlend: raise NichtKonfiguriert(f"Nicht konfiguriert: {', '.join(fehlend)} in .env fehlt") async def frage_lokal(system: str, inhalt: str, kommando: tuple = PLANUNG_OHNE_CODE, ordner: str | None = None) -> str: """Eine Runde über die Claude-CLI — der Text der Antwort. Der Prompt geht über die Standardeingabe: Katalog und Kartenstand sind zu lang, um sie als Kommandozeilenargument zu übergeben. Keine Temperatur — die CLI kennt keine; für einen einzelnen Zug braucht es auch keine Streuung. `ordner` ist das Verzeichnis, in dem der Aufruf läuft. Es entscheidet, was ein erlaubtes Lesen erreicht; ohne Angabe erbt der Prozess das Serververzeichnis, und das ist nur zulässig, solange gar nicht gelesen werden darf. """ try: prozess = await asyncio.create_subprocess_exec( *kommando, "--system-prompt", system, cwd=ordner, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) except FileNotFoundError: raise NichtKonfiguriert( "Claude-CLI nicht gefunden — „claude“ muss im Pfad liegen und angemeldet sein" ) try: aus, err = await asyncio.wait_for( prozess.communicate(inhalt.encode()), CHAT_ZEITLIMIT) except asyncio.TimeoutError: prozess.kill() raise RuntimeError(f"Claude antwortet nicht innerhalb von {CHAT_ZEITLIMIT:.0f} s") if prozess.returncode != 0: raise RuntimeError(f"Claude bricht ab: {err.decode()[-300:].strip() or 'ohne Meldung'}") try: daten = json.loads(aus) except ValueError: raise RuntimeError("Antwort der Claude-CLI ist kein JSON") if daten.get("is_error"): raise RuntimeError(f"Claude meldet einen Fehler: {daten.get('result')}") _notiere(kommando, daten) text = str(daten.get("result") or "") if not text.strip(): raise RuntimeError("Leere Antwort von Claude") return text def _notiere(kommando: tuple, daten: dict): """Wohin die Wartezeit ging — ins Serverlog, eine Zeile je Aufruf. Die CLI legt es ohnehin bei; ungelesen bleibt nur die Frage „wieso dauert das so lange" offen. Ein Zug ist ein Modellaufruf: jede Suche, jeder Abruf, jedes Lesen zählt einzeln. Sind die Züge hoch, hat das Modell gearbeitet; ist einer teuer, war der Prompt lang. """ zuege = daten.get("num_turns") or 0 api = (daten.get("duration_api_ms") or 0) / 1000 print(f"[claude] {WEGE.get(kommando, 'aufruf')}: {api:.0f}s, {zuege} Züge, " f"{(api / zuege if zuege else api):.0f}s/Zug, " f"${daten.get('total_cost_usd') or 0:.3f}", flush=True) def _pause(res, versuch: int) -> float: """Wie lange bis zum nächsten Versuch — der Provider darf es selbst sagen.""" gesagt = res.headers.get("retry-after") try: return min(float(gesagt), HOECHSTPAUSE) except (TypeError, ValueError): return min(PAUSE * (2 ** versuch), HOECHSTPAUSE) async def frage(system: str, inhalt: str, temperatur: float = 0.0) -> str: """Eine Anfrage, eine Antwort — der Text der Modellantwort. Kalt geroutet: Temperatur 0, kein Thinking; die Prüfung soll nicht raten. Für Mehrheitsentscheide wird sie bewusst angehoben, sonst wären die Stimmen gleich.""" pruefe_konfiguration() daten = { "model": config.MINIMAX_MODELL, "max_tokens": MAX_TOKENS, "system": system, "messages": [{"role": "user", "content": inhalt}], "temperature": temperatur, "thinking": {"type": "disabled"}, } async with httpx.AsyncClient(timeout=httpx.Timeout(ZEITLIMIT, connect=30)) as client: for versuch in range(WIEDERHOLUNGEN): res = await client.post( config.MINIMAX_URL, headers={"x-api-key": config.MINIMAX_API_KEY, "anthropic-version": API_VERSION}, json=daten, ) if res.status_code not in WIEDERHOLBAR or versuch == WIEDERHOLUNGEN - 1: break # Zu viele Anfragen oder Serverfehler: warten und noch einmal. # Ohne das fielen bei großen Läufen ganze Stimmen aus — und ein # fehlendes Urteil sah aus wie „nichts gefunden". await asyncio.sleep(_pause(res, versuch)) if res.status_code != 200: raise RuntimeError(f"Provider antwortet mit HTTP {res.status_code}") antwort = res.json() text = "".join( b.get("text", "") for b in antwort.get("content", []) if b.get("type") == "text" ) if not text.strip(): raise RuntimeError(f"Leere Antwort (stop_reason={antwort.get('stop_reason')})") return text