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.
@@ -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