fix(discord-bridge): plafonul de cost e optional (COST_CAP_USD_DAY=off)

Costul raportat de CLI e pretul echivalent la API; pe abonament nu se
factureaza, deci plafonul zilnic oprea puntea fara motiv (5.71 / 5.00 USD).

- limits.parse_cap(): `off`/`none`/`nelimitat`/`0`/gol/gunoi => fara plafon
- stopped()/record_cost() nu mai opresc si nu mai alerteaza cand e dezactivat
- implicit devine `off`; /status arata „(fara plafon)"
- dashboard: /api/status si doctor nu mai crapa pe valoare ne-numerica
- README + ops/env.example explica de ce ramane off pe abonament

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
Claude Agent
2026-08-30 21:02:43 +00:00
parent b14e7bc50a
commit feb31eaabe
7 changed files with 76 additions and 15 deletions

View File

@@ -170,7 +170,9 @@ Nu se poate automatiza: cere un om logat in Discord.
**Allowlist gol = nimic permis** (fail-closed). Mesajele de la webhook-uri si de la **Allowlist gol = nimic permis** (fail-closed). Mesajele de la webhook-uri si de la
alti boti sunt ignorate din principiu. alti boti sunt ignorate din principiu.
7. **Pune destinatarul alertelor**: `ALERT_RECIPIENT=` in acelasi env. 7. **Pune destinatarul alertelor**: `ALERT_RECIPIENT=` in acelasi env.
8. **Verifica plafonul de cost**: `COST_CAP_USD_DAY=` (implicit `5.00`). 8. **Verifica plafonul de cost**: `COST_CAP_USD_DAY=` (implicit `off`). Pe abonament
(Pro/Max) lasa `off`: costul raportat de CLI e doar pretul echivalent la API, nu o
factura. Pune un numar (ex. `5.00`) doar daca platesti per token.
9. Abia acum: `./ops/install.sh --start`. 9. Abia acum: `./ops/install.sh --start`.
--- ---
@@ -212,9 +214,13 @@ Fara Tailscale: `ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171`.
### Cost ### Cost
Costul se vede in trei locuri: in subsolul fiecarui raspuns (turul curent + cumulat pe Costul se vede in trei locuri: in subsolul fiecarui raspuns (turul curent + cumulat pe
fir), in `/status`, si in `state.json` la cheia `cost`. La atingerea plafonului zilnic fir), in `/status`, si in `state.json` la cheia `cost`. E pretul echivalent la API
(`COST_CAP_USD_DAY`) botul nu mai accepta tururi noi si trimite email. Plafonul se raportat de CLI: pe abonament nu se factureaza, deci ramane doar o masura de consum.
reseteaza la schimbarea zilei.
Plafonul zilnic e optional. `COST_CAP_USD_DAY=off` (implicit, si `0` sau gol) inseamna
fara plafon: cheltuiala se contorizeaza mai departe, dar nimic nu se opreste. Cu un
numar pozitiv, la atingerea lui botul nu mai accepta tururi noi si trimite email;
plafonul se reseteaza la schimbarea zilei.
### Alerte pe email ### Alerte pe email
@@ -265,7 +271,7 @@ tail -2 ~/.claude-discord/logs/alerts.log
| Unitul se invarte in restart | Dupa 5 porniri esuate in 300s systemd renunta si lasa unitul `failed` (e voit). Repara, apoi `systemctl --user reset-failed claude-discord && systemctl --user start claude-discord`. | | Unitul se invarte in restart | Dupa 5 porniri esuate in 300s systemd renunta si lasa unitul `failed` (e voit). Repara, apoi `systemctl --user reset-failed claude-discord && systemctl --user start claude-discord`. |
| Firul e blocat pe hourglass | Botul a fost restartat la mijlocul unui tur. Turul **nu** se reia automat (risc de dubla executie sub `bypassPermissions`); sweep-ul de la pornire pune un avertisment in fir. Trimite mesajul din nou. | | Firul e blocat pe hourglass | Botul a fost restartat la mijlocul unui tur. Turul **nu** se reia automat (risc de dubla executie sub `bypassPermissions`); sweep-ul de la pornire pune un avertisment in fir. Trimite mesajul din nou. |
| Memoria containerului creste | `/cleanup` (sec), apoi `/cleanup force:True`. Vezi si `systemctl --user show claude-discord -p MemoryCurrent`. | | Memoria containerului creste | `/cleanup` (sec), apoi `/cleanup force:True`. Vezi si `systemctl --user show claude-discord -p MemoryCurrent`. |
| „Plafon de cost atins" | E limita zilnica, nu o eroare. Ridica `COST_CAP_USD_DAY` in env si reporneste, sau asteapta ziua urmatoare. | | „Plafon de cost atins" | E limita zilnica, nu o eroare. Pe abonament pune `COST_CAP_USD_DAY=off` in env si reporneste; altfel ridica valoarea sau asteapta ziua urmatoare. |
| Nu vin emailuri de alerta | `command -v mail`; `mailq`; `tail ~/.claude-discord/logs/alerts.log`. Un `NESENT` in log iti spune exact de ce. | | Nu vin emailuri de alerta | `command -v mail`; `mailq`; `tail ~/.claude-discord/logs/alerts.log`. Un `NESENT` in log iti spune exact de ce. |
| Dupa reboot serviciul nu porneste | `loginctl show-user claude -p Linger` trebuie sa fie `yes`. Daca nu: `sudo loginctl enable-linger claude`. | | Dupa reboot serviciul nu porneste | `loginctl show-user claude -p Linger` trebuie sa fie `yes`. Daca nu: `sudo loginctl enable-linger claude`. |

