Skip to content

Commit 9928103

Browse files
authored
Create test_budget.py
1 parent 19d1666 commit 9928103

1 file changed

Lines changed: 178 additions & 0 deletions

File tree

‎tests/test_budget.py‎

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"""Cost/budget enforcement: the spend kill-switch before a run starts."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from app.controllers import usage
8+
from app.controllers.errors import PaymentRequired
9+
from app.infra.config import get_settings
10+
from app.infra.db import get_session_factory, reset_engine_for_tests
11+
from app.models import Conversation, UsageEvent, User, new_id
12+
13+
14+
def _session():
15+
reset_engine_for_tests(get_settings().db_url)
16+
return get_session_factory()()
17+
18+
19+
def _user(db, *, plan_points=0, pack_points=0) -> User:
20+
user = User(
21+
id=new_id("usr"),
22+
email=f"{new_id('u')}@example.com",
23+
password_hash="x",
24+
plan_points=plan_points,
25+
pack_points=pack_points,
26+
)
27+
db.add(user)
28+
db.flush()
29+
return user
30+
31+
32+
def _spend(db, user, tokens: int) -> None:
33+
conv = Conversation(id=new_id("cnv"), user_id=user.id, title="t")
34+
db.add(conv)
35+
db.flush()
36+
db.add(
37+
UsageEvent(
38+
id=new_id("use"),
39+
user_id=user.id,
40+
conversation_id=conv.id,
41+
run_id=new_id("run"),
42+
input_tokens=tokens,
43+
output_tokens=0,
44+
rounds=1,
45+
)
46+
)
47+
db.flush()
48+
49+
50+
class TestTokenBudget:
51+
def test_free_tier_budget(self, monkeypatch: pytest.MonkeyPatch) -> None:
52+
monkeypatch.setenv("PAW_BUDGET_FREE_TOKENS", "500000")
53+
monkeypatch.setenv("PAW_BUDGET_TOKENS_PER_POINT", "1000")
54+
get_settings.cache_clear()
55+
try:
56+
with _session() as db:
57+
user = _user(db)
58+
assert usage.token_budget(user) == 500_000
59+
finally:
60+
get_settings.cache_clear()
61+
62+
def test_points_raise_the_ceiling(self, monkeypatch: pytest.MonkeyPatch) -> None:
63+
monkeypatch.setenv("PAW_BUDGET_FREE_TOKENS", "100")
64+
monkeypatch.setenv("PAW_BUDGET_TOKENS_PER_POINT", "10")
65+
get_settings.cache_clear()
66+
try:
67+
with _session() as db:
68+
user = _user(db, plan_points=5, pack_points=3)
69+
# 100 free + (5+3)*10 = 180
70+
assert usage.token_budget(user) == 180
71+
finally:
72+
get_settings.cache_clear()
73+
74+
def test_consumed_tokens_sums_ledger(self) -> None:
75+
with _session() as db:
76+
user = _user(db)
77+
_spend(db, user, 1200)
78+
_spend(db, user, 800)
79+
assert usage.consumed_tokens(db, user) == 2000
80+
81+
82+
class TestEnforcement:
83+
def test_disabled_by_default_is_noop(self) -> None:
84+
with _session() as db:
85+
user = _user(db)
86+
_spend(db, user, 10_000_000_000) # way over any budget
87+
# enforcement off by default: must not raise
88+
usage.enforce_budget(db, user)
89+
90+
def test_blocks_when_exhausted(self, monkeypatch: pytest.MonkeyPatch) -> None:
91+
monkeypatch.setenv("PAW_BUDGET_ENFORCE", "true")
92+
monkeypatch.setenv("PAW_BUDGET_FREE_TOKENS", "1000")
93+
monkeypatch.setenv("PAW_BUDGET_TOKENS_PER_POINT", "1000")
94+
get_settings.cache_clear()
95+
try:
96+
with _session() as db:
97+
user = _user(db)
98+
_spend(db, user, 1000) # exactly at budget -> exhausted
99+
with pytest.raises(PaymentRequired, match="budget exhausted"):
100+
usage.enforce_budget(db, user)
101+
finally:
102+
get_settings.cache_clear()
103+
104+
def test_allows_when_under_budget(self, monkeypatch: pytest.MonkeyPatch) -> None:
105+
monkeypatch.setenv("PAW_BUDGET_ENFORCE", "true")
106+
monkeypatch.setenv("PAW_BUDGET_FREE_TOKENS", "1000")
107+
get_settings.cache_clear()
108+
try:
109+
with _session() as db:
110+
user = _user(db)
111+
_spend(db, user, 999)
112+
usage.enforce_budget(db, user) # under budget: ok
113+
status = usage.budget_status(db, user)
114+
assert status.remaining == 1
115+
assert not status.exhausted
116+
finally:
117+
get_settings.cache_clear()
118+
119+
def test_points_extend_budget_past_free_tier(self, monkeypatch: pytest.MonkeyPatch) -> None:
120+
monkeypatch.setenv("PAW_BUDGET_ENFORCE", "true")
121+
monkeypatch.setenv("PAW_BUDGET_FREE_TOKENS", "1000")
122+
monkeypatch.setenv("PAW_BUDGET_TOKENS_PER_POINT", "1000")
123+
get_settings.cache_clear()
124+
try:
125+
with _session() as db:
126+
# free tier alone (1000) would be exhausted by 1500 spent,
127+
# but 1 point adds 1000 -> budget 2000, so it's allowed
128+
user = _user(db, pack_points=1)
129+
_spend(db, user, 1500)
130+
usage.enforce_budget(db, user) # must not raise
131+
finally:
132+
get_settings.cache_clear()
133+
134+
135+
class TestRunEndpointBudget:
136+
def test_run_returns_402_when_exhausted(
137+
self, client, auth_headers, monkeypatch: pytest.MonkeyPatch
138+
) -> None:
139+
# create a conversation, then exhaust the budget and try to run
140+
conv = client.post("/conversations", json={"title": "c"}, headers=auth_headers).json()
141+
142+
# enable enforcement with a tiny budget and record over-limit usage
143+
from app.controllers.usage import consumed_tokens # noqa: F401
144+
from app.infra.db import get_session_factory
145+
from app.models import Conversation as Conv
146+
from app.models import UsageEvent, new_id
147+
148+
db = get_session_factory()()
149+
try:
150+
owner_id = db.query(Conv).filter(Conv.id == conv["id"]).one().user_id
151+
db.add(
152+
UsageEvent(
153+
id=new_id("use"),
154+
user_id=owner_id,
155+
conversation_id=conv["id"],
156+
run_id=new_id("run"),
157+
input_tokens=10_000,
158+
output_tokens=0,
159+
rounds=1,
160+
)
161+
)
162+
db.commit()
163+
finally:
164+
db.close()
165+
166+
monkeypatch.setenv("PAW_BUDGET_ENFORCE", "true")
167+
monkeypatch.setenv("PAW_BUDGET_FREE_TOKENS", "1000")
168+
get_settings.cache_clear()
169+
try:
170+
res = client.post(
171+
f"/conversations/{conv['id']}/runs",
172+
json={"prompt": "hello"},
173+
headers=auth_headers,
174+
)
175+
assert res.status_code == 402, res.text
176+
assert "budget" in res.json()["detail"].lower()
177+
finally:
178+
get_settings.cache_clear()

0 commit comments

Comments
 (0)