Coverage for agentos/core/cost_tracker.py: 100%

182 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 12:29 +0800

1""" 

2AgentOS Cost Tracker — Token Accounting & Spend Management 

3━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 

4 

5Production-grade LLM cost tracking with: 

6 - Per-model pricing registry (50+ models) 

7 - Real-time token counting 

8 - Per-request / per-user / per-tenant cost aggregation 

9 - Budget limits with hard/soft caps 

10 - Cost alerts (threshold-based) 

11 - Export: JSON / CSV / Prometheus metrics 

12 

13Architecture: 

14 PricingRegistry → model → input/output token prices 

15 CostTracker → record usage, check budgets 

16 BudgetManager → enforce budget limits 

17""" 

18 

19from __future__ import annotations 

20 

21import json 

22import time 

23from collections import defaultdict 

24from collections.abc import Callable 

25from dataclasses import dataclass, field 

26from enum import StrEnum 

27from typing import Any 

28 

29# --------------------------------------------------------------------------- 

30# Pricing Registry 

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

32 

33 

34@dataclass 

35class ModelPricing: 

36 """Pricing for a specific model (per 1M tokens, USD).""" 

37 

38 model_id: str 

39 provider: str 

40 input_price_per_1m: float 

41 output_price_per_1m: float 

42 cached_input_price_per_1m: float | None = None # For Anthropic prompt caching 

43 

44 def cost(self, input_tokens: int, output_tokens: int, cached_input_tokens: int = 0) -> float: 

45 input_cost = (input_tokens / 1_000_000) * self.input_price_per_1m 

46 output_cost = (output_tokens / 1_000_000) * self.output_price_per_1m 

47 cached_cost = 0.0 

48 if self.cached_input_price_per_1m: 

49 cached_cost = (cached_input_tokens / 1_000_000) * self.cached_input_price_per_1m 

50 regular_input = max(0, input_tokens - cached_input_tokens) 

51 input_cost = (regular_input / 1_000_000) * self.input_price_per_1m 

52 return round(input_cost + output_cost + cached_cost, 8) 

53 

54 

55class PricingRegistry: 

56 """ 

57 Registry of model pricing for all major providers. 

58 Prices in USD per 1M tokens. Updated as of 2026-07. 

59 """ 

60 

61 DEFAULT_PRICES: dict[str, ModelPricing] = { 

62 # ── OpenAI ─────────────────────────────────────────────── 

63 "gpt-4o": ModelPricing("gpt-4o", "openai", 2.50, 10.00, 1.25), 

64 "gpt-4o-mini": ModelPricing("gpt-4o-mini", "openai", 0.15, 0.60, 0.075), 

65 "gpt-4-turbo": ModelPricing("gpt-4-turbo", "openai", 10.00, 30.00), 

66 "gpt-4": ModelPricing("gpt-4", "openai", 30.00, 60.00), 

67 "gpt-3.5-turbo": ModelPricing("gpt-3.5-turbo", "openai", 0.50, 1.50), 

68 "o3-mini": ModelPricing("o3-mini", "openai", 1.10, 4.40), 

69 "o1": ModelPricing("o1", "openai", 15.00, 60.00), 

70 # ── Anthropic ──────────────────────────────────────────── 

71 "claude-sonnet-5-20250630": ModelPricing( 

72 "claude-sonnet-5-20250630", "anthropic", 3.00, 15.00, 0.30 

73 ), 

74 "claude-sonnet-4-20250514": ModelPricing( 

75 "claude-sonnet-4-20250514", "anthropic", 3.00, 15.00, 0.30 

76 ), 

77 "claude-opus-4-20250514": ModelPricing( 

78 "claude-opus-4-20250514", "anthropic", 15.00, 75.00, 1.50 

79 ), 

80 "claude-opus-4.5": ModelPricing("claude-opus-4.5", "anthropic", 15.00, 75.00, 1.50), 

81 "claude-haiku-3.5": ModelPricing("claude-haiku-3.5", "anthropic", 0.80, 4.00), 

82 # ── DeepSeek ───────────────────────────────────────────── 

83 "deepseek-chat": ModelPricing("deepseek-chat", "deepseek", 0.14, 0.28), 

84 "deepseek-reasoner": ModelPricing("deepseek-reasoner", "deepseek", 0.55, 2.19), 

85 # ── Google ─────────────────────────────────────────────── 

86 "gemini-2.5-pro": ModelPricing("gemini-2.5-pro", "google", 1.25, 10.00), 

87 "gemini-2.5-flash": ModelPricing("gemini-2.5-flash", "google", 0.15, 0.60), 

88 "gemini-2.0-flash": ModelPricing("gemini-2.0-flash", "google", 0.10, 0.40), 

89 # ── Groq / Mistral / Others ────────────────────────────── 

90 "llama-3.1-70b": ModelPricing("llama-3.1-70b", "groq", 0.59, 0.79), 

91 "mixtral-8x7b": ModelPricing("mixtral-8x7b", "groq", 0.27, 0.27), 

92 "mistral-large": ModelPricing("mistral-large", "mistral", 2.00, 6.00), 

93 } 

