nutzung: Antwortzeiten, Fehler, Verlauf je Stunde und Ladevorgänge aus dem llama-swap-Journal

„Wer nutzt die Modelle“ war zu dünn (nur Anzahl je Absender). Das Journal gibt je Anfrage auch Status und
Dauer her — daraus rechnet die Box jetzt mehr, alles nur ergänzt; die alten Felder bleiben für den Flugplan
(letzte_24h) und für ältere Oberflächen.

- Je Absender: typische Antwortzeit (Median — einzelne 30-s-Wartezeiten bei Neustarts verziehen sonst den
  Schnitt), „9 von 10 bis“ (90-%-Wert als nächster Rang, also eine echte Antwortzeit), Rechenzeit (Summe aller
  Antwortzeiten), Fehler (Status nicht 2xx, je Code) und die letzte Anfrage. Go-Dauern werden samt ms/µs/ns
  und „1m2.3s“ gelesen.
- Fenster = die letzten 7 Kalendertage bis jetzt statt 7×24 h rollend: je_tag hat genau 7 Tage, heute als
  letzten, auch leere — alle Zahlen beziehen sich auf dasselbe Fenster.
- „stunden“: die letzten 24 h chronologisch mit Beginn (in UTC gerechnet, damit die Zeitumstellung keine
  Stunde verschluckt). letzte_24h bleibt Stunde des Tages wie bisher.
- „ladevorgaenge“: je Modell Anzahl und die letzten Zeitpunkte („Health check passed“).
- „reihe“: feste Reihenfolge aus der Namensliste, damit die Farbe dem Absender folgt und nicht dem Rang.
  NerdQuiz als größter Verbraucher zuerst (ruhiges Blau).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 13:26:31 +02:00
