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

180 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 21:26 +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 @classmethod 

235 def noop(cls) -> CostTracker: 

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

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

238 inst = cls.__new__(cls) 

239 inst._pricing = PricingRegistry 

240 inst._usage_log = [] 

241 inst._budgets = {} 

242 inst._total_cost = 0.0 

243 inst._total_tokens = 0 

244 inst._model_costs = {} 

245 inst._user_costs = {} 

246 inst._tenant_costs = {} 

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

248 return inst 

249 

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

251 self._pricing = pricing_registry or PricingRegistry 

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

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

254 

255 # Aggregate counters 

256 self._total_cost: float = 0.0 

257 self._total_tokens: int = 0 

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

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

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

261 

262 # ── Budget Management ────────────────────────────────────────────── 

263 

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

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

266 self._budgets[name] = limit 

267 

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

269 self._budgets.pop(name, None) 

270 

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

272 return self._budgets.get(name) 

273 

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

275 return dict(self._budgets) 

276 

277 # ── Usage Recording ──────────────────────────────────────────────── 

278 

279 def record( 

280 self, 

281 model: str, 

282 input_tokens: int, 

283 output_tokens: int, 

284 user_id: str | None = None, 

285 tenant_id: str | None = None, 

286 request_id: str | None = None, 

287 cached_input_tokens: int = 0, 

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

289 ) -> bool: 

290 """ 

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

292 """ 

293 pricing = self._pricing.get(model) 

294 if pricing is None: 

295 cost = 0.0 

296 else: 

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

298 

299 record = UsageRecord( 

300 model=model, 

301 input_tokens=input_tokens, 

302 output_tokens=output_tokens, 

303 cached_input_tokens=cached_input_tokens, 

304 cost_usd=cost, 

305 user_id=user_id, 

306 tenant_id=tenant_id, 

307 request_id=request_id, 

308 metadata=metadata or {}, 

309 ) 

310 self._usage_log.append(record) 

311 

312 # Update aggregates 

313 self._total_cost += cost 

314 self._total_tokens += input_tokens + output_tokens 

315 self._model_costs[model] += cost 

316 if user_id: 

317 self._user_costs[user_id] += cost 

318 if tenant_id: 

319 self._tenant_costs[tenant_id] += cost 

320 

321 # Check budgets 

322 within_budget = True 

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

324 budget.reset_if_expired() 

325 if not budget.add_spend(cost): 

326 within_budget = False 

327 

328 return within_budget 

329 

330 # ── Queries ──────────────────────────────────────────────────────── 

331 

332 @property 

333 def total_cost(self) -> float: 

334 return round(self._total_cost, 6) 

335 

336 @property 

337 def total_tokens(self) -> int: 

338 return self._total_tokens 

339 

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

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

342 

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

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

345 

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

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

348 

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

350 return self._usage_log[-limit:] 

351 

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

353 """Get a comprehensive usage summary.""" 

354 return { 

355 "total_cost_usd": self.total_cost, 

356 "total_tokens": self.total_tokens, 

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

358 "model_costs": self.get_model_costs(), 

359 "user_costs": self.get_user_costs(), 

360 "tenant_costs": self.get_tenant_costs(), 

361 "budgets": { 

362 name: { 

363 "limit": b.max_usd, 

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

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

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

367 } 

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

369 }, 

370 } 

371 

372 # ── Export ───────────────────────────────────────────────────────── 

373 

374 def export_json(self) -> str: 

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

376 return json.dumps( 

377 { 

378 "summary": self.get_usage_summary(), 

379 "records": [ 

380 { 

381 "model": r.model, 

382 "input_tokens": r.input_tokens, 

383 "output_tokens": r.output_tokens, 

384 "cost_usd": r.cost_usd, 

385 "user_id": r.user_id, 

386 "tenant_id": r.tenant_id, 

387 "timestamp": r.timestamp, 

388 } 

389 for r in self._usage_log 

390 ], 

391 }, 

392 indent=2, 

393 ) 

394 

395 def export_csv(self) -> str: 

396 """Export usage records as CSV.""" 

397 lines = [ 

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

399 ] 

400 for r in self._usage_log: 

401 lines.append( 

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

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

404 ) 

405 return "\n".join(lines) 

406 

407 def reset(self) -> None: 

408 """Reset all counters and logs.""" 

409 self._usage_log.clear() 

410 self._total_cost = 0.0 

411 self._total_tokens = 0 

412 self._model_costs.clear() 

413 self._user_costs.clear() 

414 self._tenant_costs.clear() 

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

416 budget._spent = 0.0 

417 budget._period_start = time.time() 

418 

419 

420# --------------------------------------------------------------------------- 

421# Exception 

422# --------------------------------------------------------------------------- 

423 

424 

425class BudgetExceededError(Exception): 

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

427 

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

429 self.budget_name = budget_name 

430 self.spent = spent 

431 self.limit = limit 

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