agent-tally 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ """agent-tally — track costs across every AI coding agent in real-time."""
2
+
3
+ __version__ = "0.5.0"
4
+
5
+ from .budget import BudgetManager, BudgetConfig, BudgetStatus
6
+ from .config import AgentTallyConfig, load_config, save_config
7
+ from .ticker import CostTicker, IncrementalCostTracker
8
+ from .notifier import Notifier, Alert
9
+ from .dashboard import Dashboard
10
+ from .storage import Storage, Session
11
+ from .pricing import PricingConfig
12
+ from .detector import detect_agent, parse_tokens, parse_model
13
+
14
+ __all__ = [
15
+ "BudgetManager",
16
+ "BudgetConfig",
17
+ "BudgetStatus",
18
+ "AgentTallyConfig",
19
+ "load_config",
20
+ "save_config",
21
+ "CostTicker",
22
+ "IncrementalCostTracker",
23
+ "Notifier",
24
+ "Alert",
25
+ "Dashboard",
26
+ "Storage",
27
+ "Session",
28
+ "PricingConfig",
29
+ "detect_agent",
30
+ "parse_tokens",
31
+ "parse_model",
32
+ ]
agent_tally/budget.py ADDED
@@ -0,0 +1,193 @@
1
+ """Budget tracking and kill switch for agent-tally."""
2
+
3
+ from __future__ import annotations
4
+ import json
5
+ import os
6
+ import signal
7
+ from dataclasses import dataclass, field, asdict
8
+ from datetime import datetime, date
9
+ from pathlib import Path
10
+ from typing import Optional, Callable
11
+
12
+ import yaml
13
+
14
+
15
+ DEFAULT_CONFIG_DIR = Path.home() / ".agent-tally"
16
+ DEFAULT_BUDGET_FILE = DEFAULT_CONFIG_DIR / "budget.yaml"
17
+
18
+
19
+ @dataclass
20
+ class BudgetConfig:
21
+ """Budget limits configuration."""
22
+ daily_limit: Optional[float] = None # USD
23
+ session_limit: Optional[float] = None # USD
24
+ warn_at_80: bool = True
25
+ warn_at_95: bool = True
26
+ kill_at_100: bool = True
27
+ webhook_url: Optional[str] = None # For notifications
28
+
29
+
30
+ @dataclass
31
+ class BudgetStatus:
32
+ """Current budget status."""
33
+ session_cost: float = 0.0
34
+ daily_cost: float = 0.0
35
+ daily_limit: Optional[float] = None
36
+ session_limit: Optional[float] = None
37
+ session_pct: float = 0.0
38
+ daily_pct: float = 0.0
39
+ session_exceeded: bool = False
40
+ daily_exceeded: bool = False
41
+ session_warning: Optional[str] = None # "80" or "95"
42
+ daily_warning: Optional[str] = None
43
+
44
+
45
+ class BudgetManager:
46
+ """Manages budget limits and tracking."""
47
+
48
+ def __init__(self, config_path: Optional[Path] = None):
49
+ self.config_path = config_path or DEFAULT_BUDGET_FILE
50
+ self.config = self._load_config()
51
+ self._session_costs: dict[str, float] = {} # session_id -> cost
52
+ self._warned_sessions: set[str] = set() # sessions that hit 80%
53
+ _warned_95_sessions: set[str] = set() # sessions that hit 95%
54
+
55
+ def _load_config(self) -> BudgetConfig:
56
+ """Load budget configuration from file."""
57
+ if not self.config_path.exists():
58
+ return BudgetConfig()
59
+
60
+ with open(self.config_path) as f:
61
+ data = yaml.safe_load(f) or {}
62
+
63
+ return BudgetConfig(
64
+ daily_limit=data.get("daily_limit"),
65
+ session_limit=data.get("session_limit"),
66
+ warn_at_80=data.get("warn_at_80", True),
67
+ warn_at_95=data.get("warn_at_95", True),
68
+ kill_at_100=data.get("kill_at_100", True),
69
+ webhook_url=data.get("webhook_url"),
70
+ )
71
+
72
+ def save_config(self) -> None:
73
+ """Save current config to file."""
74
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
75
+ with open(self.config_path, "w") as f:
76
+ yaml.dump({
77
+ "daily_limit": self.config.daily_limit,
78
+ "session_limit": self.config.session_limit,
79
+ "warn_at_80": self.config.warn_at_80,
80
+ "warn_at_95": self.config.warn_at_95,
81
+ "kill_at_100": self.config.kill_at_100,
82
+ "webhook_url": self.config.webhook_url,
83
+ }, f, default_flow_style=False)
84
+
85
+ def set_limits(self, daily: Optional[float] = None, session: Optional[float] = None) -> None:
86
+ """Set budget limits.
87
+
88
+ Raises:
89
+ ValueError: If daily or session limit is negative.
90
+ """
91
+ if daily is not None:
92
+ if daily < 0:
93
+ raise ValueError(f"daily_limit cannot be negative, got {daily}")
94
+ self.config.daily_limit = daily
95
+ if session is not None:
96
+ if session < 0:
97
+ raise ValueError(f"session_limit cannot be negative, got {session}")
98
+ self.config.session_limit = session
99
+ self.save_config()
100
+
101
+ def check(self, session_id: str, current_cost: float, daily_total: float) -> BudgetStatus:
102
+ """Check budget status and return warnings/exceeded flags."""
103
+ config = self.config
104
+
105
+ # Calculate percentages
106
+ session_pct = 0.0
107
+ daily_pct = 0.0
108
+
109
+ if config.session_limit and config.session_limit > 0:
110
+ session_pct = (current_cost / config.session_limit) * 100
111
+
112
+ if config.daily_limit and config.daily_limit > 0:
113
+ daily_pct = (daily_total / config.daily_limit) * 100
114
+
115
+ # Check warnings
116
+ session_warning = None
117
+ daily_warning = None
118
+
119
+ if config.warn_at_80:
120
+ if session_pct >= 80 and session_id not in self._warned_sessions:
121
+ session_warning = "80"
122
+ self._warned_sessions.add(session_id)
123
+ if daily_pct >= 80:
124
+ daily_warning = "80"
125
+
126
+ if config.warn_at_95:
127
+ if session_pct >= 95:
128
+ session_warning = "95"
129
+ if daily_pct >= 95:
130
+ daily_warning = "95"
131
+
132
+ # Check exceeded
133
+ session_exceeded = config.kill_at_100 and config.session_limit and current_cost >= config.session_limit
134
+ daily_exceeded = config.kill_at_100 and config.daily_limit and daily_total >= config.daily_limit
135
+
136
+ return BudgetStatus(
137
+ session_cost=current_cost,
138
+ daily_cost=daily_total,
139
+ daily_limit=config.daily_limit,
140
+ session_limit=config.session_limit,
141
+ session_pct=session_pct,
142
+ daily_pct=daily_pct,
143
+ session_exceeded=session_exceeded,
144
+ daily_exceeded=daily_exceeded,
145
+ session_warning=session_warning,
146
+ daily_warning=daily_warning,
147
+ )
148
+
149
+ def should_kill(self, status: BudgetStatus) -> bool:
150
+ """Return True if process should be killed due to budget exceeded."""
151
+ return status.session_exceeded or status.daily_exceeded
152
+
153
+ def get_warning_level(self, status: BudgetStatus) -> str:
154
+ """Get warning level: 'none', 'yellow' (80%), 'red' (95%), 'kill' (100%)."""
155
+ if status.session_exceeded or status.daily_exceeded:
156
+ return "kill"
157
+ if (status.session_warning == "95" or status.daily_warning == "95"):
158
+ return "red"
159
+ if (status.session_warning == "80" or status.daily_warning == "80"):
160
+ return "yellow"
161
+ return "none"
162
+
163
+ @staticmethod
164
+ def kill_process(pid: int) -> None:
165
+ """Kill a process by PID."""
166
+ try:
167
+ os.kill(pid, signal.SIGTERM)
168
+ except ProcessLookupError:
169
+ pass
170
+ except PermissionError:
171
+ # Try SIGKILL if SIGTERM fails
172
+ try:
173
+ os.kill(pid, signal.SIGKILL)
174
+ except (ProcessLookupError, PermissionError):
175
+ pass
176
+
177
+ def get_status_text(self, status: BudgetStatus) -> str:
178
+ """Get human-readable status text."""
179
+ lines = []
180
+
181
+ if status.session_limit:
182
+ pct = status.session_pct
183
+ cost = status.session_cost
184
+ limit = status.session_limit
185
+ lines.append(f"Session: ${cost:.4f}/${limit:.2f} ({pct:.1f}%)")
186
+
187
+ if status.daily_limit:
188
+ pct = status.daily_pct
189
+ cost = status.daily_cost
190
+ limit = status.daily_limit
191
+ lines.append(f"Daily: ${cost:.4f}/${limit:.2f} ({pct:.1f}%)")
192
+
193
+ return " | ".join(lines) if lines else "No budget limits set"