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>
This commit is contained in:
commit
b74445b8d6
1 changed files with 7655 additions and 0 deletions
162
llm.py
Normal file
162
llm.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""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],
|
||||
))
|
||||
Loading…
Reference in a new issue