text_to_solver_program/llm.py
dschlueter b74445b8d6 Erste öffentliche Fassung: Math Model Generator & Solver
Übersetzt natürlichsprachige Optimierungsaufgaben (Deutsch) per LLM in
eine geprüfte Modellspezifikation, erzeugt Solver-Code und führt ihn in
einer bubblewrap-Sandbox aus. Deterministische Prüfungen ohne LLM,
Report als Markdown/HTML, Benchmark mit 19 verifizierten Fällen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:42:58 +02:00

162 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""LLM-Zugriff: Client je Anbieter, Wiederholung bei vorübergehenden Fehlern, Nutzungs-/Kostenerfassung.
Anbieter wird am Modellnamen erkannt:
- mit "/" (z. B. "deepseek/deepseek-v4-flash-0731") -> OpenRouter, Key: OPENROUTER_API_KEY
- sonst (z. B. "gemini-2.5-flash") -> Google Gemini, Key: GEMINI_API_KEY
"""
import os
import sys
import threading
import time
import instructor
from model_schema import CallRecord, LLMUsage
DEFAULT_MODEL = os.environ.get("LLM_MODEL", "deepseek/deepseek-v4.1-flash")
# Modell je Pipeline-Stufe: ein starkes Modell für Extraktion/Prüfung, ein schnelles für den Solver-Code
# (oder umgekehrt, je nach Messung). Leer = das Modell der Session (LLM_MODEL bzw. --model).
STEP_MODEL_ENV = {
"Extraktion": "LLM_MODEL_EXTRACTION",
"Prüfung": "LLM_MODEL_REVIEW",
"Korrektur": "LLM_MODEL_REVISION",
"Solver-Code": "LLM_MODEL_SOLVER",
"Solver-Reparatur": "LLM_MODEL_SOLVER",
}
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# Ohne Vorgabe wählt OpenRouter den billigsten Anbieter – bei DeepSeek V4 teils nur ~20 Tokens/s,
# bei zehntausenden Reasoning-Tokens also Minuten pro Aufruf. Weiterhin preisorientiert, aber mit Mindestdurchsatz.
# Gemessen am 16.09.: Fireworks ~178 Tokens/s gegen Parasail ~60 Tokens/s beim selben Modell – Faktor 3.
# Es ist eine Präferenz, kein harter Filter: gibt es keinen so schnellen Anbieter mit JSON-Schema,
# leitet OpenRouter trotzdem weiter (geprüft mit qwen3-coder -> SiliconFlow, ~25 Tokens/s).
OPENROUTER_MIN_THROUGHPUT = int(os.environ.get("OPENROUTER_MIN_THROUGHPUT", "75")) # Tokens/s (p50)
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "420")) # Sekunden je Aufruf
# Ohne Vorgabe schneiden manche OpenRouter-Anbieter die Antwort ab (beobachtet: abgebrochene
# Modellspezifikation in einer Korrekturrunde). Großzügig setzen; bezahlt werden nur erzeugte Tokens.
LLM_MAX_TOKENS = int(os.environ.get("LLM_MAX_TOKENS", "32000"))
# Denkaufwand (OpenRouter): "low" | "medium" | "high"; leer = Modellvorgabe.
# Reasoning-Tokens machen den Großteil der Laufzeit aus (gemessen: 45.000 von 73.000 Output-Tokens).
REASONING_EFFORT = os.environ.get("LLM_REASONING_EFFORT", "").strip().lower()
# Wartezeiten in Sekunden bei vorübergehenden Fehlern (Überlastung, Minutenlimit, Verbindung)
RETRY_WAITS = (10, 30, 60)
# Fehler, bei denen Wiederholen nichts bringt, sondern nur Kontingent/Geld kostet
FATAL_MARKERS = ("RESOURCE_EXHAUSTED", "Error code: 401", "Error code: 402", "Error code: 403")
TRANSIENT_MARKERS = (
"UNAVAILABLE", "Error code: 429", "Error code: 500", "Error code: 502", "Error code: 503",
"Error code: 504", "Connection error", "timed out", "Request timed out",
"incomplete due to a max_tokens", "IncompleteOutputException", # anderer Anbieter kann mehr liefern
)
def is_fatal_error(message: str) -> bool:
"""Kontingent erschöpft, Guthaben leer oder Key ungültig."""
return any(marker in message for marker in FATAL_MARKERS)
def is_transient_error(message: str) -> bool:
return not is_fatal_error(message) and any(marker in message for marker in TRANSIENT_MARKERS)
def provider_for(model: str) -> str:
return "openrouter" if "/" in model else "gemini"
def model_for_step(step: str, default: str) -> str:
"""Modell dieser Pipeline-Stufe: Umgebungsvariable aus STEP_MODEL_ENV, sonst das Session-Modell."""
return os.environ.get(STEP_MODEL_ENV.get(step, ""), "").strip() or default
def _make_client(provider: str):
if provider == "openrouter":
from openai import OpenAI
return instructor.from_openai(
# max_retries=0: Wiederholungen übernimmt LLMSession.create (mit Fehlerklassifizierung)
OpenAI(base_url=OPENROUTER_BASE_URL, api_key=os.environ["OPENROUTER_API_KEY"],
timeout=LLM_TIMEOUT, max_retries=0),
mode=instructor.Mode.JSON_SCHEMA,
)
from google import genai
return instructor.from_genai(
client=genai.Client(), # liest GEMINI_API_KEY bzw. GOOGLE_API_KEY
mode=instructor.Mode.GENAI_STRUCTURED_OUTPUTS,
)
class LLMSession:
"""Ein Modell + Client; summiert Tokens und Kosten aller Aufrufe (thread-sicher)."""
def __init__(self, model: str = DEFAULT_MODEL):
self.model = model
self.provider = provider_for(model)
self.step_models = {step: model_for_step(step, model) for step in STEP_MODEL_ENV}
# Clients hier anlegen, damit create() thread-sicher bleibt; ohne Stufen-Modell ist es genau einer
self._clients = {provider_for(m): _make_client(provider_for(m)) for m in {model, *self.step_models.values()}}
self.client = self._clients[self.provider]
self.usage = LLMUsage(model=model, provider=self.provider)
self._lock = threading.Lock()
def overrides(self) -> dict:
"""Stufen mit abweichendem Modell – für die Fortschrittsausgabe."""
return {step: m for step, m in self.step_models.items() if m != self.model}
def create(self, response_model, messages, step: str = "unbenannt"):
model = self.step_models.get(step, self.model)
provider = provider_for(model)
kwargs = {"model": model, "response_model": response_model, "messages": messages}
if provider == "openrouter":
kwargs["max_tokens"] = LLM_MAX_TOKENS
# nur Anbieter wählen, die strukturierte Ausgabe (response_format) wirklich unterstützen
kwargs["extra_body"] = {"provider": {
"require_parameters": True,
"preferred_min_throughput": {"p50": OPENROUTER_MIN_THROUGHPUT},
}}
if REASONING_EFFORT:
kwargs["extra_body"]["reasoning"] = {"effort": REASONING_EFFORT}
for wait in (*RETRY_WAITS, None):
started = time.monotonic()
try:
result, completion = self._clients[provider].chat.completions.create_with_completion(**kwargs)
self._record(completion, step, time.monotonic() - started, model)
return result
except Exception as exc:
if wait is None or not is_transient_error(str(exc)):
raise
print(f"Vorübergehender LLM-Fehler – neuer Versuch in {wait} s: {str(exc)[:120]}", file=sys.stderr)
time.sleep(wait)
def _record(self, completion, step: str = "unbenannt", seconds: float = 0.0, model: str = None) -> None:
before = (self.usage.completion_tokens, self.usage.reasoning_tokens)
with self._lock:
self.usage.calls += 1
provider = getattr(completion, "provider", None) # OpenRouter: tatsächlich antwortender Anbieter
if provider:
self.usage.providers[provider] = self.usage.providers.get(provider, 0) + 1
usage = getattr(completion, "usage", None)
if usage is not None: # OpenAI-Format (OpenRouter)
self.usage.prompt_tokens += usage.prompt_tokens or 0
self.usage.completion_tokens += usage.completion_tokens or 0
details = getattr(usage, "completion_tokens_details", None)
self.usage.reasoning_tokens += getattr(details, "reasoning_tokens", 0) or 0
cost = getattr(usage, "cost", None)
if cost is not None:
self.usage.cost_usd = (self.usage.cost_usd or 0.0) + cost
self._log_call(step, seconds, before, getattr(completion, "provider", None), model)
return
meta = getattr(completion, "usage_metadata", None)
if meta is not None: # Gemini-Format, keine Kostenangabe
self.usage.prompt_tokens += meta.prompt_token_count or 0
self.usage.completion_tokens += meta.candidates_token_count or 0
self.usage.reasoning_tokens += meta.thoughts_token_count or 0
self._log_call(step, seconds, before, getattr(completion, "provider", None), model)
def _log_call(self, step, seconds, before, provider, model=None) -> None:
self.usage.call_records.append(CallRecord(
step=step, seconds=round(seconds, 1), provider=provider, model=model,
completion_tokens=self.usage.completion_tokens - before[0],
reasoning_tokens=self.usage.reasoning_tokens - before[1],
))