Warum: llama-swap laeuft als Nutzer-Unit. Der System-Telegraf liest das Systemjournal mit priority>=warning und liest damit keine einzige llama-swap-INFO-Zeile - Loki hatte fuer den ROG null Zeilen. Ausserdem kann die System-Instanz auf einem Knoten ohne sudo nicht erweitert werden. - scripts/llamaswap-telemetrie.py: Token/Anfragen je Modell aus llama-swaps Statistik-API plus llama.cpp /metrics je laufendem Profil (Ports sind dynamisch, deshalb kein statischer Scrape) - scripts/llamaswap-logs.py: Nutzer-Journal (llama-swap, kalina-backends) nach Loki, gleiche Zeilenform wie journal-tail.sh - telegraf/llamaswap-user.conf + .service: Zugangsdaten per EnvironmentFile=/etc/default/telegraf, keine Token-Kopie - scripts/install-llamaswap-user.sh: idempotente Installation mit --dry-run und Konfigurationspruefung
215 lines
7.2 KiB
Python
Executable File
215 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""llama-swap + llama.cpp -> Prometheus-Text fuer Telegraf (inputs.exec).
|
|
|
|
Warum dieses Skript ueberhaupt:
|
|
llama-swap vergibt die Upstream-Ports DYNAMISCH (`${PORT}`, startPort 9340,
|
|
stabile Modell-ID-Reihenfolge) - ein statischer Scrape kann sie nicht
|
|
treffen. Ausserdem liegen die Token-Zahlen in zwei Quellen: llama.cpp
|
|
(/metrics, nur mit --metrics, kein Modell-Label) und llama-swaps eigenem
|
|
Statistik-Store (/api/metrics/stats, das die Upstream-Antworten selbst
|
|
auswertet und damit auch strata und sd-server abdeckt).
|
|
Ein exec-Input, das llama-swap selbst fragt, ist deshalb der einzige Weg
|
|
ohne Raten.
|
|
|
|
Quellen (alle nur lesend, jede mit eigenem Timeout, Fehler brechen nichts ab):
|
|
* GET /running laufende Modelle + Upstream-Adresse
|
|
* GET /v1/models alle Modell-IDs
|
|
* GET /api/metrics/stats?model=<id> Token-Summen je Modell
|
|
* GET http://<upstream>/metrics llama.cpp-Zaehler je Profil
|
|
|
|
Ausgabe: Prometheus-Textformat auf stdout.
|
|
* Keine erreichbare Quelle = keine Zeile fuer diese Quelle.
|
|
* Es wird nie ein Wert erfunden und nie ein Teil-Fehler als 0 gemeldet.
|
|
* Rueckgabewert ist immer 0, damit Telegraf den Lauf nicht als Fehler sieht.
|
|
|
|
Aufruf durch Telegraf (Nutzer-Instanz auf dem ROG):
|
|
[[inputs.exec]]
|
|
commands = ["/home/mm/bin/llamaswap-telemetrie.py"]
|
|
data_format = "prometheus"
|
|
timeout = "25s"
|
|
interval = "60s"
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
BASE = os.environ.get("LLAMASWAP_BASE", "http://192.168.188.184:9300")
|
|
TIMEOUT = float(os.environ.get("LLAMASWAP_TIMEOUT", "4"))
|
|
|
|
# Profile, die model-manager.sh auf festen Ports startet (Dauerlaeufer und
|
|
# Handstarts). Sie erscheinen NICHT in /running, wenn llama-swap sie nicht
|
|
# verwaltet - deshalb fest hinterlegt. Nicht erreichbare Ports kosten nur einen
|
|
# sofortigen ECONNREFUSED, keine Wartezeit.
|
|
FESTE_PORTS = {
|
|
8101: "chat",
|
|
8102: "embedding",
|
|
8103: "vision",
|
|
8104: "ocr",
|
|
8105: "rerank",
|
|
8107: "fast",
|
|
8108: "agent",
|
|
8109: "desktop",
|
|
}
|
|
|
|
METRIK_RE = re.compile(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)(\{[^}]*\})?\s+(\S+)\s*$")
|
|
|
|
|
|
def hole(pfad, timeout=None):
|
|
"""GET gegen llama-swap. Gibt (status, bytes) zurueck - status 0 bei Fehler."""
|
|
url = pfad if pfad.startswith("http") else BASE + pfad
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=timeout or TIMEOUT) as antwort:
|
|
return antwort.status, antwort.read()
|
|
except urllib.error.HTTPError as e:
|
|
return e.code, b""
|
|
except Exception:
|
|
return 0, b""
|
|
|
|
|
|
def hole_json(pfad):
|
|
status, rohdaten = hole(pfad)
|
|
if status != 200 or not rohdaten:
|
|
return None
|
|
try:
|
|
return json.loads(rohdaten)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def name_saeubern(name):
|
|
"""llama.cpp liefert `llamacpp:prompt_tokens_total` (Doppelpunkt erlaubt,
|
|
aber Prometheus reserviert ihn fuer Recording-Regeln) -> Unterstrich."""
|
|
return name.replace(":", "_")
|
|
|
|
|
|
def doppelpunkt_label_frei(labels):
|
|
"""Label-String normalisieren: fuehrende/abschliessende Klammern entfernen."""
|
|
if not labels:
|
|
return ""
|
|
labels = labels.strip()
|
|
if labels.startswith("{") and labels.endswith("}"):
|
|
return labels[1:-1]
|
|
return ""
|
|
|
|
|
|
def upstream_kandidaten():
|
|
"""{port: modell} aus /running plus die festen model-manager-Ports."""
|
|
kandidaten = dict(FESTE_PORTS)
|
|
running = hole_json("/running")
|
|
if isinstance(running, dict):
|
|
for eintrag in running.get("running", []) or []:
|
|
modell = eintrag.get("model") or ""
|
|
proxy = eintrag.get("proxy") or ""
|
|
m = re.search(r":(\d+)\s*$", proxy)
|
|
if m and modell:
|
|
# /running gewinnt: llama-swap kennt den echten Modellnamen.
|
|
kandidaten[int(m.group(1))] = modell
|
|
return kandidaten
|
|
|
|
|
|
def llama_cpp_zeilen(port, modell):
|
|
"""Prometheus-Zeilen eines llama.cpp-Upstreams, mit model-Label ergaenzt."""
|
|
status, rohdaten = hole("http://192.168.188.184:%d/metrics" % port, timeout=2)
|
|
if status != 200 or not rohdaten:
|
|
return [], []
|
|
text = rohdaten.decode("utf-8", "replace")
|
|
if "llamacpp" not in text: # kein llama.cpp (strata, sd-server)
|
|
return [], []
|
|
|
|
typen, zeilen = {}, []
|
|
for zeile in text.splitlines():
|
|
zeile = zeile.strip()
|
|
if not zeile:
|
|
continue
|
|
if zeile.startswith("# TYPE"):
|
|
teile = zeile.split()
|
|
if len(teile) >= 4:
|
|
typen[name_saeubern(teile[2])] = teile[3]
|
|
continue
|
|
if zeile.startswith("#"):
|
|
continue
|
|
m = METRIK_RE.match(zeile)
|
|
if not m:
|
|
continue
|
|
name = name_saeubern(m.group(1))
|
|
labels = doppelpunkt_label_frei(m.group(2))
|
|
wert = m.group(3)
|
|
if wert in ("NaN", "+Inf", "-Inf"):
|
|
continue
|
|
try:
|
|
float(wert)
|
|
except ValueError:
|
|
continue
|
|
neue_labels = ('model="%s"' % modell) + ("," + labels if labels else "")
|
|
zeilen.append((name, neue_labels, wert))
|
|
return typen, zeilen
|
|
|
|
|
|
def aktivitaets_zeilen():
|
|
"""Token-Summen je Modell aus llama-swaps Statistik-Store."""
|
|
modelle = hole_json("/v1/models")
|
|
if not isinstance(modelle, dict):
|
|
return [], []
|
|
ids = [m.get("id") for m in modelle.get("data", []) or [] if m.get("id")]
|
|
felder = [
|
|
("total_requests", "llamaswap_activity_requests_total"),
|
|
("total_input_tokens", "llamaswap_activity_input_tokens_total"),
|
|
("total_output_tokens", "llamaswap_activity_output_tokens_total"),
|
|
("total_cache_tokens", "llamaswap_activity_cache_tokens_total"),
|
|
]
|
|
zeilen = []
|
|
for modell in ids:
|
|
stats = hole_json("/api/metrics/stats?model=%s" % urllib.parse.quote(modell))
|
|
if not isinstance(stats, dict):
|
|
continue
|
|
for schluessel, metrik in felder:
|
|
wert = stats.get(schluessel)
|
|
if isinstance(wert, (int, float)) and not isinstance(wert, bool):
|
|
zeilen.append((metrik, 'model="%s"' % modell, "%d" % wert))
|
|
return zeilen
|
|
|
|
|
|
def main():
|
|
typen = {}
|
|
zeilen = []
|
|
|
|
kandidaten = upstream_kandidaten()
|
|
for port, modell in sorted(kandidaten.items()):
|
|
t, z = llama_cpp_zeilen(port, modell)
|
|
typen.update(t)
|
|
zeilen.extend(z)
|
|
|
|
aktivitaet = aktivitaets_zeilen()
|
|
for metrik, _, _ in aktivitaet:
|
|
typen[metrik] = "counter"
|
|
zeilen.extend(aktivitaet)
|
|
|
|
if not zeilen:
|
|
# Nichts erreichbar: lieber gar nichts ausgeben als Nullen.
|
|
return 0
|
|
|
|
ausgabe = []
|
|
for name in sorted({n for n, _, _ in zeilen}):
|
|
typ = typen.get(name, "gauge")
|
|
ausgabe.append("# TYPE %s %s" % (name, typ))
|
|
for n, labels, wert in zeilen:
|
|
if n != name:
|
|
continue
|
|
ausgabe.append("%s{%s} %s" % (n, labels, wert))
|
|
sys.stdout.write("\n".join(ausgabe) + "\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except Exception as fehler: # fail-open: nie den exec brechen
|
|
sys.stderr.write("llamaswap-telemetrie: %s\n" % fehler)
|
|
sys.exit(0)
|