View File

@@ -609,7 +609,8 @@ class Bridge:
f"in coada : {'da' if self.limits.queued(thread_id) else 'nu'}", f"in coada : {'da' if self.limits.queued(thread_id) else 'nu'}",
f"procese : {self.runner.live_count()} vii, {self.limits.free_slots} sloturi libere", f"procese : {self.runner.live_count()} vii, {self.limits.free_slots} sloturi libere",
f"cost fir : ${float(rec.get('cost_usd_total') or 0.0):.4f}", f"cost fir : ${float(rec.get('cost_usd_total') or 0.0):.4f}",
f"cost azi : ${self.limits.cost_today():.4f} / ${cap:.2f}" f"cost azi : ${self.limits.cost_today():.4f}"
+ (f" / ${cap:.2f}" if self.limits.cap_enabled else " (fara plafon)")
+ (" ⛔ PLAFON ATINS" if self.limits.stopped() else ""), + (" ⛔ PLAFON ATINS" if self.limits.stopped() else ""),
"```", "```",
] ]

View File

@@ -24,7 +24,7 @@ _env: dict[str, str] = {}
# Valori implicite pentru cheile pe care le foloseste nucleul. # Valori implicite pentru cheile pe care le foloseste nucleul.
DEFAULTS: dict[str, str] = { DEFAULTS: dict[str, str] = {
"MODEL_DEFAULT": "sonnet", "MODEL_DEFAULT": "sonnet",
"COST_CAP_USD_DAY": "10.0", "COST_CAP_USD_DAY": "off", # `off` = fara plafon (abonament); numar = plafon zilnic USD
"MAX_PROCS": "4", "MAX_PROCS": "4",
"TURN_TIMEOUT_S": "900", "TURN_TIMEOUT_S": "900",
"IDLE_REAP_S": "1200", "IDLE_REAP_S": "1200",

View File

@@ -41,6 +41,7 @@ for _p in (str(_BRIDGE), str(_DASH)):
sys.path.insert(0, _p) sys.path.insert(0, _p)
import config # noqa: E402 import config # noqa: E402
from limits import parse_cap # noqa: E402
# ── constante ─────────────────────────────────────────────────────────── # ── constante ───────────────────────────────────────────────────────────
SERVICE = "claude-discord.service" SERVICE = "claude-discord.service"
@@ -288,12 +289,13 @@ def doctor() -> list[dict]:
"detail": f'{len(st.get("threads") or {})} fire' if st else "ilizibil sau gol", "detail": f'{len(st.get("threads") or {})} fire' if st else "ilizibil sau gol",
}) })
cap = float(config.get("COST_CAP_USD_DAY") or 0) cap = parse_cap(config.get("COST_CAP_USD_DAY"), 0.0)
spent = float((st.get("cost") or {}).get("usd") or 0) spent = float((st.get("cost") or {}).get("usd") or 0)
checks.append({ checks.append({
"name": "Plafon de cost pe zi", "name": "Plafon de cost pe zi",
"pass": cap <= 0 or spent < cap, "pass": cap <= 0 or spent < cap,
"detail": f"{spent:.2f} / {cap:.2f} USD", "detail": f"{spent:.2f} / {cap:.2f} USD" if cap > 0
else f"{spent:.2f} USD cheltuiti, fara plafon (abonament)",
}) })
try: try:
@@ -500,7 +502,7 @@ class Handler(SimpleHTTPRequestHandler):
"dashboard": unit_info(SELF_SERVICE), "dashboard": unit_info(SELF_SERVICE),
"threads": threads_view(state), "threads": threads_view(state),
"cost": state.get("cost") or {}, "cost": state.get("cost") or {},
"cost_cap": float(config.get("COST_CAP_USD_DAY") or 0), "cost_cap": parse_cap(config.get("COST_CAP_USD_DAY"), 0.0),
"pending_approvals": len(pending_approvals()), "pending_approvals": len(pending_approvals()),
"auth": not auth_disabled(), "auth": not auth_disabled(),
"user": tailnet_user(self.headers), "user": tailnet_user(self.headers),

