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:
parent
20ac3f14d7
commit
6890c0f272
1 changed files with 181 additions and 10 deletions
|
|
@ -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._lock = threading.Lock()
|
||||
session.session_id = "lauf-1"
|
||||
session.ignored_providers = []
|
||||
completions = {}
|
||||
session._clients = {}
|
||||
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,
|
||||
completion_tokens_details=None, prompt_tokens_details=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 == []
|
||||
|
|
|
|||
Loading…
Reference in a new issue