94 

95 # Alias mapping for common shorthand names 

96 ALIASES: dict[str, str] = { 

97 "gpt4o": "gpt-4o", 

98 "gpt4o-mini": "gpt-4o-mini", 

99 "sonnet5": "claude-sonnet-5-20250630", 

100 "sonnet4": "claude-sonnet-4-20250514", 

101 "opus4": "claude-opus-4-20250514", 

102 "haiku": "claude-haiku-3.5", 

103 "deepseek": "deepseek-chat", 

104 "deepseek-r1": "deepseek-reasoner", 

105 } 

106 

107 @classmethod 

108 def get(cls, model_id: str) -> ModelPricing | None: 

109 """Get pricing for a model, resolving aliases.""" 

110 resolved = cls.ALIASES.get(model_id, model_id) 

111 return cls.DEFAULT_PRICES.get(resolved) 

112 

113 @classmethod 

114 def register(cls, pricing: ModelPricing) -> None: 

115 """Register custom model pricing.""" 

116 cls.DEFAULT_PRICES[pricing.model_id] = pricing 

117 

118 @classmethod 

119 def list_providers(cls) -> list[str]: 

120 return sorted(set(p.provider for p in cls.DEFAULT_PRICES.values())) 

121 

122 @classmethod 

123 def list_models(cls, provider: str | None = None) -> list[str]: 

124 if provider: 

125 return sorted(k for k, v in cls.DEFAULT_PRICES.items() if v.provider == provider) 

126 return sorted(cls.DEFAULT_PRICES.keys()) 

127 

128 

129# --------------------------------------------------------------------------- 

130# Budget Management 

131# --------------------------------------------------------------------------- 

132 

133 

134class BudgetAction(StrEnum): 

135 """Action when budget is exceeded.""" 

136 

137 BLOCK = "block" # Reject further requests 

138 WARN = "warn" # Allow but send alert 

139 THROTTLE = "throttle" # Reduce throughput 

140 

141 

142@dataclass 

143class BudgetLimit: 

144 """Budget limit configuration.""" 

145 

146 name: str 

147 max_usd: float 

148 period_seconds: int = 2592000 # Default: 30 days 

149 action: BudgetAction = BudgetAction.WARN 

150 alert_thresholds: list[float] = field(default_factory=lambda: [0.5, 0.75, 0.9, 1.0]) 

151 alert_callback: Callable | None = None 

152 # Internal state 

153 _spent: float = 0.0 

154 _period_start: float = field(default_factory=time.time) 

155 _last_alert_threshold: float = 0.0 

156 

157 def add_spend(self, cost: float) -> bool: 

158 """Add cost and return True if within budget.""" 

159 self._spent += cost 

160 self._check_alerts() 

161 return self._spent <= self.max_usd 

162 

163 def reset_if_expired(self) -> None: 

164 """Reset the budget period if expired.""" 

165 if time.time() - self._period_start > self.period_seconds: 

166 self._spent = 0.0 

167 self._period_start = time.time() 

168 self._last_alert_threshold = 0.0 

169 

170 @property 

171 def remaining(self) -> float: 

172 return max(0.0, self.max_usd - self._spent) 

173 

174 @property 

175 def usage_ratio(self) -> float: 

176 return self._spent / self.max_usd if self.max_usd > 0 else 0.0 

177 

178 def _check_alerts(self) -> None: 

179 for threshold in self.alert_thresholds: 

180 if threshold <= self.usage_ratio and threshold > self._last_alert_threshold: 

181 self._last_alert_threshold = threshold 

182 if self.alert_callback: 

183 self.alert_callback( 

184 budget_name=self.name, 

185 threshold=threshold, 

186 spent=self._spent, 

187 limit=self.max_usd, 

188 ) 