View File

@@ -37,6 +37,24 @@ class RateLimited(LimitError):
super().__init__(f"prea multe mesaje; mai asteapta {retry_after:.0f}s") super().__init__(f"prea multe mesaje; mai asteapta {retry_after:.0f}s")
def parse_cap(raw, default: float = 0.0) -> float:
"""Plafonul zilnic in USD. `off`/`none`/`nelimitat`/`0` => 0.0 (fara plafon).
Costul raportat de CLI e pretul echivalent la API; pe abonament nu se
factureaza, deci plafonul are sens doar cand chiar platesti per token.
"""
if raw is None:
return default
text = str(raw).strip().lower()
if text in ("", "off", "none", "no", "nu", "false", "unlimited", "nelimitat"):
return 0.0
try:
val = float(text)
except ValueError:
return default
return val if val > 0 else 0.0
class CostCapReached(LimitError): class CostCapReached(LimitError):
def __init__(self, spent: float, cap: float): def __init__(self, spent: float, cap: float):
self.spent, self.cap = spent, cap self.spent, self.cap = spent, cap
@@ -63,7 +81,10 @@ class Limits:
self.rate_per_min = ( self.rate_per_min = (
rate_per_min if rate_per_min is not None else config.get_int("RATE_PER_USER_PER_MIN", 10) rate_per_min if rate_per_min is not None else config.get_int("RATE_PER_USER_PER_MIN", 10)
) )
self.cost_cap = cost_cap if cost_cap is not None else config.get_float("COST_CAP_USD_DAY", 10.0) self.cost_cap = (
parse_cap(cost_cap) if cost_cap is not None
else parse_cap(config.get("COST_CAP_USD_DAY"), 0.0)
)
self.clock = clock self.clock = clock
self._alert = alerter or alerts.alert self._alert = alerter or alerts.alert
self._slots = asyncio.Semaphore(self.max_procs) self._slots = asyncio.Semaphore(self.max_procs)
@@ -115,11 +136,18 @@ class Limits:
return float(self.store.cost_today()) return float(self.store.cost_today())
return self._local_cost return self._local_cost
@property
def cap_enabled(self) -> bool:
"""Plafon dezactivat (0 sau `off`) — cazul abonamentului."""
return self.cost_cap > 0
def stopped(self) -> bool: def stopped(self) -> bool:
"""Plafonul e evaluat pe ziua curenta; reset-ul zilnic vine din `cost.day`.""" """Plafonul e evaluat pe ziua curenta; reset-ul zilnic vine din `cost.day`."""
return self.cost_today() >= self.cost_cap return self.cap_enabled and self.cost_today() >= self.cost_cap
def cost_remaining(self) -> float: def cost_remaining(self) -> float:
if not self.cap_enabled:
return float("inf")
return max(0.0, self.cost_cap - self.cost_today()) return max(0.0, self.cost_cap - self.cost_today())
def check_cost(self) -> None: def check_cost(self) -> None:
@@ -136,7 +164,7 @@ class Limits:
else: else:
self._local_cost = round(self._local_cost + usd, 6) self._local_cost = round(self._local_cost + usd, 6)
total = self._local_cost total = self._local_cost
if total >= self.cost_cap and not self._cap_alerted: if self.cap_enabled and total >= self.cost_cap and not self._cap_alerted:
self._cap_alerted = True self._cap_alerted = True
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self._alert( self._alert(
@@ -145,7 +173,7 @@ class Limits:
f"Cheltuit azi: {total:.2f} USD, plafon {self.cost_cap:.2f}. Puntea nu mai accepta tururi.", f"Cheltuit azi: {total:.2f} USD, plafon {self.cost_cap:.2f}. Puntea nu mai accepta tururi.",
"cost-cap", "cost-cap",
) )
elif total < self.cost_cap: elif not self.cap_enabled or total < self.cost_cap:
self._cap_alerted = False self._cap_alerted = False
return total return total

