Skip to content

Commit 64231d5

Browse files
authored
Update usage.py
1 parent c97e581 commit 64231d5

1 file changed

Lines changed: 68 additions & 0 deletions

File tree

‎app/controllers/usage.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77
from sqlalchemy import func, select
88
from sqlalchemy.orm import Session
99

10+
from ..infra.config import get_settings
1011
from ..models import Conversation, Run, UsageEvent, User
12+
from .errors import PaymentRequired
1113

1214

1315
@dataclass
@@ -53,3 +55,69 @@ def summary(db: Session, user: User) -> UsageTotals:
5355
or 0
5456
)
5557
return totals
58+
59+
60+
def consumed_tokens(db: Session, user: User) -> int:
61+
"""Total tokens (input + output) the user has spent, from the ledger."""
62+
row = db.execute(
63+
select(
64+
func.coalesce(func.sum(UsageEvent.input_tokens), 0)
65+
+ func.coalesce(func.sum(UsageEvent.output_tokens), 0)
66+
).where(UsageEvent.user_id == user.id)
67+
).scalar()
68+
return int(row or 0)
69+
70+
71+
def token_budget(user: User) -> int:
72+
"""The user's total token allowance.
73+
74+
Free-tier allowance plus the token value of their purchased points
75+
(both buckets). A points top-up therefore raises the ceiling.
76+
"""
77+
s = get_settings()
78+
points = (user.plan_points or 0) + (user.pack_points or 0)
79+
return s.budget_free_tokens + points * s.budget_tokens_per_point
80+
81+
82+
@dataclass(frozen=True)
83+
class BudgetStatus:
84+
consumed: int
85+
budget: int
86+
87+
@property
88+
def remaining(self) -> int:
89+
return max(0, self.budget - self.consumed)
90+
91+
@property
92+
def exhausted(self) -> bool:
93+
return self.consumed >= self.budget
94+
95+
96+
def budget_status(db: Session, user: User) -> BudgetStatus:
97+
return BudgetStatus(consumed=consumed_tokens(db, user), budget=token_budget(user))
98+
99+
100+
def enforce_budget(db: Session, user: User) -> None:
101+
"""Kill-switch: refuse a new run when the user is out of budget.
102+
103+
A no-op unless ``budget_enforce`` is on, so dev/internal deployments
104+
are unaffected. Enforced *before* a run starts against a snapshot of
105+
the ledger.
106+
107+
Bound, stated honestly: the ledger only records after a run finishes,
108+
and the per-conversation running-run guard admits one concurrent run
109+
*per conversation*. So a user with many conversations can start one
110+
run per conversation against the same pre-spend snapshot -- overspend
111+
is bounded by the number of the user's conversations, not by one run
112+
globally. Tightening that to a hard global cap would require
113+
reserving tokens up front (a debit-on-start ledger), which is a
114+
deliberate future step, not implemented here.
115+
"""
116+
if not get_settings().budget_enforce:
117+
return
118+
status = budget_status(db, user)
119+
if status.exhausted:
120+
raise PaymentRequired(
121+
f"token budget exhausted ({status.consumed}/{status.budget}); "
122+
"add credits to continue"
123+
)

0 commit comments

Comments
 (0)