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
|
||||
alti boti sunt ignorate din principiu.
|
||||
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`.
|
||||
|
||||
---
|
||||
@@ -212,9 +214,13 @@ Fara Tailscale: `ssh -L 18790:127.0.0.1:18790 -N claude@10.0.20.171`.
|
||||
### Cost
|
||||
|
||||
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
|
||||
(`COST_CAP_USD_DAY`) botul nu mai accepta tururi noi si trimite email. Plafonul se
|
||||
reseteaza la schimbarea zilei.
|
||||
fir), in `/status`, si in `state.json` la cheia `cost`. E pretul echivalent la API
|
||||
raportat de CLI: pe abonament nu se factureaza, deci ramane doar o masura de consum.
|
||||
|
||||
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
|
||||
|
||||
@@ -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`. |
|
||||
| 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`. |
|
||||
| „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. |
|
||||
| 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"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 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 ""),
|
||||
"```",
|
||||
]
|
||||
|
||||
@@ -24,7 +24,7 @@ _env: dict[str, str] = {}
|
||||
# Valori implicite pentru cheile pe care le foloseste nucleul.
|
||||
DEFAULTS: dict[str, str] = {
|
||||
"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",
|
||||
"TURN_TIMEOUT_S": "900",
|
||||
"IDLE_REAP_S": "1200",
|
||||
|
||||
@@ -41,6 +41,7 @@ for _p in (str(_BRIDGE), str(_DASH)):
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
import config # noqa: E402
|
||||
from limits import parse_cap # noqa: E402
|
||||
|
||||
# ── constante ───────────────────────────────────────────────────────────
|
||||
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",
|
||||
})
|
||||
|
||||
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)
|
||||
checks.append({
|
||||
"name": "Plafon de cost pe zi",
|
||||
"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:
|
||||
@@ -500,7 +502,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
"dashboard": unit_info(SELF_SERVICE),
|
||||
"threads": threads_view(state),
|
||||
"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()),
|
||||
"auth": not auth_disabled(),
|
||||
"user": tailnet_user(self.headers),
|
||||
|
||||
@@ -37,6 +37,24 @@ class RateLimited(LimitError):
|
||||
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):
|
||||
def __init__(self, spent: float, cap: float):
|
||||
self.spent, self.cap = spent, cap
|
||||
@@ -63,7 +81,10 @@ class Limits:
|
||||
self.rate_per_min = (
|
||||
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._alert = alerter or alerts.alert
|
||||
self._slots = asyncio.Semaphore(self.max_procs)
|
||||
@@ -115,11 +136,18 @@ class Limits:
|
||||
return float(self.store.cost_today())
|
||||
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:
|
||||
"""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:
|
||||
if not self.cap_enabled:
|
||||
return float("inf")
|
||||
return max(0.0, self.cost_cap - self.cost_today())
|
||||
|
||||
def check_cost(self) -> None:
|
||||
@@ -136,7 +164,7 @@ class Limits:
|
||||
else:
|
||||
self._local_cost = round(self._local_cost + usd, 6)
|
||||
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
|
||||
with contextlib.suppress(Exception):
|
||||
self._alert(
|
||||
@@ -145,7 +173,7 @@ class Limits:
|
||||
f"Cheltuit azi: {total:.2f} USD, plafon {self.cost_cap:.2f}. Puntea nu mai accepta tururi.",
|
||||
"cost-cap",
|
||||
)
|
||||
elif total < self.cost_cap:
|
||||
elif not self.cap_enabled or total < self.cost_cap:
|
||||
self._cap_alerted = False
|
||||
return total
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ DISCORD_USER_IDS=
|
||||
CLAUDE_MODEL=sonnet
|
||||
# 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.
|
||||
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 ----------------------------------------------------------------
|
||||
# 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")
|
||||
|
||||
|
||||
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):
|
||||
alerte = []
|
||||
lim = mkl(store, cost_cap=0.5, alerter=lambda *a, **k: alerte.append(a))
|
||||
|
||||
Reference in New Issue
Block a user