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

@@ -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