Coverage for agentos/cost/tracker.py: 44%

169 statements  

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

1""" 

2v1.10.0: Cost Tracker — token counting & pricing across all providers. 

3 

4Tracks token usage and cost for: OpenAI, Anthropic, Google, DeepSeek, Groq. 

5Features: per-request tracking, budget management, usage reporting. 

6""" 

7 

8from __future__ import annotations 

9 

10from collections import defaultdict 

11from dataclasses import dataclass 

12from datetime import datetime, timezone 

13from enum import Enum 

14from typing import Any 

15 

16 

17# ── Data Classes ────────────────────────────────────────────────── 

18 

19class ProviderPricing(str, Enum): 

20 OPENAI = "openai" 

21 ANTHROPIC = "anthropic" 

22 GOOGLE = "google" 

23 DEEPSEEK = "deepseek" 

24 GROQ = "groq" 

25 CUSTOM = "custom" 

26 

27 

28@dataclass 

29class TokenPricing: 

30 """Pricing per 1M tokens (input/output).""" 

31 provider: ProviderPricing 

32 model: str 

33 input_price_per_1m: float # USD per 1M input tokens 

34 output_price_per_1m: float # USD per 1M output tokens 

35 cache_write_price_per_1m: float = 0.0 

36 cache_read_price_per_1m: float = 0.0 

37 

38 def cost(self, input_tokens: int, output_tokens: int, 

39 cache_write: int = 0, cache_read: int = 0) -> float: 

40 return ( 

41 (input_tokens / 1_000_000) * self.input_price_per_1m 

42 + (output_tokens / 1_000_000) * self.output_price_per_1m 

43 + (cache_write / 1_000_000) * self.cache_write_price_per_1m 

44 + (cache_read / 1_000_000) * self.cache_read_price_per_1m 

45 ) 

46 

47 

48@dataclass 

49class TokenUsage: 

50 """Token usage for a single API call.""" 

51 model: str 

52 input_tokens: int = 0 

53 output_tokens: int = 0 

54 cache_write_tokens: int = 0 

55 cache_read_tokens: int = 0 

56 total_tokens: int = 0 

57 cost: float = 0.0 

58 latency_ms: float = 0.0 

59 timestamp: str = "" 

60 

61 def __post_init__(self): 

62 if not self.total_tokens: 

63 self.total_tokens = self.input_tokens + self.output_tokens 

64 if not self.timestamp: 

65 self.timestamp = datetime.now(timezone.utc).isoformat() 

66 

67 

68@dataclass 

69class Budget: 

70 """Spending budget configuration.""" 

71 name: str 

72 limit: float # USD 

73 period: str = "monthly" # daily / weekly / monthly / total 

74 current_spend: float = 0.0 

75 alert_threshold: float = 0.8 # Alert at 80% of limit 

76 hard_stop: bool = False # Block requests when exceeded 

77 

78 @property 

79 def remaining(self) -> float: 

80 return max(0.0, self.limit - self.current_spend) 

81 

82 @property 

83 def pct_used(self) -> float: 

84 return (self.current_spend / self.limit * 100) if self.limit > 0 else 0.0 

85 

86 @property 

87 def exceeded(self) -> bool: 

88 return self.current_spend >= self.limit 

89 

90 @property 

91 def should_alert(self) -> bool: 

92 return self.pct_used >= self.alert_threshold * 100 

93 

94 

95# ── Default Pricing (as of 2025-07) ──────────────────────────────── 

96 

