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
« 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━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
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
13Architecture:
14 PricingRegistry → model → input/output token prices
15 CostTracker → record usage, check budgets
16 BudgetManager → enforce budget limits
17"""
19from __future__ import annotations
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
29# ---------------------------------------------------------------------------
30# Pricing Registry
31# ---------------------------------------------------------------------------
34@dataclass
35class ModelPricing:
36 """Pricing for a specific model (per 1M tokens, USD)."""
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
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)
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 """
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 }
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 }
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)
113 @classmethod
114 def register(cls, pricing: ModelPricing) -> None:
115 """Register custom model pricing."""
116 cls.DEFAULT_PRICES[pricing.model_id] = pricing
118 @classmethod
119 def list_providers(cls) -> list[str]:
120 return sorted(set(p.provider for p in cls.DEFAULT_PRICES.values()))
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())
129# ---------------------------------------------------------------------------
130# Budget Management
131# ---------------------------------------------------------------------------
134class BudgetAction(StrEnum):
135 """Action when budget is exceeded."""
137 BLOCK = "block" # Reject further requests
138 WARN = "warn" # Allow but send alert
139 THROTTLE = "throttle" # Reduce throughput
142@dataclass
143class BudgetLimit:
144 """Budget limit configuration."""
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
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
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
170 @property
171 def remaining(self) -> float:
172 return max(0.0, self.max_usd - self._spent)
174 @property
175 def usage_ratio(self) -> float:
176 return self._spent / self.max_usd if self.max_usd > 0 else 0.0
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 )
191# ---------------------------------------------------------------------------
192# Cost Tracker
193# ---------------------------------------------------------------------------
196@dataclass
197class UsageRecord:
198 """A single LLM usage record."""
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)
212class CostTracker:
213 """
214 Production cost tracker for LLM usage.
216 Tracks per-request, per-user, per-tenant, and global aggregate costs.
217 Integrates with budget management for spend control.
219 Usage:
220 tracker = CostTracker()
221 tracker.set_budget("daily", BudgetLimit("daily", max_usd=100, period_seconds=86400))
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 """
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
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] = {}
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)
262 # ── Budget Management ──────────────────────────────────────────────
264 def set_budget(self, name: str, limit: BudgetLimit) -> None:
265 """Set or override a budget limit."""
266 self._budgets[name] = limit
268 def remove_budget(self, name: str) -> None:
269 self._budgets.pop(name, None)
271 def get_budget(self, name: str) -> BudgetLimit | None:
272 return self._budgets.get(name)
274 def list_budgets(self) -> dict[str, BudgetLimit]:
275 return dict(self._budgets)
277 # ── Usage Recording ────────────────────────────────────────────────
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)
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)
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
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
328 return within_budget
330 # ── Queries ────────────────────────────────────────────────────────
332 @property
333 def total_cost(self) -> float:
334 return round(self._total_cost, 6)
336 @property
337 def total_tokens(self) -> int:
338 return self._total_tokens
340 def get_model_costs(self) -> dict[str, float]:
341 return {k: round(v, 6) for k, v in self._model_costs.items()}
343 def get_user_costs(self) -> dict[str, float]:
344 return {k: round(v, 6) for k, v in self._user_costs.items()}
346 def get_tenant_costs(self) -> dict[str, float]:
347 return {k: round(v, 6) for k, v in self._tenant_costs.items()}
349 def get_recent_usage(self, limit: int = 100) -> list[UsageRecord]:
350 return self._usage_log[-limit:]
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 }
372 # ── Export ─────────────────────────────────────────────────────────
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 )
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)
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()
420# ---------------------------------------------------------------------------
421# Exception
422# ---------------------------------------------------------------------------
425class BudgetExceededError(Exception):
426 """Raised when a budget limit is exceeded."""
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}")