fix: abgeschnittene Antworten meiden den Anbieter; LLM-Ausfall macht unzuverlässig statt abzustürzen

Grundlinie 20260918-191400: 9 von 22 Läufen hatten Aufrufe, die an LLM_MAX_TOKENS abgeschnitten
wurden (Venice 4/28, Makora 2/43, übrige 0/54); hard_portfolio_kardinalitaet brach nach vier
Abschneidungen ab, Kosten gingen verloren.

- Abschneiden: sofort neu versuchen, Anbieter per provider.ignore meiden (_avoid_provider,
  gemeinsam mit „zu langsam“)
- nach allen Wiederholungen LLMUnavailable (Stufe + Ursache); fatale Fehler bleiben unverpackt
- Solver-Stufe behält Modell und Prüfbericht (Solver-Fehler), Gegenprobe behält erstes Ergebnis,
  davor Ergebnis ohne Spezifikation mit Kosten („LLM-Fehler“, Report „Lauf abgebrochen“)
- Benchmark: Status „LLM-Fehler“ statt ABBRUCH, zählt nicht als ehrliches Scheitern
- TextToModel.py: Shebang

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-18 19:24:23 +02:00
commit 6b2b2c6466
2 changed files with 218 additions and 25 deletions

View file

@ -298,3 +298,60 @@ def test_no_endpoints_left_lifts_the_ignore_list():
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 == []
# --- Abschneiden am Tokenlimit: anderer Anbieter statt Warten, am Ende LLMUnavailable statt Absturz ---
def _abgeschnitten(anbieter):
from instructor.core.exceptions import IncompleteOutputException
antwort = _antwort_mit_verbrauch(32000)
antwort.provider = anbieter
return IncompleteOutputException(last_completion=antwort)
def test_truncation_avoids_the_provider_and_retries_without_waiting(monkeypatch):
# gemessen am 18.09.2026: Venice 4 von 28 Aufrufen abgeschnitten, alle übrigen Anbieter fast nie
gewartet = []
monkeypatch.setattr(llm.time, "sleep", gewartet.append)
session, completions = fake_session()
gesendet, versuche = [], iter([_abgeschnitten("Venice"), ("ok", _antwort_mit_verbrauch(9000))])
def create_with_completion(**kwargs):
gesendet.append((kwargs["extra_body"]["provider"].get("ignore"), kwargs["extra_body"]["session_id"]))
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 gewartet == [0]
assert gesendet[0] == (None, "lauf-1") and gesendet[1][0] == ["Venice"] and gesendet[1][1] != "lauf-1"
def test_exhausted_retries_raise_llm_unavailable(monkeypatch):
monkeypatch.setattr(llm.time, "sleep", lambda s: None)
session, completions = fake_session()
anbieter = iter(["Venice", "Makora", "Parasail", "Together"])
def create_with_completion(**kwargs):
raise _abgeschnitten(next(anbieter))
completions["openrouter"].create_with_completion = create_with_completion
with pytest.raises(llm.LLMUnavailable) as exc:
session.create(str, [{"role": "user", "content": "x"}], step="Extraktion")
assert exc.value.step == "Extraktion"
assert session.usage.calls == 4 # alle vier bezahlten Antworten gezählt
assert session.ignored_providers == ["Venice", "Makora", "Parasail"] # höchstens MAX_IGNORED_PROVIDERS
def test_fatal_errors_stay_unwrapped():
session, completions = fake_session()
def create_with_completion(**kwargs):
raise RuntimeError("Error code: 402 - {'error': {'message': 'Insufficient credits'}}")
completions["openrouter"].create_with_completion = create_with_completion
with pytest.raises(RuntimeError) as exc:
session.create(str, [{"role": "user", "content": "x"}])
assert not isinstance(exc.value, llm.LLMUnavailable)

View file

@ -55,7 +55,10 @@ class FakeSession:
warteschlange = self.queues[response_model]
if response_model is TaskUnderstanding and not warteschlange:
return EINFACHES_VERSTAENDNIS # Standard, solange ein Test nichts anderes vorgibt
return warteschlange.pop(0)
antwort = warteschlange.pop(0)
if isinstance(antwort, Exception): # gescheiterter Aufruf, z. B. LLMUnavailable
raise antwort
return antwort
HALLUCINATED = make_spec(executive_summary="Produktionsplanung mit Drehen und Fräsen") # Fremdproblem
@ -853,3 +856,50 @@ def test_second_repair_knows_the_first_error():
reparaturen = [inhalt for stufe, inhalt in session.contents if stufe == "Solver-Reparatur"]
assert "FRÜHERE FEHLVERSUCHE" not in reparaturen[0] # beim ersten gibt es noch keinen Verlauf
assert "Fehler B: Typ" in reparaturen[1] and "Versuch 1: " in reparaturen[1] and "Fehler A: Index" in reparaturen[1]
# --- LLM nach allen Wiederholungen gescheitert: unzuverlässiges Ergebnis statt Absturz ---
def _llm_weg(step):
from core.llm import LLMUnavailable
return LLMUnavailable(step, RuntimeError("The output is incomplete due to a max_tokens length limit."))
def test_llm_failure_in_solver_stage_keeps_the_model():
# beobachtet: hard_portfolio_kardinalitaet, viermal abgeschnitten ⇒ Absturz nach 1162 s, Kosten verloren
session = FakeSession([GOOD], [OK], [_llm_weg("Solver-Code")])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
assert not result.reliable and result.specification is not None and result.review is not None
assert result.solver_run.status == SolverStatus.ERROR and result.solver_code is None
assert result.reliability_issues[0].startswith("Solver-Fehler: LLM-Aufruf 'Solver-Code'")
assert result.usage is not None
def test_llm_failure_before_the_model_is_a_paid_non_result(tmp_path):
from benchmark.benchmark import observed_behaviors
from reporting.report import write_report
session = FakeSession([_llm_weg("Extraktion")], [])
result = run_pipeline("Aufgabe", session=session)
assert not result.reliable and not result.rejected and result.specification is None
assert result.reliability_issues[0].startswith("LLM-Fehler: ") and result.usage.calls == 2
assert observed_behaviors(result) == set() # ein Absturz ist kein ehrliches Scheitern
write_report(result, str(tmp_path / "r.md"))
text = (tmp_path / "r.md").read_text(encoding="utf-8")
assert "Lauf abgebrochen" in text and "abgelehnt" not in text
def test_benchmark_counts_an_llm_failure_as_failure_with_costs(monkeypatch):
from benchmark import benchmark
session = FakeSession([_llm_weg("Extraktion")], [])
monkeypatch.setattr(benchmark, "run_pipeline", lambda *a, **k: run_pipeline("Aufgabe", session=session))
zeile = benchmark.run_case({"name": "t", "task": "Aufgabe", "expected": {"objective_value": 9, "tolerance": 1e-6}},
"fake", 1)
assert zeile["solver_status"] == "LLM-Fehler" and zeile["llm_calls"] == 2 and "error" not in zeile
assert zeile["objective_ok"] is False and zeile["task_ok"] is False
def test_llm_failure_in_the_infeasible_recheck_keeps_the_first_result():
session = FakeSession(specs=[GOOD, _llm_weg("Extraktion")], reviews=[OK], codes=[SolverCode(code=INFEASIBLE_CODE)])
result = run_pipeline("Aufgabe", session=session, max_revisions=0, max_restarts=0)
assert result.solver_run.status == SolverStatus.INFEASIBLE and not result.infeasible_confirmed
assert not result.reliable and "Gegenprobe scheiterte am LLM" in result.reliability_issues[-1]