97DEFAULT_PRICING: dict[str, TokenPricing] = { 

98 # OpenAI 

99 "gpt-4o": TokenPricing(ProviderPricing.OPENAI, "gpt-4o", 2.50, 10.00), 

100 "gpt-4o-mini": TokenPricing(ProviderPricing.OPENAI, "gpt-4o-mini", 0.15, 0.60), 

101 "gpt-4-turbo": TokenPricing(ProviderPricing.OPENAI, "gpt-4-turbo", 10.00, 30.00), 

102 "gpt-3.5-turbo": TokenPricing(ProviderPricing.OPENAI, "gpt-3.5-turbo", 0.50, 1.50), 

103 "o3-mini": TokenPricing(ProviderPricing.OPENAI, "o3-mini", 1.10, 4.40), 

104 # Anthropic 

105 "claude-3-5-sonnet": TokenPricing(ProviderPricing.ANTHROPIC, "claude-3-5-sonnet", 3.00, 15.00, 

106 cache_write_price_per_1m=3.75, cache_read_price_per_1m=0.30), 

107 "claude-3-haiku": TokenPricing(ProviderPricing.ANTHROPIC, "claude-3-haiku", 0.25, 1.25), 

108 "claude-3-opus": TokenPricing(ProviderPricing.ANTHROPIC, "claude-3-opus", 15.00, 75.00), 

109 # Google 

110 "gemini-2.0-flash": TokenPricing(ProviderPricing.GOOGLE, "gemini-2.0-flash", 0.10, 0.40), 

111 "gemini-2.0-pro": TokenPricing(ProviderPricing.GOOGLE, "gemini-2.0-pro", 1.25, 5.00), 

112 "gemini-1.5-pro": TokenPricing(ProviderPricing.GOOGLE, "gemini-1.5-pro", 1.25, 5.00), 

113 # DeepSeek 

114 "deepseek-chat": TokenPricing(ProviderPricing.DEEPSEEK, "deepseek-chat", 0.27, 1.10), 

115 "deepseek-reasoner": TokenPricing(ProviderPricing.DEEPSEEK, "deepseek-reasoner", 0.55, 2.19), 

116 # Groq 

117 "llama-3.3-70b": TokenPricing(ProviderPricing.GROQ, "llama-3.3-70b", 0.59, 0.79), 

118 "mixtral-8x7b": TokenPricing(ProviderPricing.GROQ, "mixtral-8x7b", 0.24, 0.24), 

119 "gemma2-9b-it": TokenPricing(ProviderPricing.GROQ, "gemma2-9b-it", 0.20, 0.20), 

120} 

121 

122 

123# ── Token Counter (heuristic-based, provider-agnostic) ──────────── 

124 

125class TokenCounter: 

126 """Approximate token counter based on word count + code heuristics. 

127 

128 For exact counts, use provider-specific tokenizers (tiktoken, etc.). 

129 This provides fast, offline estimates within ~10% accuracy. 

130 """ 

131 

132 # Rough tokens-per-word ratios (language-dependent) 

133 TOKENS_PER_WORD: dict[str, float] = { 

134 "en": 1.3, # ~4 chars/token for English 

135 "zh": 0.5, # ~2 chars/token for Chinese (character-based) 

136 "ja": 0.6, 

137 "ko": 0.6, 

138 "code": 0.7, # Code tends to be denser in tokens per word 

139 "default": 1.0, 

140 } 

141 

142 @classmethod 

143 def count(cls, text: str, source: str = "default") -> int: 

144 """Estimate token count.""" 

145 if not text: 

146 return 0 

147 

148 ratio = cls.TOKENS_PER_WORD.get(source, cls.TOKENS_PER_WORD["default"]) 

149 chars = len(text) 

150 

151 # For Chinese (high CJK ratio), use character-based estimation 

152 cjk_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' 

153 or '\u3040' <= c <= '\u30ff') 

154 cjk_ratio = cjk_chars / max(chars, 1) 

155 

156 if cjk_ratio > 0.3: 

157 # Mostly Chinese/Japanese — use CJK character ratio 

158 non_cjk = chars - cjk_chars 

159 return int(cjk_chars * cls.TOKENS_PER_WORD["zh"] + non_cjk * 0.25) 

160 

161 if source == "code" or cls._is_code(text): 

162 ratio = cls.TOKENS_PER_WORD["code"] 

163 

164 words = len(text.split()) 

165 return max(1, int(words * ratio)) 

166 

167 @staticmethod 

168 def _is_code(text: str) -> bool: 

169 """Heuristic: detect if text is code.""" 

