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
4 changed files with 181 additions and 10 deletions

View file

@ -446,6 +446,7 @@ Wo das Geld hingeht: rund zwei Drittel der Kosten sind **erzeugter Output**, dav
| `LLM_TIMEOUT` | Zeitlimit je LLM-Aufruf in Sekunden | 420 | | `LLM_TIMEOUT` | Zeitlimit je LLM-Aufruf in Sekunden | 420 |
| `LLM_REASONING_EFFORT` | `low`, `medium` oder `high` (OpenRouter) | Modellvorgabe | | `LLM_REASONING_EFFORT` | `low`, `medium` oder `high` (OpenRouter) | Modellvorgabe |
| `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des Anbieters in Tokens/s | 75 | | `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des Anbieters in Tokens/s | 75 |
| `OPENROUTER_SWITCH_BELOW` | liefert ein Aufruf (ab 1000 Tokens) weniger Tokens/s, wird der Anbieter für den Rest des Laufs gemieden; `0` schaltet das ab | wie `OPENROUTER_MIN_THROUGHPUT` (75) |
| `SOLVER_TIMEOUT` | Zeitlimit je Programmlauf in Sekunden; zugleich das Zeitbudget, das der Solver-Prompt dem Programm nennt | `60` | | `SOLVER_TIMEOUT` | Zeitlimit je Programmlauf in Sekunden; zugleich das Zeitbudget, das der Solver-Prompt dem Programm nennt | `60` |
| `SOLVER_SANDBOX` | `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` (ungeschützt) | `auto` | | `SOLVER_SANDBOX` | `auto` (bubblewrap, wenn vorhanden), `bwrap` (Pflicht), `aus` (ungeschützt) | `auto` |

View file

@ -113,6 +113,7 @@ Exit-Codes: `0` Ergebnis zuverlässig, `2` Ergebnis vorhanden, aber unzuverläss
| `LLM_MAX_TOKENS` | Obergrenze je Antwort (OpenRouter) | 32000 | | `LLM_MAX_TOKENS` | Obergrenze je Antwort (OpenRouter) | 32000 |
| `--max-restarts` / `MAX_RESTARTS` | komplette Neustarts, wenn das Modell nach allen Korrekturrunden fehlerhaft ist | 1 | | `--max-restarts` / `MAX_RESTARTS` | komplette Neustarts, wenn das Modell nach allen Korrekturrunden fehlerhaft ist | 1 |
| `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des OpenRouter-Anbieters (Tokens/s); Präferenz, kein harter Filter | 75 | | `OPENROUTER_MIN_THROUGHPUT` | bevorzugte Mindestgeschwindigkeit des OpenRouter-Anbieters (Tokens/s); Präferenz, kein harter Filter | 75 |
| `OPENROUTER_SWITCH_BELOW` | liefert ein Aufruf (ab 1000 Tokens) weniger Tokens/s, wird der Anbieter für den Rest des Laufs gemieden; `0` schaltet das ab | wie `OPENROUTER_MIN_THROUGHPUT` (75) |
| `LLM_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 | | `LLM_TIMEOUT` | Timeout je LLM-Aufruf (s) | 420 |
| `LLM_REASONING_EFFORT` | Denkaufwand des Modells über OpenRouter: `low`, `medium` oder `high` | Modellvorgabe | | `LLM_REASONING_EFFORT` | Denkaufwand des Modells über OpenRouter: `low`, `medium` oder `high` | Modellvorgabe |
| `SOLVER_TIMEOUT` | Zeitlimit je Programmlauf in Sekunden; zugleich das Zeitbudget, das der Solver-Prompt dem Programm nennt | `60` | | `SOLVER_TIMEOUT` | Zeitlimit je Programmlauf in Sekunden; zugleich das Zeitbudget, das der Solver-Prompt dem Programm nennt | `60` |

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, # 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). # 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) 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 LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "420")) # Sekunden je Aufruf
# Ohne Vorgabe schneiden manche OpenRouter-Anbieter die Antwort ab (beobachtet: abgebrochene # Ohne Vorgabe schneiden manche OpenRouter-Anbieter die Antwort ab (beobachtet: abgebrochene
# Modellspezifikation in einer Korrekturrunde). Großzügig setzen; bezahlt werden nur erzeugte Tokens. # 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) 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: def provider_for(model: str) -> str:
return "openrouter" if "/" in model else "gemini" 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 # 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. # je Aufruf (beobachtet: Modal, Together, Venice in einem Lauf) und kein Cache konnte greifen.
self.session_id = uuid.uuid4().hex self.session_id = uuid.uuid4().hex
self.ignored_providers = [] # in diesem Lauf zu langsame Anbieter (siehe OPENROUTER_SWITCH_BELOW)
def overrides(self) -> dict: def overrides(self) -> dict:
"""Stufen mit abweichendem Modell – für die Fortschrittsausgabe.""" """Stufen mit abweichendem Modell – für die Fortschrittsausgabe."""
return {step: m for step, m in self.step_models.items() if m != self.model} 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"): def create(self, response_model, messages, step: str = "unbenannt"):
model = self.step_models.get(step, self.model) model = self.step_models.get(step, self.model)
provider = provider_for(model) provider = provider_for(model)
kwargs = {"model": model, "response_model": response_model, "messages": messages} waits = list(RETRY_WAITS)
if provider == "openrouter": while True:
kwargs["max_tokens"] = LLM_MAX_TOKENS kwargs = self._kwargs(model, response_model, messages)
# 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):
started = time.monotonic() started = time.monotonic()
try: try:
result, completion = self._clients[provider].chat.completions.create_with_completion(**kwargs) result, completion = self._clients[provider].chat.completions.create_with_completion(**kwargs)
self._record(completion, step, time.monotonic() - started, model) self._record(completion, step, time.monotonic() - started, model)
if provider == "openrouter":
self._avoid_if_slow()
return result return result
except Exception as exc: 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 raise
wait = waits.pop(0)
print(f"Vorübergehender LLM-Fehler – neuer Versuch in {wait} s: {str(exc)[:120]}", file=sys.stderr) print(f"Vorübergehender LLM-Fehler – neuer Versuch in {wait} s: {str(exc)[:120]}", file=sys.stderr)
time.sleep(wait) 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: 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, before = (self.usage.completion_tokens, self.usage.reasoning_tokens, self.usage.prompt_tokens,
self.usage.cached_tokens) self.usage.cached_tokens)

