Coverage for /home/admin/Documents/AI/applications/lexigram-dev/lexigram/experimental/ai/lexigram-ai-governance/src/lexigram/ai/governance/budget/tracker.py: 36%

139 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 07:19 +0800

1"""Budget tracker — TPM (tokens-per-minute) + sliding-window cost enforcement. 

2 

3Extends the existing RPM-based governance with: 

4* TPM enforcement using a sliding-window counter. 

5* Per-model and per-tenant cost tracking. 

6* Budget alert events at configurable thresholds (80%, 90%, 100%) emitted 

7 via ``EventBusProtocol``. 

8* ``Result``-based API — policy violations return ``Err``, infrastructure 

9 errors raise. 

10""" 

11 

12from __future__ import annotations 

13 

14import asyncio 

15from collections import deque 

16from dataclasses import dataclass 

17import time 

18from typing import TYPE_CHECKING, Any 

19 

20from lexigram.identity import ambient as identity 

21from lexigram.logging import ( 

22 get_logger, 

23) 

24from lexigram.result import Err, Ok, Result 

25 

26if TYPE_CHECKING: 

27 from lexigram.contracts.events import EventBusProtocol 

28 

29logger = get_logger(__name__) 

30 

31# --------------------------------------------------------------------------- 

32# Domain types 

33# --------------------------------------------------------------------------- 

34 

35_ALERT_THRESHOLDS = (0.80, 0.90, 1.00) 

36 

37 

38@dataclass 

39class BudgetApproval: 

40 """Returned when a budget check passes. 

41 

42 Attributes: 

43 remaining_tokens: Remaining TPM capacity for this window. 

44 remaining_cost: Remaining cost budget for the current period. 

45 """ 

46 

47 remaining_tokens: int | None 

48 remaining_cost: float | None 

49 

50 

51@dataclass 

52class BudgetExceeded: 

53 """Domain error returned when a request would exceed a budget limit. 

54 

55 Attributes: 

56 limit_type: ``"tpm"`` or ``"cost"``. 

57 current: Current value in the windows. 

58 limit: Configured limit. 

59 model: Model identifier. 

60 tenant_id: Tenant identifier (if applicable). 

61 """ 

62 

63 limit_type: str 

64 current: float 

65 limit: float 

66 model: str 

67 tenant_id: str | None = None 

68 

69 

70@dataclass 

71class BudgetAlertEvent: 

72 """Domain event emitted when a budget threshold is crossed. 

73 

74 Attributes: 

75 threshold: The fraction threshold that was crossed (0.8, 0.9, 1.0). 

76 limit_type: ``"tpm"`` or ``"cost"``. 

77 current: Current value. 

78 limit: Configured limit. 

79 model: Model identifier. 

80 tenant_id: Tenant identifier. 

81 """ 

82 

83 threshold: float 

84 limit_type: str 

85 current: float 

86 limit: float 

87 model: str 

88 tenant_id: str | None = None 

89 

90 

91# --------------------------------------------------------------------------- 

92# Sliding-window counter 

93# --------------------------------------------------------------------------- 

94 

95 

96class SlidingWindowCounter: 

97 """Thread-safe sliding window counter for rate / budget enforcement. 

98 

99 Uses a deque of ``(timestamp, value)`` tuples. On each access, 

100 entries older than *window_seconds* are dropped. Reservation 

101 amounts (``reserve``/``release_reservation``) are held separately 

102 from used entries and counted by ``total()`` so concurrent 

103 reservations cannot oversubscribe a limit. 

104 """ 

105 

106 def __init__(self, window_seconds: float = 60.0) -> None: 

107 self._window = window_seconds 

108 self._entries: deque[tuple[float, float]] = deque() 

109 self._reserved: dict[str, float] = {} 

110 self._lock = asyncio.Lock() 

111 

112 async def add(self, value: float) -> float: 

113 """Record *value* and return the updated used-window total. 

114 

115 Args: 

116 value: Value to add (token count, cost in USD, etc.). 

117 

118 Returns: 

119 Sum of used values in the current window after adding *value*. 

120 """ 

121 now = time.monotonic() 

122 async with self._lock: 

123 self._entries.append((now, value)) 

124 self._prune(now) 

125 return sum(v for _, v in self._entries) 

126 

127 async def total(self) -> float: 

128 """Return the window total including reservations.""" 

129 now = time.monotonic() 

130 async with self._lock: 

131 self._prune(now) 

132 used = sum(v for _, v in self._entries) 

133 return used + sum(self._reserved.values()) 

134 

