feat: verworfene Antworten mitzählen, langsamen Anbieter nicht festhalten

- discarded_completions: an LLM_MAX_TOKENS abgeschnittene oder am Schema gescheiterte
  Antworten sind bezahlt und zählen jetzt als Stufe „<Stufe> (verworfen)“ in Kosten,
  Tokens und Aufrufen (wagner_whitin_dijkstra: ~32.000 Tokens fehlten).
- OPENROUTER_SWITCH_BELOW (Default = OPENROUTER_MIN_THROUGHPUT, 75): liefert ein Aufruf
  ab 1000 Tokens weniger Tokens/s, wird der Anbieter per provider.ignore für den Rest
  des Laufs gemieden (höchstens 3) und eine neue session_id vergeben; „No endpoints
  found“ hebt die Sperrliste auf. Anlass: Sticky Routing hielt einen Lauf 1575 s bei
  ~65 Tokens/s (Median über 1012 Aufrufe: 156). provider.ignore mit echtem Aufruf geprüft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-18 15:05:05 +02:00
commit 6890c0f272

View file

@ -35,6 +35,14 @@ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# 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)
# Sticky Routing (session_id) hält einen Lauf bei einem Anbieter – auch bei einem langsamen. Beobachtet an
# wagner_whitin_dijkstra (18.09.2026): alle Aufrufe bei AtlasCloud mit ~65 Tokens/s, 1575 s statt ~150 s. Liefert
# ein Aufruf mit mindestens SWITCH_MIN_TOKENS weniger als diese Rate, wird der Anbieter für den Rest des Laufs
# gemieden und eine neue session_id vergeben. Gemessen an 1012 Aufrufen: Median 156 Tokens/s, 3,8 % unter 75.
# 0 schaltet das ab. Der Cache der Folgeaufrufe geht dabei verloren – er bringt nur bei wiederholten Stufen etwas.
OPENROUTER_SWITCH_BELOW = int(os.environ.get("OPENROUTER_SWITCH_BELOW", str(OPENROUTER_MIN_THROUGHPUT)))
SWITCH_MIN_TOKENS = 1000 # darunter misst die Rate vor allem die Wartezeit bis zum ersten Token
MAX_IGNORED_PROVIDERS = 3 # sonst bliebe womöglich kein Anbieter mit JSON-Schema übrig
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.
@ -64,6 +72,25 @@ def is_transient_error(message: str) -> bool:
return not is_fatal_error(message) and any(marker in message for marker in TRANSIENT_MARKERS)
def discarded_completions(exc: BaseException) -> list:
"""Antworten, die ein Fehler verworfen hat – bezahlt, aber ohne Ergebnis.
instructor hängt sie an: `IncompleteOutputException.last_completion` (an LLM_MAX_TOKENS abgeschnitten) und
`InstructorRetryException.failed_attempts[*].completion` (Schema verletzt). Beobachtet an wagner_whitin_dijkstra
(18.09.2026): eine abgeschnittene Solver-Antwort mit ~32.000 Tokens fehlte in Kosten und Tokenzählung.
"""
gefunden = []
for fehler in (exc, exc.__cause__):
if fehler is None:
continue
kandidaten = [getattr(fehler, "last_completion", None)]
kandidaten += [getattr(a, "completion", None) for a in getattr(fehler, "failed_attempts", None) or []]
for c in kandidaten:
if c is not None and all(c is not g for g in gefunden):
gefunden.append(c)
return gefunden
def provider_for(model: str) -> str:
return "openrouter" if "/" in model else "gemini"
@ -107,37 +134,70 @@ class LLMSession:
# Routing), dessen Prompt-Cache wiederholte Präfixe billiger liest. Ohne sie wechselten die Anbieter
# je Aufruf (beobachtet: Modal, Together, Venice in einem Lauf) und kein Cache konnte greifen.
self.session_id = uuid.uuid4().hex
self.ignored_providers = [] # in diesem Lauf zu langsame Anbieter (siehe OPENROUTER_SWITCH_BELOW)
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 _kwargs(self, model: str, response_model, messages) -> dict:
kwargs = {"model": model, "response_model": response_model, "messages": messages}
if provider_for(model) == "openrouter":
kwargs["max_tokens"] = LLM_MAX_TOKENS
# nur Anbieter wählen, die strukturierte Ausgabe (response_format) wirklich unterstützen
anbieter = {"require_parameters": True, "preferred_min_throughput": {"p50": OPENROUTER_MIN_THROUGHPUT}}
if self.ignored_providers:
anbieter["ignore"] = list(self.ignored_providers)
kwargs["extra_body"] = {"provider": anbieter, "session_id": self.session_id}
if REASONING_EFFORT:
kwargs["extra_body"]["reasoning"] = {"effort": REASONING_EFFORT}
return kwargs
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},
}, "session_id": self.session_id}
if REASONING_EFFORT:
kwargs["extra_body"]["reasoning"] = {"effort": REASONING_EFFORT}
for wait in (*RETRY_WAITS, None):
waits = list(RETRY_WAITS)
while True:
kwargs = self._kwargs(model, response_model, messages)
started = time.monotonic()
try:
result, completion = self._clients[provider].chat.completions.create_with_completion(**kwargs)
self._record(completion, step, time.monotonic() - started, model)
if provider == "openrouter":
self._avoid_if_slow()
return result
except Exception as exc:
if wait is None or not is_transient_error(str(exc)):
# Verworfene Antworten sind bezahlt – sonst fehlen sie in Kosten, Tokens und Aufrufzahl
for verworfen in discarded_completions(exc):
self._record(verworfen, f"{step} (verworfen)", time.monotonic() - started, model)
if "No endpoints found" in str(exc) and self.ignored_providers:
# die Sperrliste hat alle passenden Anbieter ausgeschlossen – lieber langsam als gar nicht
print(f"Kein Anbieter ohne {self.ignored_providers} verfügbar – Sperrliste aufgehoben.",
file=sys.stderr)
self.ignored_providers.clear()
continue
if not waits or not is_transient_error(str(exc)):
raise
wait = waits.pop(0)
print(f"Vorübergehender LLM-Fehler – neuer Versuch in {wait} s: {str(exc)[:120]}", file=sys.stderr)
time.sleep(wait)
def _avoid_if_slow(self) -> None:
"""War der letzte Aufruf zu langsam, den Anbieter für den Rest des Laufs meiden (siehe OPENROUTER_SWITCH_BELOW)."""
if OPENROUTER_SWITCH_BELOW <= 0 or not self.usage.call_records:
return
letzter = self.usage.call_records[-1]
if (not letzter.provider or letzter.completion_tokens < SWITCH_MIN_TOKENS or letzter.seconds <= 0
or letzter.provider in self.ignored_providers or len(self.ignored_providers) >= MAX_IGNORED_PROVIDERS):
return
rate = letzter.completion_tokens / letzter.seconds
if rate >= OPENROUTER_SWITCH_BELOW:
return
self.ignored_providers.append(letzter.provider)
self.session_id = uuid.uuid4().hex # Sticky Routing an den neuen Anbieter binden
print(f"Anbieter {letzter.provider} zu langsam ({rate:.0f} Tokens/s < {OPENROUTER_SWITCH_BELOW}) – "
f"für den Rest des Laufs gemieden.", file=sys.stderr)
def _record(self, completion, step: str = "unbenannt", seconds: float = 0.0, model: str = None) -> None:
before = (self.usage.completion_tokens, self.usage.reasoning_tokens, self.usage.prompt_tokens,
self.usage.cached_tokens)