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.
@@ -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
+ [![CI](https://github.com/tetherai/tetherai-python/actions/workflows/ci.yml/badge.svg)](https://github.com/tetherai/tetherai-python/actions/workflows/ci.yml)
35
+ [![PyPI](https://img.shields.io/pypi/v/tetherai)](https://pypi.org/project/tetherai/)
@@ -0,0 +1,2 @@
1
+ [![CI](https://github.com/tetherai/tetherai-python/actions/workflows/ci.yml/badge.svg)](https://github.com/tetherai/tetherai-python/actions/workflows/ci.yml)
2
+ [![PyPI](https://img.shields.io/pypi/v/tetherai)](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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ from tetherai.crewai.integration import protect_crew, tether_step_callback, tether_task_callback
2
+
3
+ __all__ = ["protect_crew", "tether_step_callback", "tether_task_callback"]
@@ -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))