170 code_indicators = ["def ", "class ", "import ", "from ", "function", 

171 "const ", "let ", "var ", "{", "}", "=>", "return "] 

172 count = sum(1 for ind in code_indicators if ind in text) 

173 return count >= 3 

174 

175 

176# ── Cost Tracker ─────────────────────────────────────────────────── 

177 

178class CostTracker: 

179 """Track token usage and costs across all provider calls. 

180 

181 Usage: 

182 tracker = CostTracker() 

183 tracker.record("gpt-4o", input_tokens=500, output_tokens=200) 

184 tracker.record("claude-3-5-sonnet", input_tokens=1000, output_tokens=500) 

185 report = tracker.report() 

186 """ 

187 

188 total_cost: float = 0.0 

189 total_tokens: int = 0 

190 

191 @classmethod 

192 def noop(cls) -> "CostTracker": 

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

194 inst = cls.__new__(cls) 

195 inst.pricing = {} 

196 inst.budgets = {} 

197 inst.usage_log = [] 

198 inst._model_totals = {} 

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

200 return inst 

201 

202 def __init__( 

203 self, 

204 custom_pricing: dict[str, TokenPricing] | None = None, 

205 budgets: list[Budget] | None = None, 

206 ): 

207 self.pricing: dict[str, TokenPricing] = {**DEFAULT_PRICING} 

208 if custom_pricing: 

209 self.pricing.update(custom_pricing) 

210 

211 self.budgets: dict[str, Budget] = {} 

212 if budgets: 

213 for b in budgets: 

214 self.budgets[b.name] = b 

215 

216 self.usage_log: list[TokenUsage] = [] 

217 self._model_totals: dict[str, dict[str, float]] = defaultdict( 

218 lambda: {"input_tokens": 0, "output_tokens": 0, "cost": 0.0, "calls": 0} 

219 ) 

220 

221 def get_price(self, model: str) -> TokenPricing: 

222 """Get pricing for a model. Falls back to default if unknown.""" 

223 if model in self.pricing: 

224 return self.pricing[model] 

225 

226 # Best-effort fallback based on model name 

227 if "gpt-4" in model: 

228 return TokenPricing(ProviderPricing.OPENAI, model, 2.50, 10.00) 

229 if "gpt-3" in model: 

230 return TokenPricing(ProviderPricing.OPENAI, model, 0.50, 1.50) 

231 if "claude" in model: 

232 return TokenPricing(ProviderPricing.ANTHROPIC, model, 3.00, 15.00) 

233 if "gemini" in model: 

234 return TokenPricing(ProviderPricing.GOOGLE, model, 0.10, 0.40) 

235 if "deepseek" in model: 

236 return TokenPricing(ProviderPricing.DEEPSEEK, model, 0.27, 1.10) 

237 if any(m in model for m in ["llama", "mixtral", "gemma"]): 

238 return TokenPricing(ProviderPricing.GROQ, model, 0.20, 0.20) 

239 

240 return TokenPricing(ProviderPricing.CUSTOM, model, 1.00, 1.00) 

241 

242 def record( 

243 self, 

244 model: str, 

245 input_tokens: int = 0, 

246 output_tokens: int = 0, 

247 cache_write_tokens: int = 0, 

248 cache_read_tokens: int = 0, 

249 latency_ms: float = 0.0, 

250 ) -> TokenUsage: 

251 """Record a token usage event. Returns the TokenUsage with cost.""" 

252 pricing = self.get_price(model) 

253 cost = pricing.cost(input_tokens, output_tokens, cache_write_tokens, cache_read_tokens) 

254 

255 usage = TokenUsage( 

256 model=model, 

257 input_tokens=input_tokens, 

258 output_tokens=output_tokens, 

259 cache_write_tokens=cache_write_tokens, 

260 cache_read_tokens=cache_read_tokens, 

261 cost=cost, 

262 latency_ms=latency_ms, 

263 ) 

264 self.usage_log.append(usage) 

265 

266 # Update model totals 

267 mt = self._model_totals[model] 

268 mt["input_tokens"] += input_tokens 