View File

@@ -28,7 +28,10 @@ DISCORD_USER_IDS=
CLAUDE_MODEL=sonnet CLAUDE_MODEL=sonnet
# Plafon de cost pe zi, in USD. La atingere botul refuza tururi noi si alerteaza. # Plafon de cost pe zi, in USD. La atingere botul refuza tururi noi si alerteaza.
# Reper masurat: un tur banal pe opus a costat $0.1547. # Reper masurat: un tur banal pe opus a costat $0.1547.
COST_CAP_USD_DAY=5.00 # Plafon zilnic de cost. Pe abonament (Pro/Max) costul raportat de CLI e doar
# pretul echivalent la API, nu o factura: lasa `off`. Un numar (ex. 5.00)
# activeaza plafonul si opreste tururile cand e atins.
COST_CAP_USD_DAY=off
# --- Limite ---------------------------------------------------------------- # --- Limite ----------------------------------------------------------------
# Maxim de procese `claude` vii simultan (406 MB RSS fiecare, masurat). # Maxim de procese `claude` vii simultan (406 MB RSS fiecare, masurat).

View File

@@ -78,6 +78,27 @@ def test_plafon_de_cost_opreste_botul(store):
lim.admit("u1") lim.admit("u1")
def test_plafonul_off_nu_opreste_nimic(store, state_dir):
import config
(state_dir / "env").write_text("COST_CAP_USD_DAY=off\n")
config.reload(state_dir)
alerte = []
lim = L.Limits(store, alerter=lambda *a, **k: alerte.append(a))
assert lim.cost_cap == 0.0 and not lim.cap_enabled
lim.record_cost(99.0, "1")
assert not lim.stopped() and lim.cost_remaining() == float("inf")
lim.admit("u1") # nu ridica CostCapReached
assert alerte == []
@pytest.mark.parametrize("raw, asteptat", [
("off", 0.0), ("", 0.0), ("nelimitat", 0.0), ("0", 0.0), ("-3", 0.0),
("5.00", 5.0), (" 2.5 ", 2.5), ("gunoi", 0.0), (None, 0.0),
])
def test_parse_cap(raw, asteptat):
assert L.parse_cap(raw, 0.0) == pytest.approx(asteptat)
def test_plafon_alerteaza_o_singura_data(store): def test_plafon_alerteaza_o_singura_data(store):
alerte = [] alerte = []
lim = mkl(store, cost_cap=0.5, alerter=lambda *a, **k: alerte.append(a)) lim = mkl(store, cost_cap=0.5, alerter=lambda *a, **k: alerte.append(a))