View file

@ -23,6 +23,7 @@ def fake_session(model="deepseek/deepseek-v4.1-flash", step_models=None):
session.usage = LLMUsage(model=model, provider=session.provider) session.usage = LLMUsage(model=model, provider=session.provider)
session._lock = threading.Lock() session._lock = threading.Lock()
session.session_id = "lauf-1" session.session_id = "lauf-1"
session.ignored_providers = []
completions = {} completions = {}
session._clients = {} session._clients = {}
for provider in {session.provider, *(provider_for(m) for m in session.step_models.values())}: for provider in {session.provider, *(provider_for(m) for m in session.step_models.values())}:
@ -189,3 +190,111 @@ def test_missing_or_odd_model_field_is_ignored():
session._record(NS(provider=None, model=object(), usage=NS(prompt_tokens=1, completion_tokens=1, cost=None, session._record(NS(provider=None, model=object(), usage=NS(prompt_tokens=1, completion_tokens=1, cost=None,
completion_tokens_details=None, prompt_tokens_details=None))) completion_tokens_details=None, prompt_tokens_details=None)))
assert session.usage.responded_models == {} and session.usage.call_records[0].responded_model is None assert session.usage.responded_models == {} and session.usage.call_records[0].responded_model is None
def _antwort_mit_verbrauch(completion_tokens):
from types import SimpleNamespace as NS
return NS(provider="AtlasCloud", model="deepseek/deepseek-v4.1-flash",
usage=NS(prompt_tokens=8000, completion_tokens=completion_tokens, cost=0.02,
completion_tokens_details=NS(reasoning_tokens=completion_tokens - 500),
prompt_tokens_details=NS(cached_tokens=0)))
def test_truncated_answer_is_counted_before_the_retry(monkeypatch):
# beobachtet an wagner_whitin_dijkstra: an LLM_MAX_TOKENS abgeschnitten, bezahlt, aber nirgends gezählt
from instructor.core.exceptions import IncompleteOutputException
monkeypatch.setattr(llm, "RETRY_WAITS", (0,))
session, completions = fake_session()
versuche = iter([IncompleteOutputException(last_completion=_antwort_mit_verbrauch(32000)),
("ok", _antwort_mit_verbrauch(9000))])
def create_with_completion(**kwargs):
naechster = next(versuche)
if isinstance(naechster, Exception):
raise naechster
return naechster
completions["openrouter"].create_with_completion = create_with_completion
assert session.create(str, [{"role": "user", "content": "x"}], step="Solver-Code") == "ok"
assert session.usage.calls == 2 and session.usage.completion_tokens == 41000
assert session.usage.cost_usd == pytest.approx(0.04)
assert [r.step for r in session.usage.call_records] == ["Solver-Code (verworfen)", "Solver-Code"]
def test_rejected_attempts_are_counted_even_when_the_call_fails(monkeypatch):
from types import SimpleNamespace as NS
from instructor.core.exceptions import InstructorRetryException
session, completions = fake_session()
fehler = InstructorRetryException("Schema verletzt", last_completion=_antwort_mit_verbrauch(3000), n_attempts=1,
total_usage=0, failed_attempts=[NS(completion=_antwort_mit_verbrauch(3000))])
def create_with_completion(**kwargs):
raise fehler
completions["openrouter"].create_with_completion = create_with_completion
with pytest.raises(InstructorRetryException):
session.create(str, [{"role": "user", "content": "x"}], step="Prüfung")
assert session.usage.calls == 2 and session.usage.completion_tokens == 6000 # zwei verschiedene Antworten
def test_ordinary_errors_have_no_discarded_answers():
assert llm.discarded_completions(RuntimeError("Error code: 503")) == []
# --- Langsamen Anbieter nicht festhalten (OPENROUTER_SWITCH_BELOW) ---
def _aufruf(session, anbieter, tokens, sekunden):
from types import SimpleNamespace as NS
session._record(NS(provider=anbieter, usage=NS(prompt_tokens=100, completion_tokens=tokens, cost=None,
completion_tokens_details=None, prompt_tokens_details=None)),
step="Verstehen", seconds=sekunden)
session._avoid_if_slow()
def test_slow_provider_is_avoided_for_the_rest_of_the_run(monkeypatch):
# beobachtet: alle Aufrufe bei AtlasCloud mit ~65 Tokens/s, 1575 s statt ~150 s
monkeypatch.setattr(llm, "OPENROUTER_SWITCH_BELOW", 75)
session, completions = fake_session()
_aufruf(session, "AtlasCloud", 15201, 232.0)
assert session.ignored_providers == ["AtlasCloud"] and session.session_id != "lauf-1"
session.create(str, [{"role": "user", "content": "x"}], step="Extraktion")
body = completions["openrouter"].kwargs["extra_body"]
assert body["provider"]["ignore"] == ["AtlasCloud"] and body["session_id"] == session.session_id
@pytest.mark.parametrize("anbieter, tokens, sekunden", [
("Together", 15000, 60.0), # schnell
("Fireworks", 400, 20.0), # zu kurz, um die Rate zu beurteilen (Wartezeit bis zum ersten Token)
])
def test_fast_or_short_calls_keep_the_provider(monkeypatch, anbieter, tokens, sekunden):
monkeypatch.setattr(llm, "OPENROUTER_SWITCH_BELOW", 75)
session, _ = fake_session()
_aufruf(session, anbieter, tokens, sekunden)
assert session.ignored_providers == [] and session.session_id == "lauf-1"
def test_switching_can_be_disabled_and_is_capped(monkeypatch):
monkeypatch.setattr(llm, "OPENROUTER_SWITCH_BELOW", 0)
session, _ = fake_session()
_aufruf(session, "AtlasCloud", 15000, 230.0)
assert session.ignored_providers == []
monkeypatch.setattr(llm, "OPENROUTER_SWITCH_BELOW", 75)
for anbieter in ("A", "B", "C", "D"):
_aufruf(session, anbieter, 15000, 230.0)
assert session.ignored_providers == ["A", "B", "C"] # höchstens MAX_IGNORED_PROVIDERS
def test_no_endpoints_left_lifts_the_ignore_list():
session, completions = fake_session()
session.ignored_providers = ["AtlasCloud", "Parasail"]
gesendet = []
def create_with_completion(**kwargs):
gesendet.append(kwargs["extra_body"]["provider"].get("ignore"))
if len(gesendet) == 1:
raise RuntimeError("Error code: 404 - {'error': {'message': 'No endpoints found for deepseek/x.'}}")
return "ok", object()
completions["openrouter"].create_with_completion = create_with_completion
assert session.create(str, [{"role": "user", "content": "x"}], step="Prüfung") == "ok"
assert gesendet == [["AtlasCloud", "Parasail"], None] and session.ignored_providers == []