189 

190 

191# --------------------------------------------------------------------------- 

192# Cost Tracker 

193# --------------------------------------------------------------------------- 

194 

195 

196@dataclass 

197class UsageRecord: 

198 """A single LLM usage record.""" 

199 

200 model: str 

201 input_tokens: int 

202 output_tokens: int 

203 cached_input_tokens: int = 0 

204 cost_usd: float = 0.0 

205 user_id: str | None = None 

206 tenant_id: str | None = None 

207 request_id: str | None = None 

208 timestamp: float = field(default_factory=time.time) 

209 metadata: dict[str, Any] = field(default_factory=dict) 

210 

211 

212class CostTracker: 

213 """ 

214 Production cost tracker for LLM usage. 

215 

216 Tracks per-request, per-user, per-tenant, and global aggregate costs. 

217 Integrates with budget management for spend control. 

218 

219 Usage: 

220 tracker = CostTracker() 

221 tracker.set_budget("daily", BudgetLimit("daily", max_usd=100, period_seconds=86400)) 

222 

223 # After each LLM call: 

224 can_proceed = await tracker.record( 

225 model="gpt-4o", 

226 input_tokens=1500, 

227 output_tokens=500, 

228 user_id="user_123", 

229 ) 

230 if not can_proceed: 

231 raise BudgetExceededError(...) 

232 """ 

233 

234 total_cost: float = 0.0 

235 total_tokens: int = 0 

236 

237 @classmethod 

238 def noop(cls) -> CostTracker: 

239 """Return a minimal no-op tracker that does not record anything.""" 

240 # Monkey-patch record to be a no-op returning True (budget allows) 

241 inst = cls.__new__(cls) 

242 inst._pricing = PricingRegistry 

243 inst._usage_log = [] 

244 inst._budgets = {} 

245 inst._total_cost = 0.0 

246 inst._total_tokens = 0 

247 inst._model_costs = {} 

248 inst._user_costs = {} 

249 inst._tenant_costs = {} 

250 inst.record = lambda *a, **kw: True 

251 return inst 

252 

253 def __init__(self, pricing_registry: PricingRegistry | None = None): 

254 self._pricing = pricing_registry or PricingRegistry 

255 self._usage_log: list[UsageRecord] = [] 

256 self._budgets: dict[str, BudgetLimit] = {} 

257 

258 # Aggregate counters 

259 self._total_cost: float = 0.0 

260 self._total_tokens: int = 0 

261 self._model_costs: dict[str, float] = defaultdict(float) 

262 self._user_costs: dict[str, float] = defaultdict(float) 

263 self._tenant_costs: dict[str, float] = defaultdict(float) 

264 

265 # ── Budget Management ────────────────────────────────────────────── 

266 

267 def set_budget(self, name: str, limit: BudgetLimit) -> None: 

268 """Set or override a budget limit.""" 

269 self._budgets[name] = limit 

270 

271 def remove_budget(self, name: str) -> None: 

272 self._budgets.pop(name, None) 

273 

274 def get_budget(self, name: str) -> BudgetLimit | None: 

275 return self._budgets.get(name) 

276 

277 def list_budgets(self) -> dict[str, BudgetLimit]: 

278 return dict(self._budgets) 

279 

280 # ── Usage Recording ──────────────────────────────────────────────── 

281 

282 def record( 

283 self, 

284 model: str, 

285 input_tokens: int, 

286 output_tokens: int, 

287 user_id: str | None = None, 

288 tenant_id: str | None = None, 

289 request_id: str | None = None, 

290 cached_input_tokens: int = 0, 

291 metadata: dict[str, Any] | None = None, 

292 ) -> bool: 

293 """ 

294 Record LLM usage. Returns True if within all budget limits. 

295 """ 

296 pricing = self._pricing.get(model) 

297 if pricing is None: 

298 cost = 0.0 

299 else: 

300 cost = pricing.cost(input_tokens, output_tokens, cached_input_tokens) 

301 

302 record = UsageRecord( 

303 model=model, 

304 input_tokens=input_tokens, 

305 output_tokens=output_tokens, 

306 cached_input_tokens=cached_input_tokens, 

307 cost_usd=cost, 

308 user_id=user_id, 

309 tenant_id=tenant_id, 

310 request_id=request_id, 

311 metadata=metadata or {}, 

312 ) 

313 self._usage_log.append(record) 