135 async def reserve(self, reservation_id: str, value: float) -> None: 

136 """Reserve *value* under *reservation_id* against the limit. 

137 

138 Args: 

139 reservation_id: Identifier used to release the reservation. 

140 value: Amount to hold (token count, cost in USD, etc.). 

141 """ 

142 async with self._lock: 

143 self._reserved[reservation_id] = ( 

144 self._reserved.get(reservation_id, 0.0) + value 

145 ) 

146 

147 async def release_reservation(self, reservation_id: str) -> None: 

148 """Release a reservation; harmless when unknown.""" 

149 async with self._lock: 

150 self._reserved.pop(reservation_id, None) 

151 

152 def _prune(self, now: float) -> None: 

153 cutoff = now - self._window 

154 while self._entries and self._entries[0][0] < cutoff: 

155 self._entries.popleft() 

156 

157 

158# --------------------------------------------------------------------------- 

159# BudgetTracker 

160# --------------------------------------------------------------------------- 

161 

162 

163class BudgetTracker: 

164 """Track and enforce TPM (tokens-per-minute) + cost budgets. 

165 

166 Maintains per-model and per-tenant sliding windows. When a request 

167 would push a window over its limit, returns ``Err(BudgetExceeded)``. 

168 Emits :class:`BudgetAlertEvent` domain events at configurable thresholds 

169 (80%, 90%, 100%) via an optional ``EventBusProtocol``. 

170 

171 Example:: 

172 

173 tracker = BudgetTracker( 

174 tpm_limit=100_000, 

175 cost_limit_hourly=10.0, 

176 ) 

177 result = await tracker.check_budget("gpt-4", estimated_tokens=500) 

178 if result.is_err(): 

179 raise PermissionError(str(result.unwrap_err())) 

180 

181 # After the LLM call succeeds: 

182 await tracker.record_usage("gpt-4", tokens_used=480, cost=0.024) 

183 """ 

184 

185 def __init__( 

186 self, 

187 *, 

188 tpm_limit: int | None = None, 

189 cost_limit_hourly: float | None = None, 

190 window_seconds: float = 60.0, 

191 event_bus: EventBusProtocol | None = None, 

192 ) -> None: 

193 """Initialize the budget tracker. 

194 

195 Args: 

196 tpm_limit: Maximum tokens per minute (across all models). When 

197 ``None``, TPM is not enforced. 

198 cost_limit_hourly: Maximum USD cost per hour. When ``None``, 

199 cost is tracked but not enforced. 

200 window_seconds: Sliding window size in seconds (default 60 s for 

201 TPM; cost window is 3600 s internally). 

202 event_bus: Optional event bus for publishing 

203 :class:`BudgetAlertEvent` domain events. 

204 """ 

205 self._tpm_limit = tpm_limit 

206 self._cost_limit = cost_limit_hourly 

207 self._event_bus = event_bus 

208 

209 # Counters keyed by ``"<model>:<tenant_id>"`` (or ``"<model>:global"``) 

210 self._token_counters: dict[str, SlidingWindowCounter] = {} 

211 self._cost_counters: dict[str, SlidingWindowCounter] = {} 

212 

213 self._tpm_window = window_seconds 

214 self._cost_window = 3600.0 # 1-hour sliding window for cost 

215 

216 self._alerted_thresholds: dict[str, set[float]] = {} 

217 

218 def _counter_key(self, model: str, tenant_id: str | None) -> str: 

219 return f"{model}:{tenant_id or 'global'}" 

220 

221 def _get_token_counter(self, key: str) -> SlidingWindowCounter: 

222 if key not in self._token_counters: 

223 self._token_counters[key] = SlidingWindowCounter(self._tpm_window) 

224 return self._token_counters[key] 

225 

226 def _get_cost_counter(self, key: str) -> SlidingWindowCounter: 

227 if key not in self._cost_counters: 

228 self._cost_counters[key] = SlidingWindowCounter(self._cost_window) 

229 return self._cost_counters[key] 

230 

231 async def check_budget( 

232 self, 

233 model: str, 

234 estimated_tokens: int, 

235 *, 

236 estimated_cost: float = 0.0, 

237 tenant_id: str | None = None, 

238 ) -> Result[BudgetApproval, BudgetExceeded]: 

239 """Check whether a request fits within budget limits. 

240 

241 This method is read-only — it does NOT record usage. Call 

242 :meth:`record_usage` after a successful LLM call. 

243 

244 Args: 

245 model: Model identifier. 

246 estimated_tokens: Estimated token count for the request. 

247 estimated_cost: Estimated USD cost for the request. 

248 tenant_id: Optional tenant identifier for per-tenant limits. 

249 

250 Returns: 

251 ``Ok(BudgetApproval)`` if within budget, 

252 ``Err(BudgetExceeded)`` if the limit would be exceeded. 

253 """ 

