feat: Prompt-Cache nutzbar und messbar; Grundsatzfrage Einzelaufruf gegen Stufenkette

OpenRouter-Aufrufe eines Laufs tragen eine gemeinsame session_id (Sticky Routing,
damit der Anbieter-Cache greifen kann); Cache-Treffer werden je Aufruf und Stufe
erfasst und im Log als Anteil ausgewiesen. docs/Konzeptionelle_Fragen_und_Antworten.md
hält Pipeline-Stufen und die Abwägung ein Aufruf vs. Kette mit Messdaten und Quellen fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-09-17 15:44:37 +02:00
commit eadf0608ce

View file

@ -22,6 +22,7 @@ def fake_session(model="deepseek/deepseek-v4.1-flash", step_models=None):
session.step_models = step_models or {}
session.usage = LLMUsage(model=model, provider=session.provider)
session._lock = threading.Lock()
session.session_id = "lauf-1"
completions = {}
session._clients = {}
for provider in {session.provider, *(provider_for(m) for m in session.step_models.values())}:
@ -114,3 +115,45 @@ def test_create_uses_step_model_and_records_it():
def test_overrides_lists_only_deviating_steps():
session, _ = fake_session(model="a/b", step_models={"Extraktion": "a/b", "Solver-Code": "schnell/modell"})
assert session.overrides() == {"Solver-Code": "schnell/modell"}
# --- Prompt-Caching: Kennung je Lauf (Sticky Routing) und erfasste Cache-Treffer ---
def test_all_calls_of_a_run_share_one_openrouter_session_id():
session, completions = fake_session(step_models={"Solver-Code": "gemini-2.5-flash"})
session.create(str, [{"role": "user", "content": "x"}], step="Extraktion")
assert completions["openrouter"].kwargs["extra_body"]["session_id"] == "lauf-1"
session.create(str, [{"role": "user", "content": "x"}], step="Solver-Code")
assert "extra_body" not in completions["gemini"].kwargs # Gemini kennt den Parameter nicht
def test_new_session_gets_a_new_id(monkeypatch):
monkeypatch.setattr(llm, "_make_client", lambda provider: object())
assert llm.LLMSession("a/b").session_id != llm.LLMSession("a/b").session_id
def test_cached_tokens_are_recorded_per_call_and_step():
from types import SimpleNamespace as NS
session, _ = fake_session()
def antwort(prompt, cached):
return NS(provider="Modal", usage=NS(prompt_tokens=prompt, completion_tokens=100, cost=0.001,
completion_tokens_details=NS(reasoning_tokens=40),
prompt_tokens_details=NS(cached_tokens=cached)))
session._record(antwort(5000, 0), step="Prüfung")
session._record(antwort(5200, 4800), step="Prüfung") # zweite Runde liest den gleichen Präfix
session._record(NS(provider=None, usage=NS(prompt_tokens=900, completion_tokens=10, cost=None,
completion_tokens_details=None, prompt_tokens_details=None)),
step="Verstehen") # Anbieter ohne Cache-Angabe
assert session.usage.cached_tokens == 4800 and session.usage.prompt_tokens == 11100
assert [(r.prompt_tokens, r.cached_tokens) for r in session.usage.call_records] == [(5000, 0), (5200, 4800), (900, 0)]
stufen = session.usage.by_step()
assert stufen["Prüfung"]["prompt_tokens"] == 10200 and stufen["Prüfung"]["cached_tokens"] == 4800
def test_old_results_without_cache_fields_still_load():
from core.model_schema import LLMUsage
alt = {"model": "a/b", "provider": "openrouter", "calls": 1, "prompt_tokens": 10, "completion_tokens": 5,
"reasoning_tokens": 0, "call_records": [{"step": "Verstehen", "seconds": 1.0, "completion_tokens": 5}]}
usage = LLMUsage.model_validate(alt)
assert usage.cached_tokens == 0 and usage.call_records[0].cached_tokens == 0