tetherai-python 0.1.0a0__tar.gz
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.
- tetherai_python-0.1.0a0/PKG-INFO +35 -0
- tetherai_python-0.1.0a0/README.md +2 -0
- tetherai_python-0.1.0a0/pyproject.toml +72 -0
- tetherai_python-0.1.0a0/setup.cfg +4 -0
- tetherai_python-0.1.0a0/src/tetherai/__init__.py +42 -0
- tetherai_python-0.1.0a0/src/tetherai/_version.py +1 -0
- tetherai_python-0.1.0a0/src/tetherai/budget.py +118 -0
- tetherai_python-0.1.0a0/src/tetherai/circuit_breaker.py +132 -0
- tetherai_python-0.1.0a0/src/tetherai/config.py +91 -0
- tetherai_python-0.1.0a0/src/tetherai/crewai/__init__.py +3 -0
- tetherai_python-0.1.0a0/src/tetherai/crewai/integration.py +68 -0
- tetherai_python-0.1.0a0/src/tetherai/exceptions.py +92 -0
- tetherai_python-0.1.0a0/src/tetherai/exporter.py +60 -0
- tetherai_python-0.1.0a0/src/tetherai/interceptor.py +258 -0
- tetherai_python-0.1.0a0/src/tetherai/pricing.py +99 -0
- tetherai_python-0.1.0a0/src/tetherai/token_counter.py +150 -0
- tetherai_python-0.1.0a0/src/tetherai/trace.py +117 -0
- tetherai_python-0.1.0a0/src/tetherai_python.egg-info/PKG-INFO +35 -0
- tetherai_python-0.1.0a0/src/tetherai_python.egg-info/SOURCES.txt +20 -0
- tetherai_python-0.1.0a0/src/tetherai_python.egg-info/dependency_links.txt +1 -0
- tetherai_python-0.1.0a0/src/tetherai_python.egg-info/requires.txt +17 -0
- tetherai_python-0.1.0a0/src/tetherai_python.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tetherai-python
|
|
3
|
+
Version: 0.1.0a0
|
|
4
|
+
Summary: AI budget guardrails for LLM applications
|
|
5
|
+
Author-email: TetherAI Engineering <engineering@tetherai.com>
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/tetherai/tetherai-python
|
|
8
|
+
Project-URL: Repository, https://github.com/tetherai/tetherai-python
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
Requires-Dist: tiktoken>=0.7.0
|
|
20
|
+
Provides-Extra: crewai
|
|
21
|
+
Requires-Dist: crewai>=1.0.0; extra == "crewai"
|
|
22
|
+
Provides-Extra: litellm
|
|
23
|
+
Requires-Dist: litellm>=1.40.0; extra == "litellm"
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-mock>=3.12; extra == "dev"
|
|
29
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
30
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
31
|
+
Requires-Dist: crewai>=1.0.0; extra == "dev"
|
|
32
|
+
Requires-Dist: litellm>=1.40.0; extra == "dev"
|
|
33
|
+
|
|
34
|
+
[](https://github.com/tetherai/tetherai-python/actions/workflows/ci.yml)
|
|
35
|
+
[](https://pypi.org/project/tetherai/)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tetherai-python"
|
|
3
|
+
version = "0.1.0-alpha"
|
|
4
|
+
description = "AI budget guardrails for LLM applications"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "Apache-2.0" }
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "TetherAI Engineering", email = "engineering@tetherai.com" }
|
|
10
|
+
]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: OSI Approved :: Apache Software License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
dependencies = [
|
|
23
|
+
"tiktoken>=0.7.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
crewai = ["crewai>=1.0.0"]
|
|
28
|
+
litellm = ["litellm>=1.40.0"]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"pytest-cov>=5.0",
|
|
32
|
+
"pytest-asyncio>=0.23",
|
|
33
|
+
"pytest-mock>=3.12",
|
|
34
|
+
"ruff>=0.4",
|
|
35
|
+
"mypy>=1.10",
|
|
36
|
+
"crewai>=1.0.0",
|
|
37
|
+
"litellm>=1.40.0",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/tetherai/tetherai-python"
|
|
42
|
+
Repository = "https://github.com/tetherai/tetherai-python"
|
|
43
|
+
|
|
44
|
+
[build-system]
|
|
45
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
46
|
+
build-backend = "setuptools.build_meta"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.packages.find]
|
|
49
|
+
where = ["src"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
markers = [
|
|
55
|
+
"integration: integration tests that require more setup",
|
|
56
|
+
"slow: slow running tests",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
target-version = "py310"
|
|
61
|
+
line-length = 100
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "W", "I", "N", "UP", "B", "C4", "SIM"]
|
|
65
|
+
ignore = ["E501"]
|
|
66
|
+
|
|
67
|
+
[tool.mypy]
|
|
68
|
+
python_version = "3.10"
|
|
69
|
+
strict = true
|
|
70
|
+
warn_return_any = true
|
|
71
|
+
warn_unused_configs = true
|
|
72
|
+
disallow_untyped_defs = true
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from tetherai._version import __version__
|
|
4
|
+
from tetherai.circuit_breaker import enforce_budget
|
|
5
|
+
from tetherai.config import TetherConfig, load_config
|
|
6
|
+
from tetherai.exceptions import (
|
|
7
|
+
BudgetExceededError,
|
|
8
|
+
TetherError,
|
|
9
|
+
TokenCountError,
|
|
10
|
+
TurnLimitError,
|
|
11
|
+
UnknownModelError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Tether:
|
|
16
|
+
"""TetherAI namespace class."""
|
|
17
|
+
|
|
18
|
+
enforce_budget = staticmethod(enforce_budget)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
tether = Tether
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def protect_crew(*args: Any, **kwargs: Any) -> Any:
|
|
25
|
+
from tetherai.crewai.integration import protect_crew as _protect_crew
|
|
26
|
+
|
|
27
|
+
return _protect_crew(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"BudgetExceededError",
|
|
32
|
+
"TetherConfig",
|
|
33
|
+
"TetherError",
|
|
34
|
+
"TokenCountError",
|
|
35
|
+
"TurnLimitError",
|
|
36
|
+
"UnknownModelError",
|
|
37
|
+
"__version__",
|
|
38
|
+
"enforce_budget",
|
|
39
|
+
"load_config",
|
|
40
|
+
"protect_crew",
|
|
41
|
+
"tether",
|
|
42
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0-alpha"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from tetherai.exceptions import BudgetExceededError, TurnLimitError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CallRecord:
|
|
10
|
+
input_tokens: int
|
|
11
|
+
output_tokens: int
|
|
12
|
+
model: str
|
|
13
|
+
cost_usd: float
|
|
14
|
+
duration_ms: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BudgetTracker:
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
run_id: str,
|
|
21
|
+
max_usd: float,
|
|
22
|
+
max_turns: int | None = None,
|
|
23
|
+
):
|
|
24
|
+
self.run_id = run_id
|
|
25
|
+
self.max_usd = max_usd
|
|
26
|
+
self.max_turns = max_turns
|
|
27
|
+
self._spent_usd = 0.0
|
|
28
|
+
self._turn_count = 0
|
|
29
|
+
self._calls: list[CallRecord] = []
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def spent_usd(self) -> float:
|
|
34
|
+
with self._lock:
|
|
35
|
+
return self._spent_usd
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def remaining_usd(self) -> float:
|
|
39
|
+
with self._lock:
|
|
40
|
+
return max(0.0, self.max_usd - self._spent_usd)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def turn_count(self) -> int:
|
|
44
|
+
with self._lock:
|
|
45
|
+
return self._turn_count
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def is_exceeded(self) -> bool:
|
|
49
|
+
with self._lock:
|
|
50
|
+
return self._spent_usd >= self.max_usd
|
|
51
|
+
|
|
52
|
+
def pre_check(self, estimated_input_cost: float) -> None:
|
|
53
|
+
with self._lock:
|
|
54
|
+
projected = self._spent_usd + estimated_input_cost
|
|
55
|
+
if projected > self.max_usd:
|
|
56
|
+
raise BudgetExceededError(
|
|
57
|
+
message=f"Budget exceeded: ${projected:.2f} > ${self.max_usd:.2f}",
|
|
58
|
+
run_id=self.run_id,
|
|
59
|
+
budget_usd=self.max_usd,
|
|
60
|
+
spent_usd=projected,
|
|
61
|
+
last_model="unknown",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def record_call(
|
|
65
|
+
self,
|
|
66
|
+
input_tokens: int,
|
|
67
|
+
output_tokens: int,
|
|
68
|
+
model: str,
|
|
69
|
+
cost_usd: float,
|
|
70
|
+
duration_ms: float,
|
|
71
|
+
) -> None:
|
|
72
|
+
if cost_usd < 0:
|
|
73
|
+
raise ValueError("cost_usd must be non-negative")
|
|
74
|
+
|
|
75
|
+
with self._lock:
|
|
76
|
+
if self.max_turns is not None and self._turn_count >= self.max_turns:
|
|
77
|
+
raise TurnLimitError(
|
|
78
|
+
message=f"Turn limit exceeded: {self._turn_count} >= {self.max_turns}",
|
|
79
|
+
run_id=self.run_id,
|
|
80
|
+
max_turns=self.max_turns,
|
|
81
|
+
current_turn=self._turn_count + 1,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
self._spent_usd += cost_usd
|
|
85
|
+
self._turn_count += 1
|
|
86
|
+
|
|
87
|
+
self._calls.append(
|
|
88
|
+
CallRecord(
|
|
89
|
+
input_tokens=input_tokens,
|
|
90
|
+
output_tokens=output_tokens,
|
|
91
|
+
model=model,
|
|
92
|
+
cost_usd=cost_usd,
|
|
93
|
+
duration_ms=duration_ms,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if self._spent_usd > self.max_usd:
|
|
98
|
+
self._spent_usd = self.max_usd
|
|
99
|
+
|
|
100
|
+
def get_summary(self) -> dict[str, Any]:
|
|
101
|
+
with self._lock:
|
|
102
|
+
return {
|
|
103
|
+
"run_id": self.run_id,
|
|
104
|
+
"budget_usd": self.max_usd,
|
|
105
|
+
"spent_usd": self._spent_usd,
|
|
106
|
+
"remaining_usd": max(0.0, self.max_usd - self._spent_usd),
|
|
107
|
+
"turn_count": self._turn_count,
|
|
108
|
+
"calls": [
|
|
109
|
+
{
|
|
110
|
+
"input_tokens": call.input_tokens,
|
|
111
|
+
"output_tokens": call.output_tokens,
|
|
112
|
+
"model": call.model,
|
|
113
|
+
"cost_usd": call.cost_usd,
|
|
114
|
+
"duration_ms": call.duration_ms,
|
|
115
|
+
}
|
|
116
|
+
for call in self._calls
|
|
117
|
+
],
|
|
118
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
import uuid
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any, TypeVar
|
|
6
|
+
|
|
7
|
+
from tetherai.budget import BudgetTracker
|
|
8
|
+
from tetherai.config import TetherConfig
|
|
9
|
+
from tetherai.exceptions import BudgetExceededError
|
|
10
|
+
from tetherai.exporter import get_exporter
|
|
11
|
+
from tetherai.interceptor import LLMInterceptor
|
|
12
|
+
from tetherai.pricing import PricingRegistry
|
|
13
|
+
from tetherai.token_counter import TokenCounter
|
|
14
|
+
from tetherai.trace import TraceCollector
|
|
15
|
+
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def enforce_budget(
|
|
20
|
+
max_usd: float,
|
|
21
|
+
max_turns: int | None = None,
|
|
22
|
+
on_exceed: str = "raise",
|
|
23
|
+
trace_export: str | None = None,
|
|
24
|
+
) -> Callable[[F], F]:
|
|
25
|
+
def decorator(func: F) -> F:
|
|
26
|
+
@functools.wraps(func)
|
|
27
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
28
|
+
return _run_with_budget(
|
|
29
|
+
func, max_usd, max_turns, on_exceed, trace_export, *args, **kwargs
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@functools.wraps(func)
|
|
33
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
34
|
+
return await _run_with_budget_async(
|
|
35
|
+
func, max_usd, max_turns, on_exceed, trace_export, *args, **kwargs
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if asyncio.iscoroutinefunction(func):
|
|
39
|
+
return async_wrapper # type: ignore[return-value]
|
|
40
|
+
return wrapper # type: ignore[return-value]
|
|
41
|
+
|
|
42
|
+
return decorator
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _run_with_budget(
|
|
46
|
+
func: Callable[..., Any],
|
|
47
|
+
max_usd: float,
|
|
48
|
+
max_turns: int | None,
|
|
49
|
+
on_exceed: str,
|
|
50
|
+
trace_export: str | None,
|
|
51
|
+
*args: Any,
|
|
52
|
+
**kwargs: Any,
|
|
53
|
+
) -> Any:
|
|
54
|
+
run_id = f"run-{uuid.uuid4().hex[:8]}"
|
|
55
|
+
config = TetherConfig()
|
|
56
|
+
|
|
57
|
+
if trace_export is None:
|
|
58
|
+
trace_export = config.trace_export
|
|
59
|
+
|
|
60
|
+
budget_tracker = BudgetTracker(run_id=run_id, max_usd=max_usd, max_turns=max_turns)
|
|
61
|
+
token_counter = TokenCounter()
|
|
62
|
+
pricing = PricingRegistry()
|
|
63
|
+
trace_collector = TraceCollector()
|
|
64
|
+
|
|
65
|
+
trace_collector.start_trace(run_id, budget_tracker.get_summary())
|
|
66
|
+
|
|
67
|
+
interceptor = LLMInterceptor(
|
|
68
|
+
budget_tracker=budget_tracker,
|
|
69
|
+
token_counter=token_counter,
|
|
70
|
+
pricing=pricing,
|
|
71
|
+
trace_collector=trace_collector,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
interceptor.activate()
|
|
76
|
+
result = func(*args, **kwargs)
|
|
77
|
+
return result
|
|
78
|
+
except BudgetExceededError:
|
|
79
|
+
if on_exceed == "return_none":
|
|
80
|
+
return None
|
|
81
|
+
raise
|
|
82
|
+
finally:
|
|
83
|
+
interceptor.deactivate()
|
|
84
|
+
trace = trace_collector.end_trace()
|
|
85
|
+
if trace and trace_export != "none":
|
|
86
|
+
exporter = get_exporter(trace_export, config.trace_export_path)
|
|
87
|
+
exporter.export(trace)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _run_with_budget_async(
|
|
91
|
+
func: Callable[..., Any],
|
|
92
|
+
max_usd: float,
|
|
93
|
+
max_turns: int | None,
|
|
94
|
+
on_exceed: str,
|
|
95
|
+
trace_export: str | None,
|
|
96
|
+
*args: Any,
|
|
97
|
+
**kwargs: Any,
|
|
98
|
+
) -> Any:
|
|
99
|
+
run_id = f"run-{uuid.uuid4().hex[:8]}"
|
|
100
|
+
config = TetherConfig()
|
|
101
|
+
|
|
102
|
+
if trace_export is None:
|
|
103
|
+
trace_export = config.trace_export
|
|
104
|
+
|
|
105
|
+
budget_tracker = BudgetTracker(run_id=run_id, max_usd=max_usd, max_turns=max_turns)
|
|
106
|
+
token_counter = TokenCounter()
|
|
107
|
+
pricing = PricingRegistry()
|
|
108
|
+
trace_collector = TraceCollector()
|
|
109
|
+
|
|
110
|
+
trace_collector.start_trace(run_id, budget_tracker.get_summary())
|
|
111
|
+
|
|
112
|
+
interceptor = LLMInterceptor(
|
|
113
|
+
budget_tracker=budget_tracker,
|
|
114
|
+
token_counter=token_counter,
|
|
115
|
+
pricing=pricing,
|
|
116
|
+
trace_collector=trace_collector,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
interceptor.activate()
|
|
121
|
+
result = await func(*args, **kwargs)
|
|
122
|
+
return result
|
|
123
|
+
except BudgetExceededError:
|
|
124
|
+
if on_exceed == "return_none":
|
|
125
|
+
return None
|
|
126
|
+
raise
|
|
127
|
+
finally:
|
|
128
|
+
interceptor.deactivate()
|
|
129
|
+
trace = trace_collector.end_trace()
|
|
130
|
+
if trace and trace_export != "none":
|
|
131
|
+
exporter = get_exporter(trace_export, config.trace_export_path)
|
|
132
|
+
exporter.export(trace)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, Literal, cast
|
|
4
|
+
|
|
5
|
+
TokenCounterBackend = Literal["tiktoken", "litellm", "auto"]
|
|
6
|
+
PricingSource = Literal["bundled", "litellm"]
|
|
7
|
+
TraceExport = Literal["console", "json", "none", "otlp"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class TetherConfig:
|
|
12
|
+
collector_url: str | None = None
|
|
13
|
+
default_budget_usd: float = 10.0
|
|
14
|
+
default_max_turns: int = 50
|
|
15
|
+
token_counter_backend: TokenCounterBackend = "auto"
|
|
16
|
+
pricing_source: PricingSource = "bundled"
|
|
17
|
+
log_level: str = "WARNING"
|
|
18
|
+
trace_export: TraceExport = "console"
|
|
19
|
+
trace_export_path: str = "./tetherai_traces/"
|
|
20
|
+
|
|
21
|
+
def __post_init__(self) -> None:
|
|
22
|
+
if self.default_budget_usd < 0:
|
|
23
|
+
raise ValueError("default_budget_usd must be non-negative")
|
|
24
|
+
|
|
25
|
+
if self.default_max_turns is not None and self.default_max_turns < 0:
|
|
26
|
+
raise ValueError("default_max_turns must be non-negative")
|
|
27
|
+
|
|
28
|
+
valid_backends = ("tiktoken", "litellm", "auto")
|
|
29
|
+
if self.token_counter_backend not in valid_backends:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"Invalid token_counter_backend: {self.token_counter_backend}. "
|
|
32
|
+
f"Must be one of {valid_backends}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
valid_pricing = ("bundled", "litellm")
|
|
36
|
+
if self.pricing_source not in valid_pricing:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Invalid pricing_source: {self.pricing_source}. Must be one of {valid_pricing}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
valid_export = ("console", "json", "none", "otlp")
|
|
42
|
+
if self.trace_export not in valid_export:
|
|
43
|
+
raise ValueError(
|
|
44
|
+
f"Invalid trace_export: {self.trace_export}. Must be one of {valid_export}"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_env(cls) -> "TetherConfig":
|
|
49
|
+
return cls(
|
|
50
|
+
collector_url=os.getenv("TETHERAI_COLLECTOR_URL"),
|
|
51
|
+
default_budget_usd=float(os.getenv("TETHERAI_DEFAULT_BUDGET_USD", "10.0")),
|
|
52
|
+
default_max_turns=int(os.getenv("TETHERAI_DEFAULT_MAX_TURNS", "50")),
|
|
53
|
+
token_counter_backend=cls._resolve_backend(
|
|
54
|
+
os.getenv("TETHERAI_TOKEN_COUNTER_BACKEND", "auto")
|
|
55
|
+
),
|
|
56
|
+
pricing_source=cast(
|
|
57
|
+
PricingSource, os.getenv("TETHERAI_PRICING_SOURCE", "bundled") or "bundled"
|
|
58
|
+
),
|
|
59
|
+
log_level=os.getenv("TETHERAI_LOG_LEVEL", "WARNING"),
|
|
60
|
+
trace_export=cast(
|
|
61
|
+
TraceExport, os.getenv("TETHERAI_TRACE_EXPORT", "console") or "console"
|
|
62
|
+
),
|
|
63
|
+
trace_export_path=os.getenv("TETHERAI_TRACE_EXPORT_PATH", "./tetherai_traces/"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _resolve_backend(backend: str) -> TokenCounterBackend:
|
|
68
|
+
if backend == "auto":
|
|
69
|
+
try:
|
|
70
|
+
import litellm # noqa: F401
|
|
71
|
+
|
|
72
|
+
return "litellm"
|
|
73
|
+
except ImportError:
|
|
74
|
+
return "tiktoken"
|
|
75
|
+
return backend # type: ignore[return-value]
|
|
76
|
+
|
|
77
|
+
def resolve_backend(self) -> TokenCounterBackend:
|
|
78
|
+
return self._resolve_backend(self.token_counter_backend)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def load_config(**kwargs: Any) -> TetherConfig:
|
|
82
|
+
env_config = TetherConfig.from_env()
|
|
83
|
+
|
|
84
|
+
config_dict = {}
|
|
85
|
+
for field_name in TetherConfig.__dataclass_fields__:
|
|
86
|
+
if field_name in kwargs:
|
|
87
|
+
config_dict[field_name] = kwargs[field_name]
|
|
88
|
+
else:
|
|
89
|
+
config_dict[field_name] = getattr(env_config, field_name)
|
|
90
|
+
|
|
91
|
+
return TetherConfig(**config_dict)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import TYPE_CHECKING, Any
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from crewai import Crew
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _check_crewai_installed() -> None:
|
|
9
|
+
try:
|
|
10
|
+
import crewai # noqa: F401
|
|
11
|
+
except ImportError:
|
|
12
|
+
raise ImportError(
|
|
13
|
+
"crewai is not installed. Install it with: pip install tetherai[crewai]"
|
|
14
|
+
) from None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def protect_crew(
|
|
18
|
+
crew: "Crew",
|
|
19
|
+
max_usd: float,
|
|
20
|
+
max_turns: int | None = None,
|
|
21
|
+
) -> "Crew":
|
|
22
|
+
_check_crewai_installed()
|
|
23
|
+
|
|
24
|
+
from tetherai.circuit_breaker import enforce_budget
|
|
25
|
+
|
|
26
|
+
original_kickoff = crew.kickoff
|
|
27
|
+
|
|
28
|
+
@enforce_budget(max_usd=max_usd, max_turns=max_turns)
|
|
29
|
+
def wrapped_kickoff(*args: Any, **kwargs: Any) -> Any:
|
|
30
|
+
return original_kickoff(*args, **kwargs)
|
|
31
|
+
|
|
32
|
+
crew.kickoff = wrapped_kickoff # type: ignore[method-assign]
|
|
33
|
+
|
|
34
|
+
for agent in crew.agents:
|
|
35
|
+
original_step_callback = getattr(agent, "step_callback", None)
|
|
36
|
+
|
|
37
|
+
def make_callback(original: Callable[..., Any] | None) -> Callable[..., Any]:
|
|
38
|
+
def callback(step_output: Any) -> None:
|
|
39
|
+
if original:
|
|
40
|
+
original(step_output)
|
|
41
|
+
|
|
42
|
+
return callback
|
|
43
|
+
|
|
44
|
+
if original_step_callback is not None:
|
|
45
|
+
agent.step_callback = make_callback(original_step_callback) # type: ignore[attr-defined]
|
|
46
|
+
|
|
47
|
+
for task in crew.tasks:
|
|
48
|
+
original_task_callback = getattr(task, "callback", None)
|
|
49
|
+
|
|
50
|
+
def make_task_callback(original: Callable[..., Any] | None) -> Callable[..., Any]:
|
|
51
|
+
def callback(task_output: Any) -> None:
|
|
52
|
+
if original:
|
|
53
|
+
original(task_output)
|
|
54
|
+
|
|
55
|
+
return callback
|
|
56
|
+
|
|
57
|
+
if original_task_callback is not None:
|
|
58
|
+
task.callback = make_task_callback(original_task_callback)
|
|
59
|
+
|
|
60
|
+
return crew
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def tether_step_callback(step_output: Any) -> None:
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def tether_task_callback(task_output: Any) -> None:
|
|
68
|
+
pass
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
class TetherError(Exception):
|
|
2
|
+
"""Base exception for all TetherAI errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class BudgetExceededError(TetherError):
|
|
6
|
+
"""Raised when a run's accumulated cost exceeds its budget."""
|
|
7
|
+
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
message: str,
|
|
11
|
+
run_id: str,
|
|
12
|
+
budget_usd: float,
|
|
13
|
+
spent_usd: float,
|
|
14
|
+
last_model: str,
|
|
15
|
+
trace_url: str | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.run_id = run_id
|
|
19
|
+
self.budget_usd = budget_usd
|
|
20
|
+
self.spent_usd = spent_usd
|
|
21
|
+
self.last_model = last_model
|
|
22
|
+
self.trace_url = trace_url
|
|
23
|
+
|
|
24
|
+
def __str__(self) -> str:
|
|
25
|
+
return (
|
|
26
|
+
f"Budget exceeded: ${self.spent_usd:.2f} / ${self.budget_usd:.2f} on run {self.run_id}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def __reduce__(self) -> tuple[type, tuple]: # type: ignore[type-arg]
|
|
30
|
+
return (
|
|
31
|
+
self.__class__,
|
|
32
|
+
(
|
|
33
|
+
self.args[0],
|
|
34
|
+
self.run_id,
|
|
35
|
+
self.budget_usd,
|
|
36
|
+
self.spent_usd,
|
|
37
|
+
self.last_model,
|
|
38
|
+
self.trace_url,
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TurnLimitError(TetherError):
|
|
44
|
+
"""Raised when an agent exceeds max allowed LLM calls."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
message: str,
|
|
49
|
+
run_id: str,
|
|
50
|
+
max_turns: int,
|
|
51
|
+
current_turn: int,
|
|
52
|
+
) -> None:
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.run_id = run_id
|
|
55
|
+
self.max_turns = max_turns
|
|
56
|
+
self.current_turn = current_turn
|
|
57
|
+
|
|
58
|
+
def __str__(self) -> str:
|
|
59
|
+
return f"Turn limit exceeded: {self.current_turn} / {self.max_turns} on run {self.run_id}"
|
|
60
|
+
|
|
61
|
+
def __reduce__(self) -> tuple[type, tuple]: # type: ignore[type-arg]
|
|
62
|
+
return (
|
|
63
|
+
self.__class__,
|
|
64
|
+
(
|
|
65
|
+
self.args[0],
|
|
66
|
+
self.run_id,
|
|
67
|
+
self.max_turns,
|
|
68
|
+
self.current_turn,
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TokenCountError(TetherError):
|
|
74
|
+
"""Raised when token counting fails (e.g., unknown encoding)."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, message: str, model: str | None = None) -> None:
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.model = model
|
|
79
|
+
|
|
80
|
+
def __reduce__(self) -> tuple[type, tuple]: # type: ignore[type-arg]
|
|
81
|
+
return (self.__class__, (self.args[0], self.model))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UnknownModelError(TetherError):
|
|
85
|
+
"""Raised when a model is not found in the pricing registry."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, message: str, model: str) -> None:
|
|
88
|
+
super().__init__(message)
|
|
89
|
+
self.model = model
|
|
90
|
+
|
|
91
|
+
def __reduce__(self) -> tuple[type, tuple]: # type: ignore[type-arg]
|
|
92
|
+
return (self.__class__, (self.args[0], self.model))
|