569 lines
24 KiB
Python
569 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""Governor v2 — Token-Waechter-Proxy vor dem MC2-Gateway.
|
|
|
|
Sitzt zwischen den Coding-Agenten (OpenCode/Zed, Nacht-Laeufe, Hermes-Worker) und dem
|
|
MC2-Gateway (:9001). Reicht ALLES unveraendert durch — mit einer Ausnahme bei
|
|
/v1/chat/completions: er bestimmt die Groesse der Anfrage (= Sitzungsgroesse, weil die
|
|
ganze Historie jede Runde mitkommt) und handelt nach zwei Schwellen:
|
|
|
|
est >= SOFT: haengt eine Stopp-Anweisung als letzte User-Nachricht an ("SAVEPOINT.md
|
|
finalisieren + stoppen") und leitet weiter. Loggt FIRED.
|
|
est >= HART: antwortet SELBST mit einer kurzen Stopp-Nachricht, OHNE das Modell zu
|
|
fragen. Verhindert Fassaden jenseits der Grenze. Loggt HARDSTOP.
|
|
|
|
--- Was v2 gegenueber v0.2 aendert (25.07.2026) ---------------------------------------
|
|
1. EHRLICH ZAEHLEN. v0.2 zaehlte nur Text in `messages` und ignorierte `tools`/
|
|
`tool_calls`. Bei werkzeugdichten Agenten lag es um Faktor 3 daneben (gemessen im
|
|
eigenen Log: est=3353 exact=10224). v2 zaehlt den GANZEN Anfragekoerper — inklusive
|
|
Werkzeug-Schemata, Werkzeug-Aufrufe und Werkzeug-Ergebnisse.
|
|
2. SELBST-KALIBRIERUNG. Aus jeder Antwort liest der Governor die echten
|
|
`usage.prompt_tokens` und korrigiert damit sein Zeichen-pro-Token-Verhaeltnis —
|
|
pro Modell, gleitend. Die Schaetzung wird also im Betrieb immer genauer, statt auf
|
|
einem einmal geratenen Wert festzuhaengen.
|
|
3. TOOL-CALL-SICHERER EINSCHUB. Der Soft-Einschub wird NUR angehaengt, wenn die
|
|
Nachrichtenkette das erlaubt (letzte Nachricht ist nicht ein Assistant mit offenen
|
|
tool_calls und keine tool-Antwort). Sonst wartet er auf die naechste Runde. Ohne
|
|
diese Pruefung zerbricht der Einschub bei OpenCode die Werkzeug-Reihenfolge.
|
|
4. STATUS-ENDPUNKT. GET /governor/status liefert Zaehlerstand, Kalibrierung und die
|
|
letzten Laeufe als JSON — Datenquelle fuer die MC2-Oberflaeche, das OpenCode-Plugin
|
|
und Lucys `loop_status`.
|
|
|
|
Bewusst nur Standardbibliothek: kein pip, kein venv, laeuft mit System-python3.
|
|
Bewusst ohne Datenbank: ein kleiner Ring im Speicher, mehr braucht es nicht.
|
|
|
|
Konfiguration per Umgebungsvariablen (alle optional):
|
|
GOV_PORT Listen-Port (Default 8100)
|
|
GOV_HOST Listen-Adresse (Default 0.0.0.0)
|
|
GOV_UPSTREAM Ziel (Default http://127.0.0.1:9001)
|
|
GOV_THRESHOLD Soft-Schwelle fuer den Einschub (Default 45000)
|
|
GOV_HARD_CEILING Hart-Deckel; 0 = aus (Default: Soft+5000, AN)
|
|
GOV_CHARS_PER_TOKEN Startwert Zeichen->Token (Default 3.2, danach gelernt)
|
|
GOV_CALIBRATE Selbst-Kalibrierung an/aus (Default 1)
|
|
GOV_LOG Logdatei (zusaetzlich zu stdout) (Default ./governor.log)
|
|
GOV_DIRECTIVE Text des Soft-Einschubs
|
|
GOV_HARDSTOP_MSG Text der Hart-Stopp-Antwort
|
|
GOV_ANNOUNCE_URL Lucy-Sprach-Signal; "" = aus (Default :9001/api/voice/announce)
|
|
GOV_ANNOUNCE_THROTTLE Sekunden zwischen Signalen (Default 300)
|
|
GOV_ANNOUNCE_TEXT Text des Sprach-Signals
|
|
GOV_EXEMPT_MODELS Modelle ohne Schnitt, kommasepariert (Default: hermes,fast,embed,
|
|
reranker,vision,scout — Lucys Alltag wird nie unterbrochen)
|
|
"""
|
|
|
|
import http.client
|
|
import json
|
|
import os
|
|
import re
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from urllib.parse import urlparse
|
|
|
|
# ---- Konfiguration ---------------------------------------------------------
|
|
|
|
PORT = int(os.environ.get("GOV_PORT", "8100"))
|
|
HOST = os.environ.get("GOV_HOST", "0.0.0.0")
|
|
# Ziel ist das MC2-Gateway, NICHT llama-swap direkt: so bleiben MC2s Rollen-Aliase,
|
|
# Bild-Weiche und Telemetrie erhalten. Der Governor ist eine Schicht davor, kein Ersatz.
|
|
UPSTREAM = os.environ.get("GOV_UPSTREAM", "http://127.0.0.1:9001")
|
|
THRESHOLD = int(os.environ.get("GOV_THRESHOLD", "45000"))
|
|
_hard_env = os.environ.get("GOV_HARD_CEILING")
|
|
HARD_CEILING = (THRESHOLD + 5000) if _hard_env is None else int(_hard_env)
|
|
CHARS_PER_TOKEN = float(os.environ.get("GOV_CHARS_PER_TOKEN", "3.2"))
|
|
CALIBRATE = os.environ.get("GOV_CALIBRATE", "1") != "0"
|
|
LOG_PATH = os.environ.get("GOV_LOG", os.path.join(os.getcwd(), "governor.log"))
|
|
|
|
# Lucys Alltagsmodelle bekommen NIE einen Savepoint-Einschub: sie fuehren Gespraeche,
|
|
# keine Bau-Sitzungen. Nur die Coding-Rollen laufen gegen die Schwelle.
|
|
_DEFAULT_EXEMPT = "hermes,fast,embed,reranker,vision,scout"
|
|
EXEMPT_MODELS = {m.strip().lower() for m in
|
|
os.environ.get("GOV_EXEMPT_MODELS", _DEFAULT_EXEMPT).split(",") if m.strip()}
|
|
|
|
DEFAULT_DIRECTIVE = (
|
|
"[GOVERNOR — SITZUNGS-LIMIT ERREICHT] Der Kontext dieser Sitzung ist auf ~{est} "
|
|
"Tokens gewachsen (Limit {threshold}). Beginne oder setze JETZT KEINE weiteren "
|
|
"Code-Aenderungen fort. Stattdessen, in dieser Reihenfolge:\n"
|
|
"1. Aktualisiere SAVEPOINT.md so, dass es den aktuellen Stand vollstaendig festhaelt: "
|
|
"was WIRKLICH erledigt ist (nur was im Code steht — nichts aus Absicht oder git-"
|
|
"Nachrichten ableiten), der genaue naechste Schritt, offene Fragen und alle "
|
|
"Stolpersteine — genug, dass eine frische Sitzung ohne jede Erinnerung allein aus "
|
|
"SAVEPOINT.md plus git-Historie sauber weitermachen kann.\n"
|
|
"2. Halte dann an und sage dem Nutzer in einem Satz, dass er eine frische Sitzung "
|
|
"starten soll. Gib ausser der SAVEPOINT.md-Aktualisierung und diesem Hinweis nichts aus."
|
|
)
|
|
DIRECTIVE = os.environ.get("GOV_DIRECTIVE", DEFAULT_DIRECTIVE)
|
|
|
|
DEFAULT_HARDSTOP = (
|
|
"[GOVERNOR — HARTER STOPP] Das Sitzungs-Limit ist ueberschritten und der Savepoint "
|
|
"sollte bereits finalisiert sein. Diese Sitzung nimmt keine weiteren Auftraege mehr an. "
|
|
"Bitte starte eine FRISCHE Sitzung — sie liest SAVEPOINT.md und die git-Historie und "
|
|
"macht sauber weiter. (Keine Code-Aenderung in dieser Antwort.)"
|
|
)
|
|
HARDSTOP_MSG = os.environ.get("GOV_HARDSTOP_MSG", DEFAULT_HARDSTOP)
|
|
|
|
ANNOUNCE_URL = os.environ.get("GOV_ANNOUNCE_URL", "http://127.0.0.1:9001/api/voice/announce")
|
|
ANNOUNCE_THROTTLE = float(os.environ.get("GOV_ANNOUNCE_THROTTLE", "300"))
|
|
DEFAULT_ANNOUNCE = (
|
|
"Commander, die Coding-Sitzung wird voll — ungefähr {est} Tokens. Ich sichere den "
|
|
"Stand im Savepoint; am besten fangen wir gleich frisch an."
|
|
)
|
|
ANNOUNCE_TEXT = os.environ.get("GOV_ANNOUNCE_TEXT", DEFAULT_ANNOUNCE)
|
|
|
|
up = urlparse(UPSTREAM)
|
|
UP_HOST = up.hostname or "127.0.0.1"
|
|
UP_PORT = up.port or 80
|
|
|
|
HOP_BY_HOP = {
|
|
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
"te", "trailers", "transfer-encoding", "upgrade",
|
|
}
|
|
|
|
_log_lock = threading.Lock()
|
|
_PROMPT_TOKENS_RE = re.compile(r'"prompt_tokens"\s*:\s*(\d+)')
|
|
_announce_lock = threading.Lock()
|
|
_last_announce = 0.0
|
|
_an = urlparse(ANNOUNCE_URL) if ANNOUNCE_URL else None
|
|
|
|
# ---- Zustand (klein, im Speicher) ------------------------------------------
|
|
# Kalibrierung je Modell: gleitender Mittelwert von zeichen/echte_tokens. Startwert ist
|
|
# GOV_CHARS_PER_TOKEN; jede Antwort mit usage zieht ihn Richtung Wahrheit.
|
|
_state_lock = threading.Lock()
|
|
_cpt: dict = {} # modell -> gelerntes Zeichen-pro-Token
|
|
_cpt_n: dict = {} # modell -> Anzahl Messungen
|
|
_recent: deque = deque(maxlen=50) # letzte Laeufe fuer /governor/status
|
|
_counters = {"chat": 0, "soft": 0, "hard": 0, "passthrough": 0,
|
|
"tokens_prompt": 0, "tokens_completion": 0, "started": time.time()}
|
|
|
|
CPT_MIN, CPT_MAX = 0.8, 8.0 # Schutz gegen Ausreisser
|
|
|
|
|
|
def log(line: str) -> None:
|
|
"""Eine Zeile nach stdout UND in die Logdatei (thread-sicher)."""
|
|
stamp = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
msg = f"{stamp} {line}"
|
|
with _log_lock:
|
|
print(msg, flush=True)
|
|
try:
|
|
with open(LOG_PATH, "a", encoding="utf-8") as fh:
|
|
fh.write(msg + "\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def cpt_for(model: str) -> float:
|
|
"""Aktuelles Zeichen-pro-Token-Verhaeltnis fuer ein Modell (gelernt oder Startwert)."""
|
|
with _state_lock:
|
|
return _cpt.get(model, CHARS_PER_TOKEN)
|
|
|
|
|
|
def calibrate(model: str, chars: int, exact: int) -> None:
|
|
"""Aus einer echten Antwort lernen. Gleitender Mittelwert mit sanftem Gewicht —
|
|
ein einzelner Ausreisser (z. B. ein riesiges Bild) verbiegt nichts."""
|
|
if not CALIBRATE or not exact or exact <= 0 or chars <= 0:
|
|
return
|
|
ratio = chars / exact
|
|
if not (CPT_MIN <= ratio <= CPT_MAX):
|
|
return
|
|
with _state_lock:
|
|
n = _cpt_n.get(model, 0)
|
|
old = _cpt.get(model, CHARS_PER_TOKEN)
|
|
# Gewicht faellt mit der Anzahl Messungen: schnell einschwingen, dann stabil.
|
|
w = max(0.08, 1.0 / (n + 2))
|
|
_cpt[model] = old * (1 - w) + ratio * w
|
|
_cpt_n[model] = n + 1
|
|
|
|
|
|
def body_chars(data: dict) -> int:
|
|
"""Zeichen des GESAMTEN Anfragekoerpers — der Kern der Ehrlichkeit.
|
|
|
|
v0.2 zaehlte nur Text in `messages` und lag bei werkzeugdichten Agenten um Faktor 3
|
|
daneben, weil Werkzeug-Schemata (`tools`), Werkzeug-Aufrufe (`tool_calls`) und
|
|
Werkzeug-Ergebnisse mitgeschickt werden und im Kontext genauso Platz fressen.
|
|
Wir serialisieren einfach alles, was ans Modell geht.
|
|
"""
|
|
payload = {k: v for k, v in data.items()
|
|
if k in ("messages", "tools", "tool_choice", "system", "functions")}
|
|
try:
|
|
return len(json.dumps(payload, ensure_ascii=False))
|
|
except (TypeError, ValueError):
|
|
# Fallback: nur Nachrichtentext (nie schlechter als v0.2)
|
|
chars = 0
|
|
for m in data.get("messages") or []:
|
|
c = m.get("content") if isinstance(m, dict) else None
|
|
if isinstance(c, str):
|
|
chars += len(c) + 4
|
|
elif isinstance(c, list):
|
|
for p in c:
|
|
if isinstance(p, dict) and isinstance(p.get("text"), str):
|
|
chars += len(p["text"])
|
|
return chars
|
|
|
|
|
|
def safe_to_append(messages) -> bool:
|
|
"""Darf der Soft-Einschub JETZT als user-Nachricht ans Ende?
|
|
|
|
Nein, wenn die Kette gerade mitten in einem Werkzeug-Austausch steckt: nach einem
|
|
Assistant mit offenen `tool_calls` MUSS eine `tool`-Antwort folgen — schiebt man da
|
|
eine user-Nachricht dazwischen, lehnt das Modell (bzw. das Template) die Anfrage ab
|
|
oder halluziniert. Dann warten wir einfach auf die naechste Runde; die Schwelle ist
|
|
ohnehin ueberschritten, es kommt in Sekunden ein neuer Zug.
|
|
"""
|
|
if not isinstance(messages, list) or not messages:
|
|
return False
|
|
last = messages[-1]
|
|
if not isinstance(last, dict):
|
|
return False
|
|
role = last.get("role")
|
|
if role == "tool":
|
|
return False
|
|
return not (role == "assistant" and last.get("tool_calls"))
|
|
|
|
|
|
def _post_announce(est) -> None:
|
|
"""POSTet die Meldung an die MC2-Announce-Pipeline (Lucy spricht sie)."""
|
|
try:
|
|
text = (ANNOUNCE_TEXT.replace("{est}", str(est))
|
|
.replace("{threshold}", str(THRESHOLD)))
|
|
body = json.dumps({"text": text, "subject": "[Governor]",
|
|
"source": "governor", "priority": "normal"}).encode("utf-8")
|
|
conn = http.client.HTTPConnection(_an.hostname or "127.0.0.1",
|
|
_an.port or 80, timeout=4)
|
|
conn.request("POST", _an.path or "/api/voice/announce", body=body,
|
|
headers={"Content-Type": "application/json",
|
|
"Content-Length": str(len(body))})
|
|
resp = conn.getresponse()
|
|
resp.read()
|
|
conn.close()
|
|
log(f"ANNOUNCE -> Lucy status={resp.status} est={est}")
|
|
except Exception as exc:
|
|
log(f"ANNOUNCE fehlgeschlagen: {exc!r}")
|
|
|
|
|
|
def maybe_announce(est) -> None:
|
|
"""Sprach-Signal an Lucy — gedrosselt (eine Aeusserung je Episode)."""
|
|
if not _an:
|
|
return
|
|
global _last_announce
|
|
now = time.time()
|
|
with _announce_lock:
|
|
if now - _last_announce < ANNOUNCE_THROTTLE:
|
|
return
|
|
_last_announce = now
|
|
threading.Thread(target=_post_announce, args=(est,), daemon=True).start()
|
|
|
|
|
|
def status_payload() -> dict:
|
|
"""Momentaufnahme fuer /governor/status (MC2-Oberflaeche, Plugin, Lucy)."""
|
|
with _state_lock:
|
|
return {
|
|
"ok": True,
|
|
"upstream": UPSTREAM,
|
|
"soft": THRESHOLD,
|
|
"hard": HARD_CEILING if HARD_CEILING > 0 else None,
|
|
"uptime_s": int(time.time() - _counters["started"]),
|
|
"counters": {k: v for k, v in _counters.items() if k != "started"},
|
|
"calibration": {m: {"chars_per_token": round(v, 3), "samples": _cpt_n.get(m, 0)}
|
|
for m, v in _cpt.items()},
|
|
"calibration_default": CHARS_PER_TOKEN,
|
|
"exempt_models": sorted(EXEMPT_MODELS),
|
|
"recent": list(_recent),
|
|
}
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
protocol_version = "HTTP/1.1"
|
|
server_version = "Governor/2.0"
|
|
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
if self.path.split("?", 1)[0].rstrip("/") in ("/governor/status", "/governor"):
|
|
self._send_json(200, status_payload())
|
|
return
|
|
self._proxy()
|
|
|
|
def do_POST(self):
|
|
self._proxy()
|
|
|
|
def do_PUT(self):
|
|
self._proxy()
|
|
|
|
def do_DELETE(self):
|
|
self._proxy()
|
|
|
|
def do_OPTIONS(self):
|
|
self._proxy()
|
|
|
|
# -- Kern ---------------------------------------------------------------
|
|
def _send_json(self, status: int, obj) -> None:
|
|
try:
|
|
data = json.dumps(obj).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Access-Control-Allow-Origin", "*")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Connection", "close")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
except OSError:
|
|
pass
|
|
|
|
def _read_body(self) -> bytes:
|
|
length = self.headers.get("Content-Length")
|
|
if length is None:
|
|
return b""
|
|
try:
|
|
return self.rfile.read(int(length))
|
|
except (ValueError, OSError):
|
|
return b""
|
|
|
|
def _proxy(self) -> None:
|
|
body = self._read_body()
|
|
path = self.path
|
|
clean_path = path.split("?", 1)[0]
|
|
is_chat = clean_path.rstrip("/").endswith("/chat/completions")
|
|
|
|
action = "passthrough"
|
|
est = None
|
|
streaming = False
|
|
model = ""
|
|
chars = 0
|
|
if is_chat and body:
|
|
action, body, est, streaming, model, chars = self._decide(body)
|
|
if action in ("soft", "hard"):
|
|
maybe_announce(est)
|
|
|
|
if action == "hard":
|
|
self._send_canned_stop(model, streaming, est)
|
|
with _state_lock:
|
|
_counters["chat"] += 1
|
|
_counters["hard"] += 1
|
|
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
|
"exact": None, "action": "hard"})
|
|
log(f"chat model={model} est={est} thr={THRESHOLD} hard={HARD_CEILING} "
|
|
f"HARDSTOP stream={streaming} status=200")
|
|
return
|
|
|
|
out_headers = {}
|
|
for k, v in self.headers.items():
|
|
kl = k.lower()
|
|
if kl in HOP_BY_HOP or kl in ("host", "content-length", "accept-encoding"):
|
|
continue
|
|
out_headers[k] = v
|
|
out_headers["Host"] = f"{UP_HOST}:{UP_PORT}"
|
|
out_headers["Accept-Encoding"] = "identity"
|
|
if body:
|
|
out_headers["Content-Length"] = str(len(body))
|
|
out_headers["Connection"] = "close"
|
|
|
|
conn = None
|
|
try:
|
|
conn = http.client.HTTPConnection(UP_HOST, UP_PORT, timeout=900)
|
|
conn.request(self.command, path, body=body or None, headers=out_headers)
|
|
resp = conn.getresponse()
|
|
except (OSError, http.client.HTTPException) as exc:
|
|
log(f"ERROR upstream {self.command} {path}: {exc!r}")
|
|
if conn is not None:
|
|
conn.close()
|
|
self._safe_error(502, f"governor upstream: {exc}")
|
|
return
|
|
|
|
self.send_response(resp.status)
|
|
for k, v in resp.getheaders():
|
|
kl = k.lower()
|
|
if kl in HOP_BY_HOP or kl in ("content-length", "date", "server"):
|
|
continue
|
|
self.send_header(k, v)
|
|
self.send_header("Connection", "close")
|
|
self.end_headers()
|
|
|
|
tail = bytearray()
|
|
try:
|
|
while True:
|
|
# read1() gibt jedes Upstream-Stueck sofort zurueck (echtes SSE-
|
|
# Durchreichen); read() wuerde puffern und Streaming haengen lassen.
|
|
chunk = resp.read1(65536)
|
|
if not chunk:
|
|
break
|
|
self.wfile.write(chunk)
|
|
self.wfile.flush()
|
|
tail.extend(chunk)
|
|
if len(tail) > 16384:
|
|
del tail[:-16384]
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
conn.close()
|
|
|
|
exact = self._scan_prompt_tokens(tail)
|
|
if is_chat:
|
|
if exact:
|
|
calibrate(model, chars, exact)
|
|
with _state_lock:
|
|
_counters["chat"] += 1
|
|
_counters["soft" if action == "soft" else "passthrough"] += 1
|
|
if exact:
|
|
_counters["tokens_prompt"] += exact
|
|
_recent.appendleft({"t": int(time.time()), "model": model, "est": est,
|
|
"exact": exact, "action": action})
|
|
exact_s = str(exact) if exact is not None else "-"
|
|
flag = "FIRED" if action == "soft" else ("skip" if action == "defer" else "ok")
|
|
log(f"chat model={model} est={est} exact={exact_s} cpt={cpt_for(model):.2f} "
|
|
f"thr={THRESHOLD} {flag} stream={streaming} status={resp.status}")
|
|
|
|
def _decide(self, body: bytes):
|
|
"""Aktion bestimmen. Rueckgabe: (action, body, est, streaming, model, chars)."""
|
|
try:
|
|
data = json.loads(body)
|
|
except (ValueError, UnicodeDecodeError):
|
|
return "passthrough", body, None, False, "", 0
|
|
if not isinstance(data, dict):
|
|
return "passthrough", body, None, False, "", 0
|
|
|
|
messages = data.get("messages")
|
|
streaming = bool(data.get("stream"))
|
|
model = (data.get("model") or "").strip()
|
|
chars = body_chars(data)
|
|
est = int(chars / max(cpt_for(model), 0.1))
|
|
|
|
# Lucys Alltagsmodelle laufen nie gegen die Schwelle — ein Gespraech ist keine
|
|
# Bau-Sitzung. Wir zaehlen sie trotzdem mit (Kalibrierung + Telemetrie).
|
|
base = model.split("/")[-1].lower()
|
|
if base in EXEMPT_MODELS:
|
|
return "passthrough", body, est, streaming, model, chars
|
|
|
|
if not isinstance(messages, list):
|
|
return "passthrough", body, est, streaming, model, chars
|
|
|
|
has_warned = False
|
|
marker = "[GOVERNOR — SITZUNGS-LIMIT ERREICHT]"
|
|
for m in reversed(messages):
|
|
if m.get("role") == "user" and isinstance(m.get("content"), str) and marker in m["content"]:
|
|
has_warned = True
|
|
break
|
|
|
|
is_safe = safe_to_append(messages)
|
|
|
|
# 1. Absolutes Not-Aus bei komplettem Amoklauf
|
|
if HARD_CEILING > 0 and est >= HARD_CEILING + 10000:
|
|
return "hard", body, est, streaming, model, chars
|
|
|
|
# 2. Sind wir am Limit?
|
|
if est >= THRESHOLD:
|
|
if not is_safe:
|
|
# Agent arbeitet gerade an einer Tool-Kette. Auf keinen Fall abbrechen!
|
|
return "defer", body, est, streaming, model, chars
|
|
|
|
# Es ist sicher (Tool-Kette beendet oder Agent wartet auf Input).
|
|
if HARD_CEILING > 0 and est >= HARD_CEILING:
|
|
if has_warned:
|
|
# Wir haben ihn schon gewarnt, er macht trotzdem weiter -> harter Schnitt.
|
|
return "hard", body, est, streaming, model, chars
|
|
else:
|
|
# Er hat wegen einer langen Tool-Kette direkt das Hard-Limit ueberschritten.
|
|
# Gib ihm trotzdem noch den einen Finalisier-Zug (Soft).
|
|
pass
|
|
|
|
# Soft-Limit greift
|
|
if not has_warned:
|
|
directive = (DIRECTIVE.replace("{est}", str(est))
|
|
.replace("{threshold}", str(THRESHOLD)))
|
|
messages.append({"role": "user", "content": directive})
|
|
data["messages"] = messages
|
|
return "soft", json.dumps(data).encode("utf-8"), est, streaming, model, chars
|
|
|
|
return "passthrough", body, est, streaming, model, chars
|
|
|
|
def _send_canned_stop(self, model: str, streaming: bool, est) -> None:
|
|
"""OpenAI-kompatible Stopp-Antwort selbst erzeugen (kein Upstream-Call)."""
|
|
created = int(time.time())
|
|
usage = {"prompt_tokens": est or 0, "completion_tokens": 0,
|
|
"total_tokens": est or 0}
|
|
try:
|
|
if streaming:
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/event-stream")
|
|
self.send_header("Cache-Control", "no-cache")
|
|
self.send_header("Connection", "close")
|
|
self.end_headers()
|
|
|
|
def sse(obj):
|
|
self.wfile.write(b"data: " + json.dumps(obj).encode() + b"\n\n")
|
|
self.wfile.flush()
|
|
|
|
base = {"id": "governor-hardstop", "object": "chat.completion.chunk",
|
|
"created": created, "model": model}
|
|
sse({**base, "choices": [{"index": 0, "delta": {"role": "assistant"},
|
|
"finish_reason": None}]})
|
|
sse({**base, "choices": [{"index": 0, "delta": {"content": HARDSTOP_MSG},
|
|
"finish_reason": None}]})
|
|
sse({**base, "choices": [{"index": 0, "delta": {},
|
|
"finish_reason": "stop"}], "usage": usage})
|
|
self.wfile.write(b"data: [DONE]\n\n")
|
|
self.wfile.flush()
|
|
else:
|
|
payload = {
|
|
"id": "governor-hardstop", "object": "chat.completion",
|
|
"created": created, "model": model,
|
|
"choices": [{"index": 0, "finish_reason": "stop",
|
|
"message": {"role": "assistant", "content": HARDSTOP_MSG}}],
|
|
"usage": usage,
|
|
}
|
|
data = json.dumps(payload).encode("utf-8")
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Connection", "close")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
self.wfile.flush()
|
|
except OSError:
|
|
pass
|
|
|
|
def _safe_error(self, status: int, msg: str) -> None:
|
|
self._send_json(status, {"error": msg})
|
|
|
|
@staticmethod
|
|
def _scan_prompt_tokens(tail: bytearray):
|
|
if not tail:
|
|
return None
|
|
try:
|
|
text = tail.decode("utf-8", errors="ignore")
|
|
except Exception:
|
|
return None
|
|
matches = _PROMPT_TOKENS_RE.findall(text)
|
|
if not matches:
|
|
return None
|
|
try:
|
|
return int(matches[-1])
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def main() -> int:
|
|
log_dir = os.path.dirname(LOG_PATH)
|
|
if log_dir and not os.path.isdir(log_dir):
|
|
try:
|
|
os.makedirs(log_dir, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
|
|
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
server.daemon_threads = True
|
|
hard = HARD_CEILING if HARD_CEILING > 0 else "aus"
|
|
log(f"Governor v2 startet auf {HOST}:{PORT} -> {UPSTREAM} | Soft={THRESHOLD} "
|
|
f"Hart={hard} | CPT-Start={CHARS_PER_TOKEN} kalibrierend={CALIBRATE} | "
|
|
f"ausgenommen={sorted(EXEMPT_MODELS)} | Log={LOG_PATH}")
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
log("Governor beendet (SIGINT).")
|
|
finally:
|
|
server.server_close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|