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:
@@ -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`. |
|
||||||
|
|
||||||
|
|||||||
@@ -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 ""),
|
||||||
"```",
|
"```",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user