agentbudget 0.1.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.
- agentbudget/__init__.py +17 -0
- agentbudget/budget.py +126 -0
- agentbudget/circuit_breaker.py +72 -0
- agentbudget/exceptions.py +24 -0
- agentbudget/integrations/__init__.py +1 -0
- agentbudget/integrations/crewai.py +63 -0
- agentbudget/integrations/langchain.py +89 -0
- agentbudget/ledger.py +87 -0
- agentbudget/pricing.py +78 -0
- agentbudget/py.typed +0 -0
- agentbudget/session.py +299 -0
- agentbudget/types.py +53 -0
- agentbudget/webhook.py +58 -0
- agentbudget-0.1.0.dist-info/METADATA +288 -0
- agentbudget-0.1.0.dist-info/RECORD +18 -0
- agentbudget-0.1.0.dist-info/WHEEL +5 -0
- agentbudget-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentbudget-0.1.0.dist-info/top_level.txt +1 -0
agentbudget/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""AgentBudget - Real-time cost enforcement for AI agent sessions."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .budget import AgentBudget
|
|
6
|
+
from .exceptions import AgentBudgetError, BudgetExhausted, InvalidBudget
|
|
7
|
+
from .session import AsyncBudgetSession, BudgetSession, LoopDetected
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AgentBudget",
|
|
11
|
+
"AgentBudgetError",
|
|
12
|
+
"AsyncBudgetSession",
|
|
13
|
+
"BudgetExhausted",
|
|
14
|
+
"BudgetSession",
|
|
15
|
+
"InvalidBudget",
|
|
16
|
+
"LoopDetected",
|
|
17
|
+
]
|
agentbudget/budget.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""AgentBudget — top-level API for creating budget-enforced sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .circuit_breaker import CircuitBreaker, LoopDetectorConfig
|
|
8
|
+
from .exceptions import InvalidBudget
|
|
9
|
+
from .ledger import Ledger
|
|
10
|
+
from .session import AsyncBudgetSession, BudgetSession
|
|
11
|
+
from .webhook import WebhookEmitter
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _chain_callbacks(
|
|
15
|
+
user_cb: Optional[Callable], webhook_cb: Callable
|
|
16
|
+
) -> Callable:
|
|
17
|
+
"""Combine a user callback with a webhook callback."""
|
|
18
|
+
if user_cb is None:
|
|
19
|
+
return webhook_cb
|
|
20
|
+
|
|
21
|
+
def chained(report):
|
|
22
|
+
user_cb(report)
|
|
23
|
+
webhook_cb(report)
|
|
24
|
+
|
|
25
|
+
return chained
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_budget(value: str | float | int) -> float:
|
|
29
|
+
"""Parse a budget value into a float.
|
|
30
|
+
|
|
31
|
+
Accepts:
|
|
32
|
+
"$5.00", "$5", "5.00", "5", 5.0, 5
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(value, (int, float)):
|
|
35
|
+
if value <= 0:
|
|
36
|
+
raise InvalidBudget(str(value))
|
|
37
|
+
return float(value)
|
|
38
|
+
|
|
39
|
+
if isinstance(value, str):
|
|
40
|
+
cleaned = value.strip().lstrip("$").strip()
|
|
41
|
+
try:
|
|
42
|
+
amount = float(cleaned)
|
|
43
|
+
except ValueError:
|
|
44
|
+
raise InvalidBudget(value)
|
|
45
|
+
if amount <= 0:
|
|
46
|
+
raise InvalidBudget(value)
|
|
47
|
+
return amount
|
|
48
|
+
|
|
49
|
+
raise InvalidBudget(str(value))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AgentBudget:
|
|
53
|
+
"""Create budget-enforced agent sessions.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
budget = AgentBudget(max_spend="$5.00")
|
|
57
|
+
with budget.session() as session:
|
|
58
|
+
response = session.wrap(llm_call(...))
|
|
59
|
+
session.track(tool_call(), cost=0.01)
|
|
60
|
+
print(session.report())
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
max_spend: str | float | int,
|
|
66
|
+
soft_limit: float = 0.9,
|
|
67
|
+
max_repeated_calls: int = 10,
|
|
68
|
+
loop_window_seconds: float = 60.0,
|
|
69
|
+
on_soft_limit: Optional[Callable] = None,
|
|
70
|
+
on_hard_limit: Optional[Callable] = None,
|
|
71
|
+
on_loop_detected: Optional[Callable] = None,
|
|
72
|
+
webhook_url: Optional[str] = None,
|
|
73
|
+
):
|
|
74
|
+
self._budget = parse_budget(max_spend)
|
|
75
|
+
self._soft_limit = soft_limit
|
|
76
|
+
self._loop_config = LoopDetectorConfig(
|
|
77
|
+
max_repeated_calls=max_repeated_calls,
|
|
78
|
+
time_window_seconds=loop_window_seconds,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Wire up webhook emitter if URL is provided
|
|
82
|
+
if webhook_url:
|
|
83
|
+
emitter = WebhookEmitter(webhook_url)
|
|
84
|
+
self._on_soft_limit = _chain_callbacks(on_soft_limit, emitter.on_soft_limit)
|
|
85
|
+
self._on_hard_limit = _chain_callbacks(on_hard_limit, emitter.on_hard_limit)
|
|
86
|
+
self._on_loop_detected = _chain_callbacks(on_loop_detected, emitter.on_loop_detected)
|
|
87
|
+
else:
|
|
88
|
+
self._on_soft_limit = on_soft_limit
|
|
89
|
+
self._on_hard_limit = on_hard_limit
|
|
90
|
+
self._on_loop_detected = on_loop_detected
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def max_spend(self) -> float:
|
|
94
|
+
return self._budget
|
|
95
|
+
|
|
96
|
+
def session(self, session_id: Optional[str] = None) -> BudgetSession:
|
|
97
|
+
"""Create a new budget session."""
|
|
98
|
+
ledger = Ledger(budget=self._budget)
|
|
99
|
+
circuit_breaker = CircuitBreaker(
|
|
100
|
+
soft_limit_fraction=self._soft_limit,
|
|
101
|
+
loop_config=self._loop_config,
|
|
102
|
+
)
|
|
103
|
+
return BudgetSession(
|
|
104
|
+
ledger=ledger,
|
|
105
|
+
session_id=session_id,
|
|
106
|
+
circuit_breaker=circuit_breaker,
|
|
107
|
+
on_soft_limit=self._on_soft_limit,
|
|
108
|
+
on_hard_limit=self._on_hard_limit,
|
|
109
|
+
on_loop_detected=self._on_loop_detected,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def async_session(self, session_id: Optional[str] = None) -> AsyncBudgetSession:
|
|
113
|
+
"""Create a new async budget session."""
|
|
114
|
+
ledger = Ledger(budget=self._budget)
|
|
115
|
+
circuit_breaker = CircuitBreaker(
|
|
116
|
+
soft_limit_fraction=self._soft_limit,
|
|
117
|
+
loop_config=self._loop_config,
|
|
118
|
+
)
|
|
119
|
+
return AsyncBudgetSession(
|
|
120
|
+
ledger=ledger,
|
|
121
|
+
session_id=session_id,
|
|
122
|
+
circuit_breaker=circuit_breaker,
|
|
123
|
+
on_soft_limit=self._on_soft_limit,
|
|
124
|
+
on_hard_limit=self._on_hard_limit,
|
|
125
|
+
on_loop_detected=self._on_loop_detected,
|
|
126
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Circuit breaker — loop detection and budget threshold warnings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class LoopDetectorConfig:
|
|
13
|
+
"""Configuration for loop detection."""
|
|
14
|
+
|
|
15
|
+
max_repeated_calls: int = 10
|
|
16
|
+
time_window_seconds: float = 60.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class LoopDetector:
|
|
20
|
+
"""Detects when the same tool/model is called repeatedly in a short window."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: Optional[LoopDetectorConfig] = None):
|
|
23
|
+
self._config = config or LoopDetectorConfig()
|
|
24
|
+
self._call_log: dict[str, list[float]] = defaultdict(list)
|
|
25
|
+
|
|
26
|
+
def record_call(self, key: str) -> bool:
|
|
27
|
+
"""Record a call and return True if a loop is detected."""
|
|
28
|
+
now = time.time()
|
|
29
|
+
cutoff = now - self._config.time_window_seconds
|
|
30
|
+
|
|
31
|
+
# Prune old entries
|
|
32
|
+
self._call_log[key] = [
|
|
33
|
+
t for t in self._call_log[key] if t > cutoff
|
|
34
|
+
]
|
|
35
|
+
self._call_log[key].append(now)
|
|
36
|
+
|
|
37
|
+
return len(self._call_log[key]) > self._config.max_repeated_calls
|
|
38
|
+
|
|
39
|
+
def reset(self) -> None:
|
|
40
|
+
"""Clear all recorded calls."""
|
|
41
|
+
self._call_log.clear()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CircuitBreaker:
|
|
45
|
+
"""Monitors budget usage and detects runaway loops."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
soft_limit_fraction: float = 0.9,
|
|
50
|
+
loop_config: Optional[LoopDetectorConfig] = None,
|
|
51
|
+
):
|
|
52
|
+
self._soft_limit_fraction = soft_limit_fraction
|
|
53
|
+
self._loop_detector = LoopDetector(loop_config)
|
|
54
|
+
self._soft_limit_triggered = False
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def soft_limit_triggered(self) -> bool:
|
|
58
|
+
return self._soft_limit_triggered
|
|
59
|
+
|
|
60
|
+
def check_budget(self, spent: float, budget: float) -> Optional[str]:
|
|
61
|
+
"""Check budget thresholds. Returns warning message or None."""
|
|
62
|
+
if budget <= 0:
|
|
63
|
+
return None
|
|
64
|
+
fraction = spent / budget
|
|
65
|
+
if fraction >= self._soft_limit_fraction and not self._soft_limit_triggered:
|
|
66
|
+
self._soft_limit_triggered = True
|
|
67
|
+
return f"Soft limit reached: {fraction:.0%} of budget used (${spent:.4f} / ${budget:.2f})"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
def check_loop(self, key: str) -> bool:
|
|
71
|
+
"""Record a call and return True if a loop is detected."""
|
|
72
|
+
return self._loop_detector.record_call(key)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Exceptions for AgentBudget."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AgentBudgetError(Exception):
|
|
5
|
+
"""Base exception for all AgentBudget errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BudgetExhausted(AgentBudgetError):
|
|
9
|
+
"""Raised when a session exceeds its allocated budget."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, budget: float, spent: float):
|
|
12
|
+
self.budget = budget
|
|
13
|
+
self.spent = spent
|
|
14
|
+
super().__init__(
|
|
15
|
+
f"Budget exhausted: spent ${spent:.4f} of ${budget:.2f} budget"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InvalidBudget(AgentBudgetError):
|
|
20
|
+
"""Raised when a budget value is invalid."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, value: str):
|
|
23
|
+
self.value = value
|
|
24
|
+
super().__init__(f"Invalid budget value: {value!r}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework integrations for AgentBudget."""
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""CrewAI integration for AgentBudget.
|
|
2
|
+
|
|
3
|
+
Provides middleware that tracks costs for CrewAI agent runs.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from agentbudget.integrations.crewai import CrewAIBudgetMiddleware
|
|
7
|
+
|
|
8
|
+
middleware = CrewAIBudgetMiddleware(budget="$3.00")
|
|
9
|
+
# Use middleware.session to track costs in your CrewAI setup
|
|
10
|
+
|
|
11
|
+
Requires: crewai (optional dependency)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any, Optional
|
|
17
|
+
|
|
18
|
+
from ..budget import AgentBudget
|
|
19
|
+
from ..session import BudgetSession
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CrewAIBudgetMiddleware:
|
|
23
|
+
"""Budget middleware for CrewAI agent runs.
|
|
24
|
+
|
|
25
|
+
Wraps a CrewAI execution with a budget session. Use the
|
|
26
|
+
`session` attribute to track costs within your CrewAI callbacks.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
budget: str | float | int,
|
|
32
|
+
session_id: Optional[str] = None,
|
|
33
|
+
on_soft_limit: Optional[Any] = None,
|
|
34
|
+
on_hard_limit: Optional[Any] = None,
|
|
35
|
+
on_loop_detected: Optional[Any] = None,
|
|
36
|
+
):
|
|
37
|
+
self._agent_budget = AgentBudget(
|
|
38
|
+
max_spend=budget,
|
|
39
|
+
on_soft_limit=on_soft_limit,
|
|
40
|
+
on_hard_limit=on_hard_limit,
|
|
41
|
+
on_loop_detected=on_loop_detected,
|
|
42
|
+
)
|
|
43
|
+
self.session = self._agent_budget.session(session_id=session_id)
|
|
44
|
+
|
|
45
|
+
def __enter__(self) -> "CrewAIBudgetMiddleware":
|
|
46
|
+
self.session.__enter__()
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *args: Any) -> None:
|
|
50
|
+
self.session.__exit__(*args)
|
|
51
|
+
|
|
52
|
+
def get_report(self) -> dict[str, Any]:
|
|
53
|
+
"""Get the cost report for this middleware's session."""
|
|
54
|
+
return self.session.report()
|
|
55
|
+
|
|
56
|
+
def track(
|
|
57
|
+
self,
|
|
58
|
+
result: Any,
|
|
59
|
+
cost: float,
|
|
60
|
+
tool_name: Optional[str] = None,
|
|
61
|
+
) -> Any:
|
|
62
|
+
"""Track a cost within the CrewAI execution."""
|
|
63
|
+
return self.session.track(result, cost=cost, tool_name=tool_name)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""LangChain/LangGraph integration for AgentBudget.
|
|
2
|
+
|
|
3
|
+
Provides a callback handler that tracks LLM costs automatically.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from agentbudget.integrations.langchain import LangChainBudgetCallback
|
|
7
|
+
|
|
8
|
+
callback = LangChainBudgetCallback(budget="$5.00")
|
|
9
|
+
agent.run(callbacks=[callback])
|
|
10
|
+
|
|
11
|
+
print(callback.session.report())
|
|
12
|
+
|
|
13
|
+
Requires: langchain-core (optional dependency)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from ..budget import AgentBudget, parse_budget
|
|
21
|
+
from ..ledger import Ledger
|
|
22
|
+
from ..pricing import calculate_llm_cost
|
|
23
|
+
from ..session import BudgetSession
|
|
24
|
+
from ..types import CostEvent, CostType
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
28
|
+
|
|
29
|
+
_HAS_LANGCHAIN = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
_HAS_LANGCHAIN = False
|
|
32
|
+
|
|
33
|
+
# Provide a stub so the class definition doesn't fail at import
|
|
34
|
+
class BaseCallbackHandler: # type: ignore[no-redef]
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LangChainBudgetCallback(BaseCallbackHandler):
|
|
39
|
+
"""LangChain callback handler that enforces a per-run budget.
|
|
40
|
+
|
|
41
|
+
Tracks LLM call costs in real time and raises BudgetExhausted
|
|
42
|
+
when the budget is exceeded.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
budget: str | float | int,
|
|
48
|
+
session: Optional[BudgetSession] = None,
|
|
49
|
+
**kwargs: Any,
|
|
50
|
+
):
|
|
51
|
+
if not _HAS_LANGCHAIN:
|
|
52
|
+
raise ImportError(
|
|
53
|
+
"langchain-core is required for LangChainBudgetCallback. "
|
|
54
|
+
"Install it with: pip install langchain-core"
|
|
55
|
+
)
|
|
56
|
+
super().__init__(**kwargs)
|
|
57
|
+
self._agent_budget = AgentBudget(max_spend=budget)
|
|
58
|
+
self.session = session or self._agent_budget.session()
|
|
59
|
+
self.session.__enter__()
|
|
60
|
+
|
|
61
|
+
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
|
|
62
|
+
"""Called when an LLM call finishes. Records the cost."""
|
|
63
|
+
llm_output = getattr(response, "llm_output", None) or {}
|
|
64
|
+
token_usage = llm_output.get("token_usage", {})
|
|
65
|
+
model_name = llm_output.get("model_name")
|
|
66
|
+
|
|
67
|
+
input_tokens = token_usage.get("prompt_tokens")
|
|
68
|
+
output_tokens = token_usage.get("completion_tokens")
|
|
69
|
+
|
|
70
|
+
if model_name and input_tokens is not None and output_tokens is not None:
|
|
71
|
+
cost = calculate_llm_cost(model_name, input_tokens, output_tokens)
|
|
72
|
+
if cost is not None:
|
|
73
|
+
event = CostEvent(
|
|
74
|
+
cost=cost,
|
|
75
|
+
cost_type=CostType.LLM,
|
|
76
|
+
model=model_name,
|
|
77
|
+
input_tokens=input_tokens,
|
|
78
|
+
output_tokens=output_tokens,
|
|
79
|
+
)
|
|
80
|
+
self.session._ledger.record(event)
|
|
81
|
+
self.session._check_after_record(call_key=model_name)
|
|
82
|
+
|
|
83
|
+
def on_tool_end(self, output: str, **kwargs: Any) -> None:
|
|
84
|
+
"""Called when a tool finishes. Override to add cost tracking."""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def get_report(self) -> dict[str, Any]:
|
|
88
|
+
"""Get the cost report for this callback's session."""
|
|
89
|
+
return self.session.report()
|
agentbudget/ledger.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Budget ledger — tracks running totals and event history."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .exceptions import BudgetExhausted
|
|
9
|
+
from .types import CostEvent, CostType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Ledger:
|
|
13
|
+
"""Thread-safe running balance tracker for a budget session."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, budget: float):
|
|
16
|
+
self._budget = budget
|
|
17
|
+
self._spent = 0.0
|
|
18
|
+
self._events: list[CostEvent] = []
|
|
19
|
+
self._lock = threading.Lock()
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def budget(self) -> float:
|
|
23
|
+
return self._budget
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def spent(self) -> float:
|
|
27
|
+
with self._lock:
|
|
28
|
+
return self._spent
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def remaining(self) -> float:
|
|
32
|
+
with self._lock:
|
|
33
|
+
return self._budget - self._spent
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def events(self) -> list[CostEvent]:
|
|
37
|
+
with self._lock:
|
|
38
|
+
return list(self._events)
|
|
39
|
+
|
|
40
|
+
def record(self, event: CostEvent) -> None:
|
|
41
|
+
"""Record a cost event. Raises BudgetExhausted if budget exceeded."""
|
|
42
|
+
with self._lock:
|
|
43
|
+
new_total = self._spent + event.cost
|
|
44
|
+
if new_total > self._budget:
|
|
45
|
+
raise BudgetExhausted(budget=self._budget, spent=new_total)
|
|
46
|
+
self._spent = new_total
|
|
47
|
+
self._events.append(event)
|
|
48
|
+
|
|
49
|
+
def would_exceed(self, cost: float) -> bool:
|
|
50
|
+
"""Check if a cost would exceed the budget without recording it."""
|
|
51
|
+
with self._lock:
|
|
52
|
+
return (self._spent + cost) > self._budget
|
|
53
|
+
|
|
54
|
+
def breakdown(self) -> dict[str, Any]:
|
|
55
|
+
"""Return a cost breakdown by type and model/tool."""
|
|
56
|
+
with self._lock:
|
|
57
|
+
llm_total = 0.0
|
|
58
|
+
llm_calls = 0
|
|
59
|
+
by_model: dict[str, float] = {}
|
|
60
|
+
tool_total = 0.0
|
|
61
|
+
tool_calls = 0
|
|
62
|
+
by_tool: dict[str, float] = {}
|
|
63
|
+
|
|
64
|
+
for event in self._events:
|
|
65
|
+
if event.cost_type == CostType.LLM:
|
|
66
|
+
llm_total += event.cost
|
|
67
|
+
llm_calls += 1
|
|
68
|
+
if event.model:
|
|
69
|
+
by_model[event.model] = by_model.get(event.model, 0.0) + event.cost
|
|
70
|
+
elif event.cost_type == CostType.TOOL:
|
|
71
|
+
tool_total += event.cost
|
|
72
|
+
tool_calls += 1
|
|
73
|
+
if event.tool_name:
|
|
74
|
+
by_tool[event.tool_name] = by_tool.get(event.tool_name, 0.0) + event.cost
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"llm": {
|
|
78
|
+
"total": round(llm_total, 6),
|
|
79
|
+
"calls": llm_calls,
|
|
80
|
+
"by_model": {k: round(v, 6) for k, v in by_model.items()},
|
|
81
|
+
},
|
|
82
|
+
"tools": {
|
|
83
|
+
"total": round(tool_total, 6),
|
|
84
|
+
"calls": tool_calls,
|
|
85
|
+
"by_tool": {k: round(v, 6) for k, v in by_tool.items()},
|
|
86
|
+
},
|
|
87
|
+
}
|
agentbudget/pricing.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Model pricing data for LLM cost calculation.
|
|
2
|
+
|
|
3
|
+
Prices are per token in USD. Updated as of early 2025.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
# Mapping of model name -> (input_price_per_token, output_price_per_token)
|
|
11
|
+
MODEL_PRICING: dict[str, tuple[float, float]] = {
|
|
12
|
+
# OpenAI
|
|
13
|
+
"gpt-4o": (2.50 / 1_000_000, 10.00 / 1_000_000),
|
|
14
|
+
"gpt-4o-2024-11-20": (2.50 / 1_000_000, 10.00 / 1_000_000),
|
|
15
|
+
"gpt-4o-2024-08-06": (2.50 / 1_000_000, 10.00 / 1_000_000),
|
|
16
|
+
"gpt-4o-mini": (0.15 / 1_000_000, 0.60 / 1_000_000),
|
|
17
|
+
"gpt-4o-mini-2024-07-18": (0.15 / 1_000_000, 0.60 / 1_000_000),
|
|
18
|
+
"gpt-4-turbo": (10.00 / 1_000_000, 30.00 / 1_000_000),
|
|
19
|
+
"gpt-4-turbo-2024-04-09": (10.00 / 1_000_000, 30.00 / 1_000_000),
|
|
20
|
+
"gpt-4": (30.00 / 1_000_000, 60.00 / 1_000_000),
|
|
21
|
+
"gpt-3.5-turbo": (0.50 / 1_000_000, 1.50 / 1_000_000),
|
|
22
|
+
"o1": (15.00 / 1_000_000, 60.00 / 1_000_000),
|
|
23
|
+
"o1-mini": (3.00 / 1_000_000, 12.00 / 1_000_000),
|
|
24
|
+
"o1-preview": (15.00 / 1_000_000, 60.00 / 1_000_000),
|
|
25
|
+
"o3-mini": (1.10 / 1_000_000, 4.40 / 1_000_000),
|
|
26
|
+
# Anthropic
|
|
27
|
+
"claude-3-5-sonnet-20241022": (3.00 / 1_000_000, 15.00 / 1_000_000),
|
|
28
|
+
"claude-3-5-sonnet-20240620": (3.00 / 1_000_000, 15.00 / 1_000_000),
|
|
29
|
+
"claude-3-5-haiku-20241022": (0.80 / 1_000_000, 4.00 / 1_000_000),
|
|
30
|
+
"claude-3-opus-20240229": (15.00 / 1_000_000, 75.00 / 1_000_000),
|
|
31
|
+
"claude-3-sonnet-20240229": (3.00 / 1_000_000, 15.00 / 1_000_000),
|
|
32
|
+
"claude-3-haiku-20240307": (0.25 / 1_000_000, 1.25 / 1_000_000),
|
|
33
|
+
# Google Gemini
|
|
34
|
+
"gemini-1.5-pro": (1.25 / 1_000_000, 5.00 / 1_000_000),
|
|
35
|
+
"gemini-1.5-pro-latest": (1.25 / 1_000_000, 5.00 / 1_000_000),
|
|
36
|
+
"gemini-1.5-flash": (0.075 / 1_000_000, 0.30 / 1_000_000),
|
|
37
|
+
"gemini-1.5-flash-latest": (0.075 / 1_000_000, 0.30 / 1_000_000),
|
|
38
|
+
"gemini-2.0-flash": (0.10 / 1_000_000, 0.40 / 1_000_000),
|
|
39
|
+
"gemini-2.0-flash-lite": (0.075 / 1_000_000, 0.30 / 1_000_000),
|
|
40
|
+
"gemini-1.0-pro": (0.50 / 1_000_000, 1.50 / 1_000_000),
|
|
41
|
+
# Mistral
|
|
42
|
+
"mistral-large-latest": (2.00 / 1_000_000, 6.00 / 1_000_000),
|
|
43
|
+
"mistral-large-2411": (2.00 / 1_000_000, 6.00 / 1_000_000),
|
|
44
|
+
"mistral-small-latest": (0.10 / 1_000_000, 0.30 / 1_000_000),
|
|
45
|
+
"mistral-small-2501": (0.10 / 1_000_000, 0.30 / 1_000_000),
|
|
46
|
+
"open-mistral-nemo": (0.15 / 1_000_000, 0.15 / 1_000_000),
|
|
47
|
+
"mistral-medium-latest": (2.70 / 1_000_000, 8.10 / 1_000_000),
|
|
48
|
+
"codestral-latest": (0.30 / 1_000_000, 0.90 / 1_000_000),
|
|
49
|
+
# Cohere
|
|
50
|
+
"command-r-plus": (2.50 / 1_000_000, 10.00 / 1_000_000),
|
|
51
|
+
"command-r": (0.15 / 1_000_000, 0.60 / 1_000_000),
|
|
52
|
+
"command-light": (0.30 / 1_000_000, 0.60 / 1_000_000),
|
|
53
|
+
"command": (1.00 / 1_000_000, 2.00 / 1_000_000),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_model_pricing(model: str) -> Optional[tuple[float, float]]:
|
|
58
|
+
"""Look up per-token pricing for a model.
|
|
59
|
+
|
|
60
|
+
Returns (input_price_per_token, output_price_per_token) or None if unknown.
|
|
61
|
+
"""
|
|
62
|
+
return MODEL_PRICING.get(model)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def calculate_llm_cost(
|
|
66
|
+
model: str,
|
|
67
|
+
input_tokens: int,
|
|
68
|
+
output_tokens: int,
|
|
69
|
+
) -> Optional[float]:
|
|
70
|
+
"""Calculate the cost of an LLM call in USD.
|
|
71
|
+
|
|
72
|
+
Returns None if model pricing is not found.
|
|
73
|
+
"""
|
|
74
|
+
pricing = get_model_pricing(model)
|
|
75
|
+
if pricing is None:
|
|
76
|
+
return None
|
|
77
|
+
input_price, output_price = pricing
|
|
78
|
+
return (input_tokens * input_price) + (output_tokens * output_price)
|
agentbudget/py.typed
ADDED
|
File without changes
|