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 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()
@@ -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)