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.
- agent_tally/__init__.py +32 -0
- agent_tally/budget.py +193 -0
- agent_tally/cli.py +569 -0
- agent_tally/config.py +136 -0
- agent_tally/dashboard.py +317 -0
- agent_tally/detector.py +235 -0
- agent_tally/display.py +180 -0
- agent_tally/notifier.py +259 -0
- agent_tally/pricing.py +176 -0
- agent_tally/py.typed +0 -0
- agent_tally/storage.py +201 -0
- agent_tally/ticker.py +265 -0
- agent_tally/wrapper.py +237 -0
- agent_tally-0.5.0.dist-info/METADATA +153 -0
- agent_tally-0.5.0.dist-info/RECORD +19 -0
- agent_tally-0.5.0.dist-info/WHEEL +5 -0
- agent_tally-0.5.0.dist-info/entry_points.txt +2 -0
- agent_tally-0.5.0.dist-info/licenses/LICENSE +21 -0
- agent_tally-0.5.0.dist-info/top_level.txt +1 -0
agent_tally/__init__.py
ADDED
|
@@ -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"
|