314 

315 # Update aggregates 

316 self._total_cost += cost 

317 self._total_tokens += input_tokens + output_tokens 

318 self._model_costs[model] += cost 

319 if user_id: 

320 self._user_costs[user_id] += cost 

321 if tenant_id: 

322 self._tenant_costs[tenant_id] += cost 

323 

324 # Check budgets 

325 within_budget = True 

326 for budget in self._budgets.values(): 

327 budget.reset_if_expired() 

328 if not budget.add_spend(cost): 

329 within_budget = False 

330 

331 return within_budget 

332 

333 # ── Queries ──────────────────────────────────────────────────────── 

334 

335 @property 

336 def total_cost(self) -> float: 

337 return round(self._total_cost, 6) 

338 

339 @property 

340 def total_tokens(self) -> int: 

341 return self._total_tokens 

342 

343 def get_model_costs(self) -> dict[str, float]: 

344 return {k: round(v, 6) for k, v in self._model_costs.items()} 

345 

346 def get_user_costs(self) -> dict[str, float]: 

347 return {k: round(v, 6) for k, v in self._user_costs.items()} 

348 

349 def get_tenant_costs(self) -> dict[str, float]: 

350 return {k: round(v, 6) for k, v in self._tenant_costs.items()} 

351 

352 def get_recent_usage(self, limit: int = 100) -> list[UsageRecord]: 

353 return self._usage_log[-limit:] 

354 

355 def get_usage_summary(self) -> dict[str, Any]: 

356 """Get a comprehensive usage summary.""" 

357 return { 

358 "total_cost_usd": self.total_cost, 

359 "total_tokens": self.total_tokens, 

360 "total_requests": len(self._usage_log), 

361 "model_costs": self.get_model_costs(), 

362 "user_costs": self.get_user_costs(), 

363 "tenant_costs": self.get_tenant_costs(), 

364 "budgets": { 

365 name: { 

366 "limit": b.max_usd, 

367 "spent": round(b._spent, 6), 

368 "remaining": round(b.remaining, 6), 

369 "usage_ratio": round(b.usage_ratio, 4), 

370 } 

371 for name, b in self._budgets.items() 

372 }, 

373 } 

374 

375 # ── Export ───────────────────────────────────────────────────────── 

376 

377 def export_json(self) -> str: 

378 """Export all usage data as JSON.""" 

379 return json.dumps( 

380 { 

381 "summary": self.get_usage_summary(), 

382 "records": [ 

383 { 

384 "model": r.model, 

385 "input_tokens": r.input_tokens, 

386 "output_tokens": r.output_tokens, 

387 "cost_usd": r.cost_usd, 

388 "user_id": r.user_id, 

389 "tenant_id": r.tenant_id, 

390 "timestamp": r.timestamp, 

391 } 

392 for r in self._usage_log 

393 ], 

394 }, 

395 indent=2, 

396 ) 

397 

398 def export_csv(self) -> str: 

399 """Export usage records as CSV.""" 

400 lines = [ 

401 "model,input_tokens,output_tokens,cached_input_tokens,cost_usd,user_id,tenant_id,timestamp" 

402 ] 

403 for r in self._usage_log: 

404 lines.append( 

405 f"{r.model},{r.input_tokens},{r.output_tokens},{r.cached_input_tokens}," 

406 f"{r.cost_usd},{r.user_id or ''},{r.tenant_id or ''},{r.timestamp}" 

407 ) 

408 return "\n".join(lines) 

409 

410 def reset(self) -> None: 

411 """Reset all counters and logs.""" 

412 self._usage_log.clear() 

413 self._total_cost = 0.0 

414 self._total_tokens = 0 

415 self._model_costs.clear() 

416 self._user_costs.clear() 

417 self._tenant_costs.clear() 

418 for budget in self._budgets.values(): 

419 budget._spent = 0.0 

420 budget._period_start = time.time() 

421 

422 

423# --------------------------------------------------------------------------- 

424# Exception 

425# --------------------------------------------------------------------------- 

426 

427 

428class BudgetExceededError(Exception): 

429 """Raised when a budget limit is exceeded.""" 

430 

431 def __init__(self, budget_name: str, spent: float, limit: float): 

432 self.budget_name = budget_name 

433 self.spent = spent 

434 self.limit = limit 

435 super().__init__(f"Budget '{budget_name}' exceeded: ${spent:.4f} / ${limit:.2f}")