Refactor: Zentrales Logging + robuste Token-Erfassung (Phase 2)
- app.py: logging.basicConfig (MC_LOG_LEVEL, INFO default) als eine Konfiguration für alle Module. - Neuer services/gateway_stream.py: SSE-/Non-Stream-usage-Parsing aus dem gateway_proxy-Router extrahiert; robuster Zeilenparser mit Debug-Logging statt verschluckter Exceptions. Router ist jetzt dünn. - token_stats.py: In-Memory-Cache + gedrosseltes Flushen (5s) + atexit-Flush statt Write-pro-Request; atomarer Write (.tmp -> replace); thread-safe. - agent.py/discover.py: stille `except Exception: pass` durch gezieltes log.debug/warning ersetzt; ungenutzten yaml-Import entfernt. Verifiziert: Stream-Parsing (Summen + per-Modell), malformed-Chunk übersteht, flush schreibt; app importiert sauber. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,56 +1,99 @@
|
||||
"""Token-Statistik (Verbrauch je Modell) mit gedrosseltem Persistieren.
|
||||
|
||||
Früher wurde bei JEDEM Request die komplette JSON-Datei gelesen und geschrieben
|
||||
(Disk-Thrash). Jetzt: einmaliges Laden in einen In-Memory-Cache, Inkremente laufen
|
||||
gegen den Cache, Persistieren passiert höchstens alle FLUSH_INTERVAL Sekunden sowie
|
||||
beim Prozess-Ende (atexit). Lesen liefert immer den aktuellen (auch ungeflushten) Stand.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from config import HERMES_HOME
|
||||
|
||||
STATS_FILE = HERMES_HOME / "token_stats.json"
|
||||
FLUSH_INTERVAL = 5.0 # Sekunden zwischen Disk-Writes
|
||||
# Baseline (repräsentiert Verbrauch vor dem modellspezifischen Logging).
|
||||
_BASELINE = {"prompt_tokens": 718400, "completion_tokens": 324200, "models": {}}
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_stats: dict | None = None
|
||||
_dirty = False
|
||||
_last_flush = 0.0
|
||||
|
||||
|
||||
def _load_from_disk() -> dict:
|
||||
if not STATS_FILE.exists():
|
||||
return dict(_BASELINE)
|
||||
try:
|
||||
with open(STATS_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
data.setdefault("prompt_tokens", 0)
|
||||
data.setdefault("completion_tokens", 0)
|
||||
data.setdefault("models", {})
|
||||
return data
|
||||
except (OSError, json.JSONDecodeError):
|
||||
log.warning("token_stats: Laden fehlgeschlagen, nutze Baseline", exc_info=True)
|
||||
return dict(_BASELINE)
|
||||
|
||||
|
||||
def _ensure_loaded() -> dict:
|
||||
global _stats
|
||||
if _stats is None:
|
||||
_stats = _load_from_disk()
|
||||
return _stats
|
||||
|
||||
|
||||
def _write(stats: dict) -> None:
|
||||
try:
|
||||
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATS_FILE.with_suffix(".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(stats, f)
|
||||
tmp.replace(STATS_FILE)
|
||||
except OSError:
|
||||
log.warning("token_stats: Schreiben fehlgeschlagen", exc_info=True)
|
||||
|
||||
|
||||
def get_stats() -> dict:
|
||||
if not STATS_FILE.exists():
|
||||
# Initialize stats with a nice baseline (e.g., representing previous usage)
|
||||
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
default_stats = {
|
||||
"prompt_tokens": 718400,
|
||||
"completion_tokens": 324200
|
||||
}
|
||||
try:
|
||||
with open(STATS_FILE, "w") as f:
|
||||
json.dump(default_stats, f)
|
||||
except Exception:
|
||||
return default_stats
|
||||
return default_stats
|
||||
|
||||
try:
|
||||
with open(STATS_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
# Ensure keys exist
|
||||
if "prompt_tokens" not in data:
|
||||
data["prompt_tokens"] = 0
|
||||
if "completion_tokens" not in data:
|
||||
data["completion_tokens"] = 0
|
||||
if "models" not in data:
|
||||
data["models"] = {}
|
||||
return data
|
||||
except Exception:
|
||||
return {"prompt_tokens": 0, "completion_tokens": 0, "models": {}}
|
||||
"""Aktueller Stand (inkl. noch nicht geflushter Inkremente) als Kopie."""
|
||||
with _lock:
|
||||
return json.loads(json.dumps(_ensure_loaded()))
|
||||
|
||||
def save_stats(stats: dict):
|
||||
try:
|
||||
STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(STATS_FILE, "w") as f:
|
||||
json.dump(stats, f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def increment_tokens(prompt: int, completion: int, model: str = None):
|
||||
stats = get_stats()
|
||||
stats["prompt_tokens"] += prompt
|
||||
stats["completion_tokens"] += completion
|
||||
if model:
|
||||
model = model.lower()
|
||||
if "models" not in stats:
|
||||
stats["models"] = {}
|
||||
if model not in stats["models"]:
|
||||
stats["models"][model] = {"prompt": 0, "completion": 0}
|
||||
stats["models"][model]["prompt"] += prompt
|
||||
stats["models"][model]["completion"] += completion
|
||||
save_stats(stats)
|
||||
def increment_tokens(prompt: int, completion: int, model: str | None = None) -> None:
|
||||
"""Tokens im Cache verbuchen; gedrosselt auf Disk persistieren."""
|
||||
global _dirty, _last_flush
|
||||
with _lock:
|
||||
stats = _ensure_loaded()
|
||||
stats["prompt_tokens"] += prompt
|
||||
stats["completion_tokens"] += completion
|
||||
if model:
|
||||
m = stats.setdefault("models", {}).setdefault(
|
||||
model.lower(), {"prompt": 0, "completion": 0})
|
||||
m["prompt"] += prompt
|
||||
m["completion"] += completion
|
||||
_dirty = True
|
||||
now = time.monotonic()
|
||||
if now - _last_flush >= FLUSH_INTERVAL:
|
||||
_write(stats)
|
||||
_dirty = False
|
||||
_last_flush = now
|
||||
|
||||
|
||||
def flush() -> None:
|
||||
"""Ungeschriebene Inkremente sofort persistieren (z.B. beim Shutdown)."""
|
||||
global _dirty
|
||||
with _lock:
|
||||
if _dirty and _stats is not None:
|
||||
_write(_stats)
|
||||
_dirty = False
|
||||
|
||||
|
||||
atexit.register(flush)
|
||||
|
||||
Reference in New Issue
Block a user