co-authored by Claude Opus 5.5
parent 0df593c05a
commit 7834de9592
2 changed files with 277 additions and 36 deletions
+120 -24
View File
@@ -1,40 +1,62 @@
"""
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026)
Wer nutzt die Modelle? (Box-Wart, Umbau 09/2026; ausgebaut am 25.09.2026)
llama-swap protokolliert jede Anfrage mit Absender-IP im Journal. Daraus zählen wir je
Absender und Tag die Chat-Anfragen, je Stunde der letzten 24 h, und wann welches Modell
zuletzt geladen wurde ("Health check passed").
llama-swap schreibt jede Anfrage ins Journal — Absender-IP, Pfad, Status, Antwortgröße, Programm und
am Zeilenende die Dauer als Go-Dauer ("388.09ms", "3.41s", "1m2.3s"):
... [INFO] Request 192.168.178.28 "POST /v1/chat/completions HTTP/1.1" 200 806 "Go-http-client/1.1" 388.09ms
Daraus rechnen wir für die letzten TAGE Kalendertage (heute zählt bis jetzt):
- je Absender die Chat-Anfragen, die Fehler (Status nicht 2xx, je Code), die typische Antwortzeit
(Median), den Wert, unter dem 9 von 10 Antworten blieben (90-%-Wert), die Summe aller Antwortzeiten
("Rechenzeit"; gleichzeitige Anfragen zählen einzeln) und die letzte Anfrage;
- den Verlauf je Tag und je Stunde der letzten 24 h, beides je Absender;
- wann welches Modell geladen wurde ("Health check passed").
Welches Modell eine Anfrage bedient hat, steht nicht in diesen Zeilen — das zeigen wir darum nicht.
Warum das Journal und nicht /api/metrics/activity: Letzteres hält nur die letzten rund 25
Anfragen. NerdQuiz schickt nachts in einer Stunde fast 600 — das wäre längst überschrieben,
bevor jemand nachsieht (gemessen am 23.09.2026).
Die Zuordnung IP → Name kommt aus MC_NUTZER_NAMEN ("ip=Name;ip=Name"); Loopback ist alles,
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst).
was über MC2 und den Gateway kommt (Lucy, OpenChamber, MC2 selbst). Die Reihenfolge der Namen
legt die Farbe im Cockpit fest ("reihe"): Ein Absender behält seine Farbe, auch wenn sich die
Rangfolge nach Anfragen ändert.
"""
import math
import os
import re
import statistics
import subprocess
import threading
import time
from collections import Counter, defaultdict
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from datetime import time as uhrzeit
from kern.zeit import LOCAL_TZ
CACHE_S = 600
TAGE = 7
LADEZEITEN = 10 # so viele Ladezeitpunkte je Modell (neueste zuerst) reichen der Anzeige
# Die Reihenfolge ist die Farbe (Blau, Magenta, Gelb): Der größte Verbraucher bekommt das ruhige Blau.
_STANDARD_NAMEN = {
"192.168.178.28": "NerdQuiz auf Arcane",
"127.0.0.1": "Lucy und OpenChamber über MC2",
"::1": "Lucy und OpenChamber über MC2",
"192.168.178.28": "NerdQuiz auf Arcane",
"192.168.178.22": "NerdQuiz-Tests vom PC",
}
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)')
# Status ist optional: Fehlt er (anderes Zeilenformat), zählt die Anfrage, aber nicht als Fehler.
_ANFRAGE = re.compile(r'\] Request (\S+) "POST (/v1/[a-z/_]+)[^"]*"(?: (\d{3}))?')
_GELADEN = re.compile(r"<([^>]+)> Health check passed")
# Go schreibt Mikrosekunden mit dem Mikro-Zeichen (U+00B5); das griechische My und "us" gehen auch.
_DAUER_TEIL = re.compile(r"(\d+(?:\.\d+)?)(h|ms|m|s|µs|μs|us|ns)")
_DAUER_FAKTOR = {"h": 3600.0, "m": 60.0, "s": 1.0, "ms": 1e-3, "µs": 1e-6, "μs": 1e-6, "us": 1e-6, "ns": 1e-9}
_lock = threading.Lock()
_cache: dict = {"ts": 0.0, "daten": None}
@@ -61,13 +83,44 @@ def _journal(seit: str) -> list[str]:
return out.stdout.splitlines()
def dauer_s(text: str) -> float | None:
"""Go-Dauer → Sekunden: "388.09ms" → 0.38809, "1m2.5s" → 62.5, "850µs" → 0.00085. Sonst None."""
teile = list(_DAUER_TEIL.finditer(text))
if not teile or "".join(t.group(0) for t in teile) != text:
return None
return sum(float(t.group(1)) * _DAUER_FAKTOR[t.group(2)] for t in teile)
def _kennwerte(dauern: list[float], summe: float) -> dict:
"""Typisch (Median) und 9 von 10 höchstens (90-%-Wert als nächster Rang, also eine echte
Antwortzeit), dazu die Summe — alles in Sekunden."""
werte = sorted(dauern)
return {
"dauer_typisch_s": round(statistics.median(werte), 3) if werte else None,
"dauer_p90_s": round(werte[math.ceil(0.9 * len(werte)) - 1], 3) if werte else None,
"dauer_summe_s": round(summe, 1),
}
def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict:
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Stunde, Modell."""
je_absender: Counter = Counter()
je_tag: dict[str, Counter] = defaultdict(Counter)
stunden: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender → Anzahl (letzte 24 h)
geladen: dict[str, str] = {}
"""Reine Auswertung (testbar): Journal-Zeilen → Nutzung je Absender, Tag, Stunde und Modell."""
jetzt = jetzt.astimezone(LOCAL_TZ)
tage = [jetzt.date() - timedelta(days=TAGE - 1 - i) for i in range(TAGE)]
beginn = datetime.combine(tage[0], uhrzeit(0), tzinfo=LOCAL_TZ)
# Stunden in UTC gerechnet, damit die Zeitumstellung keine Stunde verschluckt oder verdoppelt.
stunde0 = jetzt.astimezone(timezone.utc).replace(minute=0, second=0, microsecond=0) - timedelta(hours=23)
grenze_24h = jetzt - timedelta(hours=24)
anfragen: Counter = Counter()
fehler: dict[str, Counter] = defaultdict(Counter) # Absender → Status → Anzahl
dauern: dict[str, list[float]] = defaultdict(list) # nur erfolgreiche Antworten
summe: Counter = Counter() # alle Antwortzeiten in Sekunden
zuletzt: dict[str, datetime] = {}
je_tag: dict[str, Counter] = {t.isoformat(): Counter() for t in tage}
je_stunde = [Counter() for _ in range(24)] # chronologisch, die letzte ist die laufende
stunden_alt: dict[str, Counter] = defaultdict(Counter) # "HH" → Absender (für den Flugplan)
geladen: dict[str, list[datetime]] = defaultdict(list)
for z in zeilen:
try:
ts = datetime.fromisoformat(z.split(" ", 1)[0])
@@ -75,35 +128,78 @@ def werte_aus(zeilen: list[str], jetzt: datetime, namen: dict[str, str]) -> dict
continue
if ts.tzinfo is None:
ts = ts.replace(tzinfo=LOCAL_TZ)
if ts < beginn:
continue
if (m := _GELADEN.search(z)):
geladen[m.group(1)] = ts.isoformat()
geladen[m.group(1)].append(ts)
continue
m = _ANFRAGE.search(z)
if not m or not m.group(2).startswith("/v1/chat/completions"):
continue
wer = namen.get(m.group(1), m.group(1))
je_absender[wer] += 1
je_tag[ts.date().isoformat()][wer] += 1
anfragen[wer] += 1
ok = m.group(3) is None or m.group(3).startswith("2")
if not ok:
fehler[wer][m.group(3)] += 1
if (d := dauer_s(z.rsplit(None, 1)[-1])) is not None:
summe[wer] += d
if ok:
dauern[wer].append(d)
if wer not in zuletzt or ts > zuletzt[wer]:
zuletzt[wer] = ts
if (tag := ts.astimezone(LOCAL_TZ).date().isoformat()) in je_tag:
je_tag[tag][wer] += 1
if 0 <= (i := int((ts - stunde0).total_seconds() // 3600)) < 24:
je_stunde[i][wer] += 1
if ts >= grenze_24h:
stunden[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
stunden_alt[f"{ts.astimezone(LOCAL_TZ).hour:02d}"][wer] += 1
bekannt = list(dict.fromkeys(namen.values()))
fremde = [n for n, _ in anfragen.most_common() if n not in bekannt]
def reihe(name: str) -> int:
return bekannt.index(name) if name in bekannt else len(bekannt) + fremde.index(name)
alle_fehler: Counter = sum(fehler.values(), Counter())
zuletzt_geladen = {modell: max(zeiten) for modell, zeiten in geladen.items()}
return {
"tage": TAGE,
"absender": [{"name": n, "anfragen": c} for n, c in je_absender.most_common()],
"je_tag": {tag: dict(c) for tag, c in sorted(je_tag.items())},
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden.get(f"{h:02d}", {}))}
"seit": beginn.isoformat(),
"stand": jetzt.isoformat(timespec="seconds"),
"absender": [
{"name": n, "anfragen": c, "reihe": reihe(n),
"fehler": sum(fehler[n].values()), "fehler_codes": dict(fehler[n]),
**_kennwerte(dauern[n], summe[n]),
"zuletzt": zuletzt[n].isoformat()}
for n, c in anfragen.most_common()
],
"gesamt": {
"anfragen": sum(anfragen.values()),
"fehler": sum(alle_fehler.values()), "fehler_codes": dict(alle_fehler),
**_kennwerte([d for liste in dauern.values() for d in liste], sum(summe.values())),
},
"je_tag": {tag: dict(c) for tag, c in je_tag.items()},
"stunden": [{"beginn": (stunde0 + timedelta(hours=i)).astimezone(LOCAL_TZ).isoformat(),
"je_absender": dict(c)} for i, c in enumerate(je_stunde)],
"letzte_24h": [{"stunde": f"{h:02d}", "je_absender": dict(stunden_alt.get(f"{h:02d}", {}))}
for h in range(24)],
"zuletzt_geladen": geladen,
"zuletzt_geladen": {modell: t.isoformat() for modell, t in zuletzt_geladen.items()},
"ladevorgaenge": [
{"modell": modell, "anzahl": len(geladen[modell]),
"zeiten": [t.isoformat() for t in sorted(geladen[modell], reverse=True)[:LADEZEITEN]]}
for modell in sorted(zuletzt_geladen, key=zuletzt_geladen.__getitem__, reverse=True)
],
}
def nutzung() -> dict:
"""Gecachte Auswertung der letzten TAGE Tage (10 min)."""
"""Gecachte Auswertung der letzten TAGE Kalendertage (10 min)."""
with _lock:
if _cache["daten"] is not None and time.time() - _cache["ts"] < CACHE_S:
return _cache["daten"]
jetzt = datetime.now(LOCAL_TZ)
seit = (jetzt - timedelta(days=TAGE)).strftime("%Y-%m-%d %H:%M:%S")
daten = werte_aus(_journal(seit), jetzt, _namen())
seit = datetime.combine(jetzt.date() - timedelta(days=TAGE - 1), uhrzeit(0))
daten = werte_aus(_journal(seit.strftime("%Y-%m-%d %H:%M:%S")), jetzt, _namen())
with _lock:
_cache.update(ts=time.time(), daten=daten)
return daten
+157 -12
View File
@@ -1,7 +1,8 @@
"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23.09.2026)."""
"""Tests für die Auswertung des llama-swap-Journals — Zeilenformat wie auf der Box (23. und 25.09.2026)."""
from datetime import datetime
import pytest
from services import modell_nutzung
ZEILEN = [
@@ -19,18 +20,32 @@ ZEILEN = [
]
NAMEN = {"127.0.0.1": "Lucy und OpenChamber über MC2", "192.168.178.28": "NerdQuiz auf Arcane"}
JETZT = datetime.fromisoformat("2026-09-23T20:45:00+02:00")
def test_zaehlt_nur_chat_anfragen_je_absender():
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
assert d["absender"] == [
{"name": "NerdQuiz auf Arcane", "anfragen": 3},
{"name": "Lucy und OpenChamber über MC2", "anfragen": 1},
def anfrage(zeit: str, ip: str = "192.168.178.28", status: int = 200, dauer: str = "1s",
programm: str = "Go-http-client/1.1") -> str:
return (f'{zeit} tobisniceaiarbeitstier llama-swap[237863]: [INFO] Request {ip} '
f'"POST /v1/chat/completions HTTP/1.1" {status} 805 "{programm}" {dauer}')
def je_name(d: dict) -> dict:
return {a["name"]: a for a in d["absender"]}
def test_zaehlt_nur_chat_anfragen_der_letzten_sieben_tage_je_absender():
d = modell_nutzung.werte_aus(ZEILEN, JETZT, NAMEN)
# Der Lauf vom 10.09. liegt vor dem Fenster (17.09. 00:00 bis jetzt), Embeddings zählen nicht.
assert [(a["name"], a["anfragen"]) for a in d["absender"]] == [
("NerdQuiz auf Arcane", 2),
("Lucy und OpenChamber über MC2", 1),
]
assert d["seit"] == "2026-09-17T00:00:00+02:00"
assert d["gesamt"]["anfragen"] == 3
def test_letzte_24h_je_stunde_und_zuletzt_geladen():
d = modell_nutzung.werte_aus(ZEILEN, datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
d = modell_nutzung.werte_aus(ZEILEN, JETZT, NAMEN)
stunden = {s["stunde"]: s["je_absender"] for s in d["letzte_24h"]}
assert stunden["03"] == {"NerdQuiz auf Arcane": 2} # der Lauf vom 10.09. liegt außerhalb
assert stunden["07"] == {"Lucy und OpenChamber über MC2": 1}
@@ -38,8 +53,138 @@ def test_letzte_24h_je_stunde_und_zuletzt_geladen():
assert d["zuletzt_geladen"]["Qwen3.8-27B"].startswith("2026-09-17T19:59")
def test_unbekannte_ip_bleibt_als_ip_stehen():
zeile = ('2026-09-23T10:00:00+02:00 box llama-swap[1]: [INFO] Request 10.0.0.9 '
'"POST /v1/chat/completions HTTP/1.1" 200 1 "curl/8" 1s')
d = modell_nutzung.werte_aus([zeile], datetime.fromisoformat("2026-09-23T20:45:00+02:00"), NAMEN)
assert d["absender"] == [{"name": "10.0.0.9", "anfragen": 1}]
def test_unbekannte_ip_bleibt_als_ip_stehen_und_kommt_nach_den_bekannten():
zeile = anfrage("2026-09-23T10:00:00+02:00", ip="10.0.0.9", programm="curl/8")
d = modell_nutzung.werte_aus([zeile], JETZT, NAMEN)
assert [(a["name"], a["anfragen"], a["reihe"]) for a in d["absender"]] == [("10.0.0.9", 1, 2)]
@pytest.mark.parametrize(("text", "sekunden"), [
("388.092603ms", 0.388092603),
("3.416448406s", 3.416448406),
("31.899196571s", 31.899196571),
("1m2.5s", 62.5),
("2h0m0s", 7200.0),
("850µs", 0.00085),
("850μs", 0.00085),
("12ns", 0.000000012),
("0s", 0.0),
])
def test_dauer_liest_go_dauern(text, sekunden):
assert modell_nutzung.dauer_s(text) == pytest.approx(sekunden)
@pytest.mark.parametrize("text", ["", "abc", "805", "1.5x", "s", '"Go-http-client/1.1"', "1s2"])
def test_dauer_ohne_einheit_ist_keine_dauer(text):
assert modell_nutzung.dauer_s(text) is None
def test_antwortzeiten_fehler_und_letzte_anfrage_je_absender():
zeilen = [
anfrage("2026-09-23T09:00:00+02:00", dauer="400ms"),
anfrage("2026-09-23T09:01:00+02:00", dauer="1.2s"),
anfrage("2026-09-23T09:02:00+02:00", dauer="1m2s"),
anfrage("2026-09-23T09:03:00+02:00", status=502, dauer="31.9s"),
anfrage("2026-09-23T12:00:00+02:00", ip="127.0.0.1", status=499, dauer="5s", programm="python-httpx/0.28.1"),
anfrage("2026-09-23T12:01:00+02:00", ip="127.0.0.1", dauer="600ms", programm="python-httpx/0.28.1"),
# Ohne Dauer am Ende (anderes Format): zählt, geht aber in keine Zeit ein.
'2026-09-23T12:02:00+02:00 box llama-swap[1]: [INFO] Request 127.0.0.1 "POST /v1/chat/completions HTTP/1.1"',
]
d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN)
nq, lucy = je_name(d)["NerdQuiz auf Arcane"], je_name(d)["Lucy und OpenChamber über MC2"]
assert nq["anfragen"] == 4
assert nq["fehler"] == 1 and nq["fehler_codes"] == {"502": 1}
# Typisch und 9 von 10 nur aus den erfolgreichen Antworten; die Rechenzeit zählt alle.
assert nq["dauer_typisch_s"] == pytest.approx(1.2)
assert nq["dauer_p90_s"] == pytest.approx(62.0)
assert nq["dauer_summe_s"] == pytest.approx(0.4 + 1.2 + 62 + 31.9, abs=0.05)
assert nq["zuletzt"] == "2026-09-23T09:03:00+02:00"
assert lucy["anfragen"] == 3
assert lucy["fehler_codes"] == {"499": 1}
assert lucy["dauer_typisch_s"] == pytest.approx(0.6)
assert lucy["zuletzt"] == "2026-09-23T12:02:00+02:00"
assert d["gesamt"]["fehler"] == 2
assert d["gesamt"]["fehler_codes"] == {"502": 1, "499": 1}
assert d["gesamt"]["dauer_typisch_s"] == pytest.approx(0.9) # Median aus 0,4 · 0,6 · 1,2 · 62
assert d["gesamt"]["dauer_p90_s"] == pytest.approx(62.0)
def test_ohne_anfragen_bleiben_die_zeiten_leer():
d = modell_nutzung.werte_aus([], JETZT, NAMEN)
assert d["absender"] == []
assert d["gesamt"] == {"anfragen": 0, "fehler": 0, "fehler_codes": {}, "dauer_typisch_s": None,
"dauer_p90_s": None, "dauer_summe_s": 0.0}
assert list(d["je_tag"].values()) == [{}] * 7
assert all(s["je_absender"] == {} for s in d["stunden"])
def test_farbe_folgt_dem_absender_nicht_dem_rang():
"""Überholt Lucy NerdQuiz, bleibt die Reihe (= Farbe) gleich — sie kommt aus der Namensliste."""
zeilen = [anfrage(f"2026-09-23T10:0{i}:00+02:00", ip="127.0.0.1") for i in range(5)]
zeilen.append(anfrage("2026-09-23T11:00:00+02:00"))
namen = {"192.168.178.28": "NerdQuiz auf Arcane", "127.0.0.1": "Lucy", "::1": "Lucy", "192.168.178.22": "PC"}
d = modell_nutzung.werte_aus(zeilen, JETZT, namen)
assert [(a["name"], a["reihe"]) for a in d["absender"]] == [("Lucy", 1), ("NerdQuiz auf Arcane", 0)]
def test_verlauf_je_tag_deckt_sieben_kalendertage_bis_heute():
zeilen = [
anfrage("2026-09-16T23:59:59+02:00"), # vor dem Fenster
anfrage("2026-09-17T00:00:00+02:00"),
anfrage("2026-09-20T22:30:00+00:00"), # in UTC geschrieben: in Berlin schon der 21.09.
anfrage("2026-09-23T20:00:00+02:00", ip="127.0.0.1"),
]
d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN)
assert list(d["je_tag"]) == [f"2026-09-{t}" for t in range(17, 24)]
assert d["je_tag"]["2026-09-17"] == {"NerdQuiz auf Arcane": 1}
assert d["je_tag"]["2026-09-20"] == {}
assert d["je_tag"]["2026-09-21"] == {"NerdQuiz auf Arcane": 1}
assert d["je_tag"]["2026-09-23"] == {"Lucy und OpenChamber über MC2": 1}
def test_stunden_laufen_chronologisch_bis_zur_laufenden_stunde():
zeilen = [
anfrage("2026-09-22T20:59:00+02:00"), # vor der ersten Stunde (21:00 gestern)
anfrage("2026-09-22T21:10:00+02:00"),
anfrage("2026-09-23T00:30:00+02:00", ip="127.0.0.1"),
anfrage("2026-09-23T20:44:00+02:00"),
]
d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN)
stunden = d["stunden"]
assert len(stunden) == 24
assert stunden[0]["beginn"] == "2026-09-22T21:00:00+02:00"
assert stunden[-1]["beginn"] == "2026-09-23T20:00:00+02:00"
assert stunden[0]["je_absender"] == {"NerdQuiz auf Arcane": 1}
assert stunden[3]["je_absender"] == {"Lucy und OpenChamber über MC2": 1}
assert stunden[-1]["je_absender"] == {"NerdQuiz auf Arcane": 1}
def test_stunden_ueber_die_zeitumstellung():
"""Am 25.10.2026 gibt es 02:00 zweimal — die Stunden bleiben 24 echte Stunden."""
d = modell_nutzung.werte_aus([], datetime.fromisoformat("2026-10-25T12:30:00+01:00"), NAMEN)
beginne = [s["beginn"] for s in d["stunden"]]
assert beginne[0] == "2026-10-24T14:00:00+02:00"
assert "2026-10-25T02:00:00+02:00" in beginne and "2026-10-25T02:00:00+01:00" in beginne
assert beginne[-1] == "2026-10-25T12:00:00+01:00"
def test_ladevorgaenge_je_modell_neueste_zuerst():
geladen = "box llama-swap[1]: [INFO] <{}> Health check passed on http://localhost:5804/health"
zeilen = [
f"2026-09-20T06:09:02+02:00 {geladen.format('Qwen3.6-35B-A3B')}",
f"2026-09-21T14:57:07+02:00 {geladen.format('Qwen3-VL-30B-A3B-Instruct')}",
f"2026-09-23T11:25:09+02:00 {geladen.format('Qwen3.6-35B-A3B')}",
f"2026-09-23T11:25:46+02:00 {geladen.format('Qwen3.6-35B-A3B')}",
f"2026-09-10T11:25:46+02:00 {geladen.format('Altes-Modell')}", # vor dem Fenster
]
d = modell_nutzung.werte_aus(zeilen, JETZT, NAMEN)
assert d["ladevorgaenge"] == [
{"modell": "Qwen3.6-35B-A3B", "anzahl": 3,
"zeiten": ["2026-09-23T11:25:46+02:00", "2026-09-23T11:25:09+02:00", "2026-09-20T06:09:02+02:00"]},
{"modell": "Qwen3-VL-30B-A3B-Instruct", "anzahl": 1, "zeiten": ["2026-09-21T14:57:07+02:00"]},
]
assert d["zuletzt_geladen"] == {"Qwen3.6-35B-A3B": "2026-09-23T11:25:46+02:00",
"Qwen3-VL-30B-A3B-Instruct": "2026-09-21T14:57:07+02:00"}