steerplane 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.
- steerplane/__init__.py +69 -0
- steerplane/client.py +129 -0
- steerplane/config.py +49 -0
- steerplane/cost_tracker.py +171 -0
- steerplane/exceptions.py +68 -0
- steerplane/guard.py +179 -0
- steerplane/loop_detector.py +124 -0
- steerplane/run_manager.py +266 -0
- steerplane/telemetry.py +113 -0
- steerplane/utils.py +57 -0
- steerplane-0.1.0.dist-info/METADATA +63 -0
- steerplane-0.1.0.dist-info/RECORD +18 -0
- steerplane-0.1.0.dist-info/WHEEL +5 -0
- steerplane-0.1.0.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_cost_tracker.py +65 -0
- tests/test_guard.py +60 -0
- tests/test_loop_detector.py +100 -0
steerplane/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SteerPlane SDK
|
|
3
|
+
|
|
4
|
+
Agent Control Plane for Autonomous Systems.
|
|
5
|
+
"Agents don't fail in the dark anymore."
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from steerplane import guard, SteerPlane
|
|
9
|
+
|
|
10
|
+
# Decorator API (minimal integration)
|
|
11
|
+
@guard(max_cost_usd=10, max_steps=50)
|
|
12
|
+
def run_agent():
|
|
13
|
+
agent.run()
|
|
14
|
+
|
|
15
|
+
# Context Manager API (full control)
|
|
16
|
+
sp = SteerPlane(agent_id="my_bot")
|
|
17
|
+
with sp.run(max_cost_usd=10) as run:
|
|
18
|
+
run.log_step("search", tokens=100)
|
|
19
|
+
|
|
20
|
+
# Programmatic API
|
|
21
|
+
sp = SteerPlane(agent_id="my_bot")
|
|
22
|
+
run = sp.create_run(max_cost_usd=10)
|
|
23
|
+
run.start()
|
|
24
|
+
run.log_step("search", tokens=100)
|
|
25
|
+
run.end()
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
from .guard import guard, SteerPlane, get_active_run
|
|
31
|
+
from .run_manager import RunManager
|
|
32
|
+
from .loop_detector import LoopDetector, detect_loop
|
|
33
|
+
from .cost_tracker import CostTracker
|
|
34
|
+
from .telemetry import TelemetryCollector, StepEvent
|
|
35
|
+
from .config import configure, get_config
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
SteerPlaneError,
|
|
38
|
+
LoopDetectedError,
|
|
39
|
+
CostLimitExceeded,
|
|
40
|
+
StepLimitExceeded,
|
|
41
|
+
RunTerminatedError,
|
|
42
|
+
APIConnectionError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Main APIs
|
|
47
|
+
"guard",
|
|
48
|
+
"SteerPlane",
|
|
49
|
+
"get_active_run",
|
|
50
|
+
# Core classes
|
|
51
|
+
"RunManager",
|
|
52
|
+
"LoopDetector",
|
|
53
|
+
"CostTracker",
|
|
54
|
+
"TelemetryCollector",
|
|
55
|
+
"StepEvent",
|
|
56
|
+
# Utilities
|
|
57
|
+
"detect_loop",
|
|
58
|
+
"configure",
|
|
59
|
+
"get_config",
|
|
60
|
+
# Exceptions
|
|
61
|
+
"SteerPlaneError",
|
|
62
|
+
"LoopDetectedError",
|
|
63
|
+
"CostLimitExceeded",
|
|
64
|
+
"StepLimitExceeded",
|
|
65
|
+
"RunTerminatedError",
|
|
66
|
+
"APIConnectionError",
|
|
67
|
+
# Metadata
|
|
68
|
+
"__version__",
|
|
69
|
+
]
|
steerplane/client.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SteerPlane SDK — API Client
|
|
3
|
+
|
|
4
|
+
HTTP client that communicates with the SteerPlane API server.
|
|
5
|
+
Handles run lifecycle: start → log steps → end.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .config import get_config
|
|
13
|
+
from .exceptions import APIConnectionError
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("steerplane")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SteerPlaneClient:
|
|
19
|
+
"""
|
|
20
|
+
HTTP client for the SteerPlane API.
|
|
21
|
+
|
|
22
|
+
Sends run events, step telemetry, and receives commands.
|
|
23
|
+
Gracefully degrades if API is unavailable (SDK still works locally).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, api_url: str | None = None, api_key: str | None = None):
|
|
27
|
+
config = get_config()
|
|
28
|
+
self.api_url = (api_url or config.api_url).rstrip("/")
|
|
29
|
+
self.api_key = api_key or config.api_key
|
|
30
|
+
self.session = requests.Session()
|
|
31
|
+
self.session.headers.update({
|
|
32
|
+
"Content-Type": "application/json",
|
|
33
|
+
"User-Agent": "SteerPlane-SDK/0.1.0",
|
|
34
|
+
})
|
|
35
|
+
if self.api_key:
|
|
36
|
+
self.session.headers["Authorization"] = f"Bearer {self.api_key}"
|
|
37
|
+
|
|
38
|
+
self._api_available = True
|
|
39
|
+
|
|
40
|
+
def _request(self, method: str, path: str, **kwargs) -> dict | None:
|
|
41
|
+
"""Make an HTTP request to the API. Returns None if API unavailable."""
|
|
42
|
+
if not self._api_available:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
url = f"{self.api_url}{path}"
|
|
46
|
+
try:
|
|
47
|
+
response = self.session.request(method, url, timeout=5, **kwargs)
|
|
48
|
+
response.raise_for_status()
|
|
49
|
+
return response.json()
|
|
50
|
+
except requests.ConnectionError:
|
|
51
|
+
self._api_available = False
|
|
52
|
+
logger.warning(
|
|
53
|
+
f"⚠️ SteerPlane API not reachable at {self.api_url}. "
|
|
54
|
+
f"Running in offline mode (guards still active, no dashboard data)."
|
|
55
|
+
)
|
|
56
|
+
return None
|
|
57
|
+
except requests.RequestException as e:
|
|
58
|
+
logger.warning(f"⚠️ API request failed: {e}")
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def start_run(
|
|
62
|
+
self,
|
|
63
|
+
run_id: str,
|
|
64
|
+
agent_name: str,
|
|
65
|
+
max_cost_usd: float = 0,
|
|
66
|
+
max_steps: int = 0,
|
|
67
|
+
) -> dict | None:
|
|
68
|
+
"""Register a new run with the API."""
|
|
69
|
+
return self._request("POST", "/runs/start", json={
|
|
70
|
+
"run_id": run_id,
|
|
71
|
+
"agent_name": agent_name,
|
|
72
|
+
"max_cost_usd": max_cost_usd,
|
|
73
|
+
"max_steps": max_steps,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
def log_step(
|
|
77
|
+
self,
|
|
78
|
+
run_id: str,
|
|
79
|
+
step_number: int,
|
|
80
|
+
action: str,
|
|
81
|
+
tokens: int = 0,
|
|
82
|
+
cost_usd: float = 0.0,
|
|
83
|
+
latency_ms: float = 0.0,
|
|
84
|
+
status: str = "completed",
|
|
85
|
+
error: str | None = None,
|
|
86
|
+
metadata: dict | None = None,
|
|
87
|
+
) -> dict | None:
|
|
88
|
+
"""Log a step event to the API."""
|
|
89
|
+
return self._request("POST", "/runs/step", json={
|
|
90
|
+
"run_id": run_id,
|
|
91
|
+
"step_number": step_number,
|
|
92
|
+
"action": action,
|
|
93
|
+
"tokens": tokens,
|
|
94
|
+
"cost_usd": cost_usd,
|
|
95
|
+
"latency_ms": latency_ms,
|
|
96
|
+
"status": status,
|
|
97
|
+
"error": error,
|
|
98
|
+
"metadata": metadata or {},
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
def end_run(
|
|
102
|
+
self,
|
|
103
|
+
run_id: str,
|
|
104
|
+
status: str = "completed",
|
|
105
|
+
total_cost: float = 0.0,
|
|
106
|
+
total_steps: int = 0,
|
|
107
|
+
error: str | None = None,
|
|
108
|
+
) -> dict | None:
|
|
109
|
+
"""Finalize a run."""
|
|
110
|
+
return self._request("POST", "/runs/end", json={
|
|
111
|
+
"run_id": run_id,
|
|
112
|
+
"status": status,
|
|
113
|
+
"total_cost": total_cost,
|
|
114
|
+
"total_steps": total_steps,
|
|
115
|
+
"error": error,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
def get_run(self, run_id: str) -> dict | None:
|
|
119
|
+
"""Fetch run details."""
|
|
120
|
+
return self._request("GET", f"/runs/{run_id}")
|
|
121
|
+
|
|
122
|
+
def list_runs(self, limit: int = 50, offset: int = 0) -> dict | None:
|
|
123
|
+
"""List recent runs."""
|
|
124
|
+
return self._request("GET", f"/runs?limit={limit}&offset={offset}")
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def is_connected(self) -> bool:
|
|
128
|
+
"""Check if API is reachable."""
|
|
129
|
+
return self._api_available
|
steerplane/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SteerPlane SDK — Configuration
|
|
3
|
+
|
|
4
|
+
Default settings and environment variable overrides.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SteerPlaneConfig:
|
|
11
|
+
"""SDK configuration with environment variable overrides."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
api_url: str | None = None,
|
|
16
|
+
api_key: str | None = None,
|
|
17
|
+
agent_id: str | None = None,
|
|
18
|
+
default_max_cost_usd: float = 50.0,
|
|
19
|
+
default_max_steps: int = 200,
|
|
20
|
+
default_max_runtime_sec: int = 3600,
|
|
21
|
+
loop_window_size: int = 8,
|
|
22
|
+
enable_telemetry: bool = True,
|
|
23
|
+
log_to_console: bool = True,
|
|
24
|
+
):
|
|
25
|
+
self.api_url = api_url or os.getenv("STEERPLANE_API_URL", "http://localhost:8000")
|
|
26
|
+
self.api_key = api_key or os.getenv("STEERPLANE_API_KEY", "")
|
|
27
|
+
self.agent_id = agent_id or os.getenv("STEERPLANE_AGENT_ID", "default_agent")
|
|
28
|
+
self.default_max_cost_usd = default_max_cost_usd
|
|
29
|
+
self.default_max_steps = default_max_steps
|
|
30
|
+
self.default_max_runtime_sec = default_max_runtime_sec
|
|
31
|
+
self.loop_window_size = loop_window_size
|
|
32
|
+
self.enable_telemetry = enable_telemetry
|
|
33
|
+
self.log_to_console = log_to_console
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Global default config (can be overridden)
|
|
37
|
+
_config = SteerPlaneConfig()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_config() -> SteerPlaneConfig:
|
|
41
|
+
"""Get the current global configuration."""
|
|
42
|
+
return _config
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def configure(**kwargs) -> SteerPlaneConfig:
|
|
46
|
+
"""Update global SDK configuration."""
|
|
47
|
+
global _config
|
|
48
|
+
_config = SteerPlaneConfig(**kwargs)
|
|
49
|
+
return _config
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SteerPlane SDK — Cost Tracker
|
|
3
|
+
|
|
4
|
+
Tracks cumulative token usage and cost per run.
|
|
5
|
+
Enforces cost limits and projects final cost.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from .exceptions import CostLimitExceeded
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Default pricing per token (can be overridden per model)
|
|
13
|
+
DEFAULT_PRICING = {
|
|
14
|
+
"gpt-4o": {"input": 0.0000025, "output": 0.000010},
|
|
15
|
+
"gpt-4o-mini": {"input": 0.00000015, "output": 0.0000006},
|
|
16
|
+
"gpt-4": {"input": 0.00003, "output": 0.00006},
|
|
17
|
+
"gpt-3.5-turbo": {"input": 0.0000005, "output": 0.0000015},
|
|
18
|
+
"claude-3-opus": {"input": 0.000015, "output": 0.000075},
|
|
19
|
+
"claude-3-sonnet": {"input": 0.000003, "output": 0.000015},
|
|
20
|
+
"claude-3-haiku": {"input": 0.00000025, "output": 0.00000125},
|
|
21
|
+
"gemini-pro": {"input": 0.00000025, "output": 0.0000005},
|
|
22
|
+
"default": {"input": 0.000002, "output": 0.000002},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class StepCost:
|
|
28
|
+
"""Cost breakdown for a single step."""
|
|
29
|
+
input_tokens: int = 0
|
|
30
|
+
output_tokens: int = 0
|
|
31
|
+
total_tokens: int = 0
|
|
32
|
+
cost_usd: float = 0.0
|
|
33
|
+
model: str = "default"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CostSummary:
|
|
38
|
+
"""Cumulative cost summary for an entire run."""
|
|
39
|
+
total_input_tokens: int = 0
|
|
40
|
+
total_output_tokens: int = 0
|
|
41
|
+
total_tokens: int = 0
|
|
42
|
+
total_cost_usd: float = 0.0
|
|
43
|
+
step_costs: list[StepCost] = field(default_factory=list)
|
|
44
|
+
projected_final_cost: float = 0.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class CostTracker:
|
|
48
|
+
"""
|
|
49
|
+
Tracks cumulative cost across an agent run.
|
|
50
|
+
|
|
51
|
+
Enforces cost limits and provides real-time cost projection.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, max_cost_usd: float = 50.0, model: str = "default"):
|
|
55
|
+
"""
|
|
56
|
+
Args:
|
|
57
|
+
max_cost_usd: Maximum allowed cost before termination.
|
|
58
|
+
model: Default model name for pricing lookup.
|
|
59
|
+
"""
|
|
60
|
+
self.max_cost_usd = max_cost_usd
|
|
61
|
+
self.model = model
|
|
62
|
+
self.total_cost: float = 0.0
|
|
63
|
+
self.total_tokens: int = 0
|
|
64
|
+
self.total_input_tokens: int = 0
|
|
65
|
+
self.total_output_tokens: int = 0
|
|
66
|
+
self.step_costs: list[StepCost] = []
|
|
67
|
+
|
|
68
|
+
def calculate_step_cost(
|
|
69
|
+
self,
|
|
70
|
+
input_tokens: int = 0,
|
|
71
|
+
output_tokens: int = 0,
|
|
72
|
+
total_tokens: int = 0,
|
|
73
|
+
model: str | None = None,
|
|
74
|
+
cost_override: float | None = None,
|
|
75
|
+
) -> StepCost:
|
|
76
|
+
"""
|
|
77
|
+
Calculate cost for a single step.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
input_tokens: Number of input/prompt tokens.
|
|
81
|
+
output_tokens: Number of output/completion tokens.
|
|
82
|
+
total_tokens: Total tokens (used if input/output not split).
|
|
83
|
+
model: Model name for pricing (overrides default).
|
|
84
|
+
cost_override: Directly specify cost (skips calculation).
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
StepCost with the calculated cost.
|
|
88
|
+
"""
|
|
89
|
+
use_model = model or self.model
|
|
90
|
+
pricing = DEFAULT_PRICING.get(use_model, DEFAULT_PRICING["default"])
|
|
91
|
+
|
|
92
|
+
if cost_override is not None:
|
|
93
|
+
cost = cost_override
|
|
94
|
+
elif input_tokens > 0 or output_tokens > 0:
|
|
95
|
+
cost = (input_tokens * pricing["input"]) + (output_tokens * pricing["output"])
|
|
96
|
+
total_tokens = input_tokens + output_tokens
|
|
97
|
+
elif total_tokens > 0:
|
|
98
|
+
# If no split, assume 50/50 input/output
|
|
99
|
+
avg_price = (pricing["input"] + pricing["output"]) / 2
|
|
100
|
+
cost = total_tokens * avg_price
|
|
101
|
+
else:
|
|
102
|
+
cost = 0.0
|
|
103
|
+
|
|
104
|
+
step_cost = StepCost(
|
|
105
|
+
input_tokens=input_tokens,
|
|
106
|
+
output_tokens=output_tokens,
|
|
107
|
+
total_tokens=total_tokens,
|
|
108
|
+
cost_usd=cost,
|
|
109
|
+
model=use_model,
|
|
110
|
+
)
|
|
111
|
+
return step_cost
|
|
112
|
+
|
|
113
|
+
def add_step(self, step_cost: StepCost) -> float:
|
|
114
|
+
"""
|
|
115
|
+
Add a step's cost to the running total and enforce limits.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
step_cost: The StepCost to add.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
The new cumulative cost.
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
CostLimitExceeded: If cost exceeds the configured limit.
|
|
125
|
+
"""
|
|
126
|
+
self.total_cost += step_cost.cost_usd
|
|
127
|
+
self.total_tokens += step_cost.total_tokens
|
|
128
|
+
self.total_input_tokens += step_cost.input_tokens
|
|
129
|
+
self.total_output_tokens += step_cost.output_tokens
|
|
130
|
+
self.step_costs.append(step_cost)
|
|
131
|
+
|
|
132
|
+
if self.total_cost > self.max_cost_usd:
|
|
133
|
+
raise CostLimitExceeded(self.total_cost, self.max_cost_usd)
|
|
134
|
+
|
|
135
|
+
return self.total_cost
|
|
136
|
+
|
|
137
|
+
def project_final_cost(
|
|
138
|
+
self, steps_completed: int, expected_total_steps: int
|
|
139
|
+
) -> float:
|
|
140
|
+
"""
|
|
141
|
+
Project the final cost based on current spending rate.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
steps_completed: Number of steps completed so far.
|
|
145
|
+
expected_total_steps: Expected total number of steps.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Projected final cost in USD.
|
|
149
|
+
"""
|
|
150
|
+
if steps_completed <= 0:
|
|
151
|
+
return 0.0
|
|
152
|
+
projected = self.total_cost * (expected_total_steps / steps_completed)
|
|
153
|
+
return projected
|
|
154
|
+
|
|
155
|
+
def get_summary(self) -> CostSummary:
|
|
156
|
+
"""Get the current cost summary."""
|
|
157
|
+
return CostSummary(
|
|
158
|
+
total_input_tokens=self.total_input_tokens,
|
|
159
|
+
total_output_tokens=self.total_output_tokens,
|
|
160
|
+
total_tokens=self.total_tokens,
|
|
161
|
+
total_cost_usd=self.total_cost,
|
|
162
|
+
step_costs=list(self.step_costs),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def reset(self):
|
|
166
|
+
"""Reset all tracking."""
|
|
167
|
+
self.total_cost = 0.0
|
|
168
|
+
self.total_tokens = 0
|
|
169
|
+
self.total_input_tokens = 0
|
|
170
|
+
self.total_output_tokens = 0
|
|
171
|
+
self.step_costs.clear()
|
steerplane/exceptions.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SteerPlane SDK — Custom Exceptions
|
|
3
|
+
|
|
4
|
+
All errors raised by the SteerPlane guard system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SteerPlaneError(Exception):
|
|
9
|
+
"""Base exception for all SteerPlane errors."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoopDetectedError(SteerPlaneError):
|
|
14
|
+
"""Raised when a repeating action pattern is detected."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, pattern: list, window_size: int):
|
|
17
|
+
self.pattern = pattern
|
|
18
|
+
self.window_size = window_size
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"🔄 Loop detected! Repeating pattern {pattern} "
|
|
21
|
+
f"found in last {window_size} actions. Run terminated."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CostLimitExceeded(SteerPlaneError):
|
|
26
|
+
"""Raised when cumulative run cost exceeds the configured limit."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, current_cost: float, max_cost: float):
|
|
29
|
+
self.current_cost = current_cost
|
|
30
|
+
self.max_cost = max_cost
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"💰 Cost limit exceeded! Current: ${current_cost:.4f}, "
|
|
33
|
+
f"Limit: ${max_cost:.2f}. Run terminated."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class StepLimitExceeded(SteerPlaneError):
|
|
38
|
+
"""Raised when the number of steps exceeds the configured limit."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, current_steps: int, max_steps: int):
|
|
41
|
+
self.current_steps = current_steps
|
|
42
|
+
self.max_steps = max_steps
|
|
43
|
+
super().__init__(
|
|
44
|
+
f"🚫 Step limit exceeded! Steps: {current_steps}, "
|
|
45
|
+
f"Limit: {max_steps}. Run terminated."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RunTerminatedError(SteerPlaneError):
|
|
50
|
+
"""Raised when a run is forcefully terminated."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, run_id: str, reason: str = "Manual termination"):
|
|
53
|
+
self.run_id = run_id
|
|
54
|
+
self.reason = reason
|
|
55
|
+
super().__init__(
|
|
56
|
+
f"⛔ Run {run_id} terminated. Reason: {reason}"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class APIConnectionError(SteerPlaneError):
|
|
61
|
+
"""Raised when the SDK cannot connect to the SteerPlane API."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, url: str, detail: str = ""):
|
|
64
|
+
self.url = url
|
|
65
|
+
self.detail = detail
|
|
66
|
+
super().__init__(
|
|
67
|
+
f"🔌 Cannot connect to SteerPlane API at {url}. {detail}"
|
|
68
|
+
)
|
steerplane/guard.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SteerPlane SDK — Guard Decorator
|
|
3
|
+
|
|
4
|
+
The primary developer-facing API. Wrap any agent function with @guard
|
|
5
|
+
to get automatic loop detection, cost limits, step limits, and telemetry.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from steerplane import guard
|
|
9
|
+
|
|
10
|
+
@guard(max_cost_usd=10, max_steps=50)
|
|
11
|
+
def run_agent():
|
|
12
|
+
agent.run()
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import functools
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Callable, Any
|
|
18
|
+
|
|
19
|
+
from .run_manager import RunManager
|
|
20
|
+
from .config import get_config
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger("steerplane")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Thread-local storage for the active run manager
|
|
26
|
+
_active_run: RunManager | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_active_run() -> RunManager | None:
|
|
30
|
+
"""Get the currently active RunManager (if inside a guarded function)."""
|
|
31
|
+
return _active_run
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def guard(
|
|
35
|
+
max_cost_usd: float = 50.0,
|
|
36
|
+
max_steps: int = 200,
|
|
37
|
+
max_runtime_sec: int = 3600,
|
|
38
|
+
agent_name: str | None = None,
|
|
39
|
+
model: str = "default",
|
|
40
|
+
loop_window_size: int = 8,
|
|
41
|
+
log_to_console: bool = True,
|
|
42
|
+
api_url: str | None = None,
|
|
43
|
+
api_key: str | None = None,
|
|
44
|
+
) -> Callable:
|
|
45
|
+
"""
|
|
46
|
+
Guard decorator for agent functions.
|
|
47
|
+
|
|
48
|
+
Wraps an agent function with SteerPlane's guard system:
|
|
49
|
+
- Loop detection (sliding window)
|
|
50
|
+
- Cost limit enforcement
|
|
51
|
+
- Step limit enforcement
|
|
52
|
+
- Runtime limit enforcement
|
|
53
|
+
- Full telemetry logging
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
max_cost_usd: Maximum allowed cost in USD.
|
|
57
|
+
max_steps: Maximum number of steps allowed.
|
|
58
|
+
max_runtime_sec: Maximum runtime in seconds.
|
|
59
|
+
agent_name: Name of the agent (defaults to function name).
|
|
60
|
+
model: Default model for cost calculation.
|
|
61
|
+
loop_window_size: Window size for loop detection.
|
|
62
|
+
log_to_console: Whether to print step logs to console.
|
|
63
|
+
api_url: SteerPlane API URL (overrides config).
|
|
64
|
+
api_key: API key (overrides config).
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Decorated function with guard capabilities.
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
@guard(max_cost_usd=10, max_steps=50)
|
|
71
|
+
def run_my_agent():
|
|
72
|
+
# The agent's run manager is available via steerplane.get_active_run()
|
|
73
|
+
agent.run()
|
|
74
|
+
"""
|
|
75
|
+
def decorator(func: Callable) -> Callable:
|
|
76
|
+
@functools.wraps(func)
|
|
77
|
+
def wrapper(*args, **kwargs) -> Any:
|
|
78
|
+
global _active_run
|
|
79
|
+
|
|
80
|
+
name = agent_name or func.__name__
|
|
81
|
+
|
|
82
|
+
# Create run manager with guard configuration
|
|
83
|
+
run = RunManager(
|
|
84
|
+
agent_name=name,
|
|
85
|
+
max_cost_usd=max_cost_usd,
|
|
86
|
+
max_steps=max_steps,
|
|
87
|
+
max_runtime_sec=max_runtime_sec,
|
|
88
|
+
loop_window_size=loop_window_size,
|
|
89
|
+
model=model,
|
|
90
|
+
api_url=api_url,
|
|
91
|
+
api_key=api_key,
|
|
92
|
+
log_to_console=log_to_console,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Set as active run
|
|
96
|
+
_active_run = run
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
run.start()
|
|
100
|
+
result = func(*args, **kwargs)
|
|
101
|
+
run.end(status="completed")
|
|
102
|
+
return result
|
|
103
|
+
except Exception as e:
|
|
104
|
+
run.end(status="failed", error=str(e))
|
|
105
|
+
raise
|
|
106
|
+
finally:
|
|
107
|
+
_active_run = None
|
|
108
|
+
|
|
109
|
+
# Attach run manager accessor to the wrapper
|
|
110
|
+
wrapper._steerplane_guarded = True
|
|
111
|
+
return wrapper
|
|
112
|
+
|
|
113
|
+
return decorator
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SteerPlane:
|
|
117
|
+
"""
|
|
118
|
+
Main SDK entry point for programmatic usage.
|
|
119
|
+
|
|
120
|
+
Provides both context manager and explicit API for run management.
|
|
121
|
+
|
|
122
|
+
Usage (context manager):
|
|
123
|
+
sp = SteerPlane(agent_id="my_bot")
|
|
124
|
+
with sp.run() as run:
|
|
125
|
+
run.log_step("query_db", tokens=380, cost=0.002)
|
|
126
|
+
run.log_step("generate_response", tokens=1240, cost=0.008)
|
|
127
|
+
|
|
128
|
+
Usage (explicit):
|
|
129
|
+
sp = SteerPlane(agent_id="my_bot")
|
|
130
|
+
run = sp.create_run(max_cost_usd=10)
|
|
131
|
+
run.start()
|
|
132
|
+
run.log_step("search", tokens=100)
|
|
133
|
+
run.end()
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(
|
|
137
|
+
self,
|
|
138
|
+
agent_id: str = "default_agent",
|
|
139
|
+
api_url: str | None = None,
|
|
140
|
+
api_key: str | None = None,
|
|
141
|
+
model: str = "default",
|
|
142
|
+
):
|
|
143
|
+
self.agent_id = agent_id
|
|
144
|
+
self.api_url = api_url
|
|
145
|
+
self.api_key = api_key
|
|
146
|
+
self.model = model
|
|
147
|
+
|
|
148
|
+
def run(
|
|
149
|
+
self,
|
|
150
|
+
run_id: str | None = None,
|
|
151
|
+
max_cost_usd: float = 50.0,
|
|
152
|
+
max_steps: int = 200,
|
|
153
|
+
max_runtime_sec: int = 3600,
|
|
154
|
+
loop_window_size: int = 8,
|
|
155
|
+
log_to_console: bool = True,
|
|
156
|
+
) -> RunManager:
|
|
157
|
+
"""
|
|
158
|
+
Create a new run context manager.
|
|
159
|
+
|
|
160
|
+
Usage:
|
|
161
|
+
with sp.run(max_cost_usd=10) as run:
|
|
162
|
+
run.log_step("action", tokens=100)
|
|
163
|
+
"""
|
|
164
|
+
return RunManager(
|
|
165
|
+
agent_name=self.agent_id,
|
|
166
|
+
run_id=run_id,
|
|
167
|
+
max_cost_usd=max_cost_usd,
|
|
168
|
+
max_steps=max_steps,
|
|
169
|
+
max_runtime_sec=max_runtime_sec,
|
|
170
|
+
loop_window_size=loop_window_size,
|
|
171
|
+
model=self.model,
|
|
172
|
+
api_url=self.api_url,
|
|
173
|
+
api_key=self.api_key,
|
|
174
|
+
log_to_console=log_to_console,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
def create_run(self, **kwargs) -> RunManager:
|
|
178
|
+
"""Create a run without starting it (for explicit management)."""
|
|
179
|
+
return self.run(**kwargs)
|