254 key = self._counter_key(model, tenant_id) 

255 

256 if self._tpm_limit is not None: 

257 current_tpm = await self._get_token_counter(key).total() 

258 if current_tpm + estimated_tokens > self._tpm_limit: 

259 logger.warning( 

260 "budget_tracker_tpm_exceeded", 

261 model=model, 

262 current_tpm=current_tpm, 

263 tpm_limit=self._tpm_limit, 

264 tenant_id=tenant_id, 

265 ) 

266 return Err( 

267 BudgetExceeded( 

268 limit_type="tpm", 

269 current=current_tpm, 

270 limit=float(self._tpm_limit), 

271 model=model, 

272 tenant_id=tenant_id, 

273 ) 

274 ) 

275 

276 if self._cost_limit is not None and estimated_cost > 0: 

277 current_cost = await self._get_cost_counter(key).total() 

278 if current_cost + estimated_cost > self._cost_limit: 

279 logger.warning( 

280 "budget_tracker_cost_exceeded", 

281 model=model, 

282 current_cost=current_cost, 

283 cost_limit=self._cost_limit, 

284 tenant_id=tenant_id, 

285 ) 

286 return Err( 

287 BudgetExceeded( 

288 limit_type="cost", 

289 current=current_cost, 

290 limit=self._cost_limit, 

291 model=model, 

292 tenant_id=tenant_id, 

293 ) 

294 ) 

295 

296 remaining_tokens = ( 

297 (self._tpm_limit - int(await self._get_token_counter(key).total())) 

298 if self._tpm_limit is not None 

299 else None 

300 ) 

301 remaining_cost = ( 

302 (self._cost_limit - await self._get_cost_counter(key).total()) 

303 if self._cost_limit is not None 

304 else None 

305 ) 

306 return Ok( 

307 BudgetApproval( 

308 remaining_tokens=remaining_tokens, remaining_cost=remaining_cost 

309 ) 

310 ) 

311 

312 async def record_usage( 

313 self, 

314 model: str, 

315 tokens_used: int, 

316 cost: float, 

317 *, 

318 tenant_id: str | None = None, 

319 ) -> None: 

320 """Record actual token usage and cost after a completed LLM call. 

321 

322 Args: 

323 model: Model identifier. 

324 tokens_used: Actual tokens consumed. 

325 cost: Actual cost in USD. 

326 tenant_id: Optional tenant identifier. 

327 """ 

328 key = self._counter_key(model, tenant_id) 

329 new_tpm = await self._get_token_counter(key).add(float(tokens_used)) 

330 new_cost = await self._get_cost_counter(key).add(cost) 

331 

332 # Emit alert events at threshold crossings 

333 await self._check_alerts( 

334 key, model, tenant_id, "tpm", new_tpm, float(self._tpm_limit or 0) 

335 ) 

336 await self._check_alerts( 

337 key, model, tenant_id, "cost", new_cost, self._cost_limit or 0.0 

338 ) 

339 

340 async def _check_alerts( 

341 self, 

342 key: str, 

343 model: str, 

344 tenant_id: str | None, 

345 limit_type: str, 

346 current: float, 

347 limit: float, 

348 ) -> None: 

349 """Emit alert events when thresholds are crossed.""" 

350 if limit <= 0 or self._event_bus is None: 

351 return 

352 

353 alerted = self._alerted_thresholds.setdefault(f"{key}:{limit_type}", set()) 

354 fraction = current / limit 

355 

356 for threshold in _ALERT_THRESHOLDS: 

357 if fraction >= threshold and threshold not in alerted: 

358 alerted.add(threshold) 

359 event = BudgetAlertEvent( 

360 threshold=threshold, 

361 limit_type=limit_type, 

362 current=current, 

363 limit=limit, 

364 model=model, 

365 tenant_id=tenant_id, 

366 ) 

367 result = await self._event_bus.publish(event) 

368 if result.is_err(): 

369 logger.warning( 

370 "budget_alert_publish_failed", 

371 threshold=threshold, 

372 limit_type=limit_type, 

373 error=str(result.unwrap_err()), 

374 ) 

375 

376 async def get_usage( 

377 self, model: str, *, tenant_id: str | None = None 

378 ) -> dict[str, Any]: 

