5aded967ef
Lucys Augen-Waermer (alle 10 min, max_tokens=1 an "vision"), warmup.sh und models/load enden konstruktionsbedingt mit finish_reason=length — die Warnung vom 15.07. hielt jeden Ping fuer einen abgerissenen Worker und spammte den Briefkasten (~alle 20 min, Serie 16.07. abends). Wer freiwillig auf <=16 Tokens deckelt, will keine echte Antwort -> keine Warnung; echte Abrisse melden weiter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
79 lines
3.5 KiB
Python
79 lines
3.5 KiB
Python
"""Token-Erfassung für den Builtin-Gateway.
|
|
|
|
Parst die `usage`-Felder aus llama-swap-Antworten (Stream + Non-Stream) und meldet
|
|
sie an token_stats. Hält den gateway_proxy-Router dünn und ersetzt die zuvor inline
|
|
verstreute, still scheiternde String-Suche durch einen testbaren SSE-Zeilenparser.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import time
|
|
|
|
from services.token_stats import increment_tokens
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Kontext-Limit-Warnung (User-Wunsch 15.07.: „es sollte eine Warnung geben — Kontext ist
|
|
# nicht unendlich"): finish_reason=length heißt, eine Antwort ist am Token-/Kontext-Budget
|
|
# ABGERISSEN — bei Nacht-Workern stirbt damit still die halbe Arbeit (4 Worker am 15.07.).
|
|
# Statt still: Eintrag in den Briefkasten (silent → Chronik/Panel, kein Sprach-Spam),
|
|
# je Modell höchstens alle 10 min. Läuft im mc2-gateway-Prozess → announce liefert per
|
|
# MC_ANNOUNCE_HTTP beim Steuerpult ab (Unit-Env), nie direkt in die Store-Datei.
|
|
_trunc_last: dict[str, float] = {}
|
|
_TRUNC_EVERY = 600.0 # s
|
|
|
|
# Warm-Pings (Lucys Augen-Wärmer alle 10 min, warmup.sh, models/load) halten Modelle
|
|
# ABSICHTLICH mit max_tokens=1 warm — deren finish_reason=length ist konstruktionsbedingt
|
|
# und kein abgerissener Worker (Fehlalarm-Serie 16.07. abends, ~alle 20 min im Briefkasten).
|
|
# Wer freiwillig so knapp deckelt, will keine echte Antwort → keine Warnung.
|
|
_PING_MAXTOK = 16
|
|
|
|
|
|
def warn_truncation(model: str, max_tokens: int | None = None) -> None:
|
|
if isinstance(max_tokens, int) and max_tokens <= _PING_MAXTOK:
|
|
return
|
|
now = time.time()
|
|
if now - _trunc_last.get(model, 0.0) < _TRUNC_EVERY:
|
|
return
|
|
_trunc_last[model] = now
|
|
log.warning("gateway: finish_reason=length bei %s — Antwort am Kontext-/Token-Limit abgerissen", model)
|
|
try:
|
|
from services import announce
|
|
announce.add(
|
|
f"Eine Antwort von „{model}“ ist am Token-/Kontext-Limit abgerissen "
|
|
f"(finish_reason=length). War das ein Nacht-Worker, ist seine Aufgabe zu groß "
|
|
f"geschnitten — besser in Etappen teilen (eine Etappe = ein Worker-Lauf).",
|
|
"[Kontext-Limit]", "gateway", "silent")
|
|
except Exception:
|
|
log.warning("gateway: Kontext-Limit-Warnung nicht zustellbar", exc_info=True)
|
|
|
|
|
|
def record_usage(usage: dict | None, model: str) -> None:
|
|
"""Ein usage-Objekt verbuchen (no-op bei None/leer)."""
|
|
if not usage:
|
|
return
|
|
prompt = usage.get("prompt_tokens", 0)
|
|
completion = usage.get("completion_tokens", 0)
|
|
if prompt or completion:
|
|
increment_tokens(prompt, completion, model=model)
|
|
|
|
|
|
def record_stream_chunk(chunk: bytes, model: str, max_tokens: int | None = None) -> None:
|
|
"""Rohen SSE-Chunk auf `usage` prüfen und Tokens verbuchen. Fehler werden
|
|
geloggt (debug) statt verschluckt — ein defekter Chunk bricht den Stream nicht."""
|
|
if b'"finish_reason":"length"' in chunk or b'"finish_reason": "length"' in chunk:
|
|
warn_truncation(model, max_tokens)
|
|
if b'"usage"' not in chunk:
|
|
return
|
|
text = chunk.decode("utf-8", errors="ignore")
|
|
for line in text.splitlines():
|
|
if not line.startswith("data:"):
|
|
continue
|
|
data_str = line[5:].strip()
|
|
if not data_str or data_str == "[DONE]":
|
|
continue
|
|
try:
|
|
record_usage(json.loads(data_str).get("usage"), model)
|
|
except json.JSONDecodeError:
|
|
log.debug("gateway stream: usage-Parsing fehlgeschlagen: %s", data_str[:120])
|