269 mt["output_tokens"] += output_tokens 

270 mt["cost"] += cost 

271 mt["calls"] += 1 

272 

273 # Update budgets 

274 for budget in self.budgets.values(): 

275 budget.current_spend += cost 

276 

277 return usage 

278 

279 def check_budget(self) -> list[str]: 

280 """Check all budgets. Returns list of alert messages.""" 

281 alerts = [] 

282 for budget in self.budgets.values(): 

283 if budget.exceeded and budget.hard_stop: 

284 alerts.append(f"BUDGET EXCEEDED: {budget.name} (${budget.current_spend:.2f}/${budget.limit:.2f})") 

285 elif budget.should_alert: 

286 alerts.append(f"Budget alert: {budget.name} at {budget.pct_used:.0f}% (${budget.current_spend:.2f}/${budget.limit:.2f})") 

287 return alerts 

288 

289 def report(self) -> str: 

290 """Generate a human-readable cost report.""" 

291 total_cost = sum(u.cost for u in self.usage_log) 

292 total_tokens = sum(u.total_tokens for u in self.usage_log) 

293 total_calls = len(self.usage_log) 

294 

295 lines = [ 

296 "╔══ Cost Report ══╗", 

297 f"║ Total calls: {total_calls}", 

298 f"║ Total tokens: {total_tokens:,}", 

299 f"║ Total cost: ${total_cost:.4f}", 

300 "╚════════════════╝", 

301 "", 

302 "By model:", 

303 ] 

304 for model, totals in sorted(self._model_totals.items(), key=lambda x: -x[1]["cost"]): 

305 lines.append( 

306 f" {model:<30} {totals['calls']:>4} calls " 

307 f"{totals['input_tokens']+totals['output_tokens']:>12,} tokens " 

308 f"${totals['cost']:>8.4f}" 

309 ) 

310 

311 if self.budgets: 

312 lines.append("\nBudgets:") 

313 for budget in self.budgets.values(): 

314 status = "EXCEEDED" if budget.exceeded else "OK" 

315 lines.append( 

316 f" {budget.name:<20} ${budget.current_spend:.2f}/${budget.limit:.2f} " 

317 f"({budget.pct_used:.0f}%) [{status}]" 

318 ) 

319 

320 return "\n".join(lines) 

321 

322 def report_dict(self) -> dict[str, Any]: 

323 """Generate a machine-readable cost report.""" 

324 return { 

325 "total_calls": len(self.usage_log), 

326 "total_tokens": sum(u.total_tokens for u in self.usage_log), 

327 "total_cost": sum(u.cost for u in self.usage_log), 

328 "by_model": { 

329 model: dict(totals) 

330 for model, totals in self._model_totals.items() 

331 }, 

332 "recent": [ 

333 { 

334 "model": u.model, 

335 "input_tokens": u.input_tokens, 

336 "output_tokens": u.output_tokens, 

337 "cost": u.cost, 

338 "timestamp": u.timestamp, 

339 } 

340 for u in self.usage_log[-20:] # Last 20 calls 

341 ], 

342 } 

343 

344 def reset(self) -> None: 

345 """Reset all counters (keeps pricing and budgets).""" 

346 self.usage_log.clear() 

347 self._model_totals.clear() 

348 for budget in self.budgets.values(): 

349 budget.current_spend = 0.0 

350 

351 def set_budget(self, name: str, limit: float, hard_stop: bool = False) -> Budget: 

352 """Create or update a budget.""" 

353 budget = Budget(name=name, limit=limit, hard_stop=hard_stop) 

354 self.budgets[name] = budget 

355 return budget 

356 

357 

358# ── Backward Compatibility Aliases (v1.2.7-) ────────────────────── 

359# Old names → new equivalents 

360RunCostSession = CostTracker # CostTracker was RunCostSession 

361ModelPricing = TokenPricing # ModelPricing → TokenPricing 

362UsageRecord = TokenUsage # UsageRecord → TokenUsage 

363PRICING = DEFAULT_PRICING # PRICING → DEFAULT_PRICING