379 """Return current usage totals for a model/tenant combination. 

380 

381 Args: 

382 model: Model identifier. 

383 tenant_id: Optional tenant identifier. 

384 

385 Returns: 

386 Dict with ``tokens_per_minute`` and ``cost_per_hour`` keys. 

387 """ 

388 key = self._counter_key(model, tenant_id) 

389 return { 

390 "tokens_per_minute": await self._get_token_counter(key).total(), 

391 "cost_per_hour": await self._get_cost_counter(key).total(), 

392 "tpm_limit": self._tpm_limit, 

393 "cost_limit_hourly": self._cost_limit, 

394 } 

395 

396 def reset_alerts(self, model: str, *, tenant_id: str | None = None) -> None: 

397 """Reset threshold-alert state for a model/tenant (e.g. on period rollover). 

398 

399 Args: 

400 model: Model identifier. 

401 tenant_id: Optional tenant identifier. 

402 """ 

403 key = self._counter_key(model, tenant_id) 

404 for limit_type in ("tpm", "cost"): 

405 self._alerted_thresholds.pop(f"{key}:{limit_type}", None) 

406 

407 async def reserve( 

408 self, 

409 model: str, 

410 estimated_tokens: int, 

411 *, 

412 estimated_cost: float = 0.0, 

413 tenant_id: str | None = None, 

414 ) -> Result[str, BudgetExceeded]: 

415 """Atomically check limits and reserve capacity for a request. 

416 

417 Reserved amounts count against the sliding window so concurrent 

418 requests cannot oversubscribe a limit. Call 

419 :meth:`release_reservation` after the request settles. 

420 

421 Args: 

422 model: Model identifier. 

423 estimated_tokens: Estimated token count for the request. 

424 estimated_cost: Estimated USD cost for the request. 

425 tenant_id: Optional tenant identifier. 

426 

427 Returns: 

428 ``Ok(reservation_id)`` on success, ``Err(BudgetExceeded)`` 

429 when a limit would be exceeded. 

430 """ 

431 key = self._counter_key(model, tenant_id) 

432 

433 if self._tpm_limit is not None: 

434 current_tpm = await self._get_token_counter(key).total() 

435 if current_tpm + estimated_tokens > self._tpm_limit: 

436 logger.warning( 

437 "budget_tracker_reserve_tpm_exceeded", 

438 model=model, 

439 current_tpm=current_tpm, 

440 tpm_limit=self._tpm_limit, 

441 tenant_id=tenant_id, 

442 ) 

443 return Err( 

444 BudgetExceeded( 

445 limit_type="tpm", 

446 current=current_tpm, 

447 limit=float(self._tpm_limit), 

448 model=model, 

449 tenant_id=tenant_id, 

450 ) 

451 ) 

452 

453 if self._cost_limit is not None and estimated_cost > 0: 

454 current_cost = await self._get_cost_counter(key).total() 

455 if current_cost + estimated_cost > self._cost_limit: 

456 logger.warning( 

457 "budget_tracker_reserve_cost_exceeded", 

458 model=model, 

459 current_cost=current_cost, 

460 cost_limit=self._cost_limit, 

461 tenant_id=tenant_id, 

462 ) 

463 return Err( 

464 BudgetExceeded( 

465 limit_type="cost", 

466 current=current_cost, 

467 limit=self._cost_limit, 

468 model=model, 

469 tenant_id=tenant_id, 

470 ) 

471 ) 

472 

473 reservation_id = identity.new_uuid() 

474 await self._get_token_counter(key).reserve( 

475 reservation_id, float(estimated_tokens) 

476 ) 

477 await self._get_cost_counter(key).reserve(reservation_id, estimated_cost) 

478 return Ok(reservation_id) 

479 

480 async def release_reservation( 

481 self, 

482 model: str, 

483 reservation_id: str, 

484 *, 

485 tenant_id: str | None = None, 

486 ) -> None: 

487 """Release a previously reserved amount; harmless when unknown. 

488 

489 Args: 

490 model: Model identifier the reservation was made for. 

491 reservation_id: Reservation identifier from :meth:`reserve`. 

492 tenant_id: Optional tenant identifier. 

493 """ 

494 key = self._counter_key(model, tenant_id) 

495 await self._get_token_counter(key).release_reservation(reservation_id) 

496 await self._get_cost_counter(key).release_reservation(reservation_id) 

497 

498 

499__all__ = [ 

500 "BudgetAlertEvent", 

501 "BudgetApproval", 

502 "BudgetExceeded", 

503 "BudgetTracker", 

504 "SlidingWindowCounter", 

505]