straleio 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.
straleio/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """Strale Python SDK — let AI agents buy capabilities at runtime."""
2
+
3
+ from .client import Strale
4
+ from .errors import (
5
+ CapabilityUnavailableError,
6
+ ExecutionFailedError,
7
+ InsufficientBalanceError,
8
+ InvalidRequestError,
9
+ NoMatchingCapabilityError,
10
+ NotFoundError,
11
+ RateLimitedError,
12
+ StraleError,
13
+ TimeoutExceededError,
14
+ UnauthorizedError,
15
+ )
16
+ from .types import (
17
+ BalanceResponse,
18
+ Capability,
19
+ DoRequest,
20
+ DoResponse,
21
+ DryRunResponse,
22
+ Provenance,
23
+ Transaction,
24
+ TransactionDetail,
25
+ )
26
+
27
+ __all__ = [
28
+ # Client
29
+ "Strale",
30
+ # Types
31
+ "DoRequest",
32
+ "DoResponse",
33
+ "DryRunResponse",
34
+ "Provenance",
35
+ "Capability",
36
+ "BalanceResponse",
37
+ "Transaction",
38
+ "TransactionDetail",
39
+ # Errors
40
+ "StraleError",
41
+ "InsufficientBalanceError",
42
+ "NoMatchingCapabilityError",
43
+ "CapabilityUnavailableError",
44
+ "ExecutionFailedError",
45
+ "TimeoutExceededError",
46
+ "InvalidRequestError",
47
+ "RateLimitedError",
48
+ "UnauthorizedError",
49
+ "NotFoundError",
50
+ ]
51
+
52
+ __version__ = "0.1.0"
straleio/client.py ADDED
@@ -0,0 +1,314 @@
1
+ """Strale Python SDK client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+ import httpx
10
+
11
+ from .errors import StraleError, create_error
12
+ from .types import (
13
+ BalanceResponse,
14
+ Capability,
15
+ DoRequest,
16
+ DoResponse,
17
+ DryRunResponse,
18
+ Provenance,
19
+ Transaction,
20
+ TransactionDetail,
21
+ )
22
+
23
+ DEFAULT_BASE_URL = "https://strale-production.up.railway.app"
24
+ DEFAULT_TIMEOUT = 60.0
25
+ DEFAULT_POLL_INTERVAL = 2.0
26
+ DEFAULT_MAX_POLL_WAIT = 120.0
27
+
28
+
29
+ class Strale:
30
+ """Strale API client.
31
+
32
+ Args:
33
+ api_key: API key (starts with sk_).
34
+ base_url: Base URL of the Strale API.
35
+ default_max_price_cents: Default max_price_cents for do() calls.
36
+ timeout: HTTP request timeout in seconds. Defaults to 60.
37
+ poll_interval: Poll interval in seconds for async responses. Defaults to 2.
38
+ max_poll_wait: Max wait in seconds for async polling. Defaults to 120.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ api_key: str,
44
+ *,
45
+ base_url: str = DEFAULT_BASE_URL,
46
+ default_max_price_cents: Optional[int] = None,
47
+ timeout: float = DEFAULT_TIMEOUT,
48
+ poll_interval: float = DEFAULT_POLL_INTERVAL,
49
+ max_poll_wait: float = DEFAULT_MAX_POLL_WAIT,
50
+ ):
51
+ if not api_key:
52
+ raise ValueError("api_key is required")
53
+ self._api_key = api_key
54
+ self._base_url = base_url.rstrip("/")
55
+ self._default_max_price_cents = default_max_price_cents
56
+ self._timeout = timeout
57
+ self._poll_interval = poll_interval
58
+ self._max_poll_wait = max_poll_wait
59
+ self._client = httpx.Client(
60
+ base_url=self._base_url,
61
+ timeout=self._timeout,
62
+ headers={"Authorization": f"Bearer {self._api_key}"},
63
+ )
64
+
65
+ def close(self) -> None:
66
+ """Close the underlying HTTP client."""
67
+ self._client.close()
68
+
69
+ def __enter__(self) -> "Strale":
70
+ return self
71
+
72
+ def __exit__(self, *args: Any) -> None:
73
+ self.close()
74
+
75
+ # ─── Core: execute a capability ────────────────────────────────────────────
76
+
77
+ def do(self, request: DoRequest) -> Union[DoResponse, DryRunResponse]:
78
+ """Execute a capability.
79
+
80
+ If the server returns status "executing" (async), auto-polls until
81
+ completed or failed.
82
+ """
83
+ body: Dict[str, Any] = {
84
+ "max_price_cents": request.max_price_cents
85
+ or self._default_max_price_cents,
86
+ }
87
+
88
+ if request.task is not None:
89
+ body["task"] = request.task
90
+ if request.capability_slug is not None:
91
+ body["capability_slug"] = request.capability_slug
92
+ if request.inputs is not None:
93
+ body["inputs"] = request.inputs
94
+ if request.timeout_seconds is not None:
95
+ body["timeout_seconds"] = request.timeout_seconds
96
+ if request.dry_run:
97
+ body["dry_run"] = True
98
+
99
+ headers: Dict[str, str] = {}
100
+ if request.idempotency_key:
101
+ headers["Idempotency-Key"] = request.idempotency_key
102
+
103
+ data = self._request("POST", "/v1/do", json=body, extra_headers=headers)
104
+
105
+ # Dry run returns immediately
106
+ if data.get("dry_run"):
107
+ return DryRunResponse(
108
+ dry_run=True,
109
+ would_execute=data["would_execute"],
110
+ price_cents=data["price_cents"],
111
+ wallet_balance_cents=data["wallet_balance_cents"],
112
+ wallet_sufficient=data["wallet_sufficient"],
113
+ )
114
+
115
+ response = self._parse_do_response(data)
116
+
117
+ # If async (status: "executing"), auto-poll until terminal state
118
+ if response.status == "executing":
119
+ return self._poll_transaction(response)
120
+
121
+ return response
122
+
123
+ # ─── Capabilities ──────────────────────────────────────────────────────────
124
+
125
+ def capabilities(self) -> List[Capability]:
126
+ """List all available capabilities."""
127
+ data = self._request("GET", "/v1/capabilities")
128
+ return [self._parse_capability(c) for c in data["capabilities"]]
129
+
130
+ def capability(self, slug: str) -> Capability:
131
+ """Get details for a specific capability."""
132
+ data = self._request("GET", f"/v1/capabilities/{slug}")
133
+ return self._parse_capability(data)
134
+
135
+ # ─── Wallet ────────────────────────────────────────────────────────────────
136
+
137
+ def balance(self) -> BalanceResponse:
138
+ """Get current wallet balance."""
139
+ data = self._request("GET", "/v1/wallet/balance")
140
+ return BalanceResponse(
141
+ balance_cents=data["balance_cents"],
142
+ currency=data["currency"],
143
+ )
144
+
145
+ # ─── Transactions ──────────────────────────────────────────────────────────
146
+
147
+ def transactions(self) -> List[Transaction]:
148
+ """List recent transactions."""
149
+ data = self._request("GET", "/v1/transactions")
150
+ return [
151
+ Transaction(
152
+ id=t["id"],
153
+ status=t["status"],
154
+ capability_slug=t["capability_slug"],
155
+ price_cents=t["price_cents"],
156
+ latency_ms=t["latency_ms"],
157
+ created_at=t["created_at"],
158
+ completed_at=t.get("completed_at"),
159
+ )
160
+ for t in data["transactions"]
161
+ ]
162
+
163
+ def transaction(self, id: str) -> TransactionDetail:
164
+ """Get details for a specific transaction."""
165
+ data = self._request("GET", f"/v1/transactions/{id}")
166
+ prov = data.get("provenance")
167
+ return TransactionDetail(
168
+ id=data["id"],
169
+ status=data["status"],
170
+ capability_slug=data["capability_slug"],
171
+ input=data.get("input", {}),
172
+ output=data.get("output"),
173
+ error=data.get("error"),
174
+ price_cents=data["price_cents"],
175
+ latency_ms=data["latency_ms"],
176
+ provenance=Provenance(
177
+ source=prov["source"], fetched_at=prov["fetched_at"]
178
+ )
179
+ if prov
180
+ else None,
181
+ created_at=data["created_at"],
182
+ completed_at=data.get("completed_at"),
183
+ )
184
+
185
+ # ─── Auto-poll for async responses ─────────────────────────────────────────
186
+
187
+ def _poll_transaction(self, initial: DoResponse) -> DoResponse:
188
+ deadline = time.monotonic() + self._max_poll_wait
189
+ last_status = initial.status
190
+
191
+ while time.monotonic() < deadline:
192
+ time.sleep(self._poll_interval)
193
+
194
+ detail = self.transaction(initial.transaction_id)
195
+
196
+ if detail.status == "completed":
197
+ return DoResponse(
198
+ transaction_id=detail.id,
199
+ status="completed",
200
+ capability_used=detail.capability_slug,
201
+ price_cents=detail.price_cents,
202
+ latency_ms=detail.latency_ms,
203
+ wallet_balance_cents=initial.wallet_balance_cents,
204
+ output=detail.output or {},
205
+ provenance=detail.provenance
206
+ or Provenance(source="unknown", fetched_at=""),
207
+ )
208
+
209
+ if detail.status == "failed":
210
+ raise StraleError(
211
+ "execution_failed",
212
+ detail.error or "Capability execution failed",
213
+ 500,
214
+ {"transaction_id": detail.id},
215
+ )
216
+
217
+ last_status = detail.status
218
+
219
+ raise StraleError(
220
+ "timeout_exceeded",
221
+ f"Timed out waiting for transaction {initial.transaction_id} "
222
+ f"(last status: {last_status})",
223
+ 408,
224
+ {"transaction_id": initial.transaction_id},
225
+ )
226
+
227
+ # ─── HTTP layer ────────────────────────────────────────────────────────────
228
+
229
+ def _request(
230
+ self,
231
+ method: str,
232
+ path: str,
233
+ *,
234
+ json: Optional[Dict[str, Any]] = None,
235
+ extra_headers: Optional[Dict[str, str]] = None,
236
+ ) -> Dict[str, Any]:
237
+ try:
238
+ response = self._client.request(
239
+ method,
240
+ path,
241
+ json=json,
242
+ headers=extra_headers,
243
+ )
244
+ except httpx.TimeoutException:
245
+ raise StraleError(
246
+ "timeout_exceeded",
247
+ f"Request timed out after {self._timeout}s",
248
+ 408,
249
+ )
250
+ except httpx.HTTPError as e:
251
+ raise StraleError(
252
+ "execution_failed",
253
+ f"Network error: {e}",
254
+ 0,
255
+ )
256
+
257
+ try:
258
+ data = response.json()
259
+ except Exception:
260
+ raise StraleError(
261
+ "execution_failed",
262
+ f"Invalid JSON response (HTTP {response.status_code})",
263
+ response.status_code,
264
+ )
265
+
266
+ if not response.is_success:
267
+ if isinstance(data, dict) and "error_code" in data:
268
+ raise create_error(
269
+ data["error_code"],
270
+ data.get("message", "Unknown error"),
271
+ response.status_code,
272
+ data.get("details"),
273
+ )
274
+ raise StraleError(
275
+ "execution_failed",
276
+ f"HTTP {response.status_code}: {data}",
277
+ response.status_code,
278
+ )
279
+
280
+ return data
281
+
282
+ # ─── Parsers ───────────────────────────────────────────────────────────────
283
+
284
+ @staticmethod
285
+ def _parse_do_response(data: Dict[str, Any]) -> DoResponse:
286
+ prov = data.get("provenance")
287
+ return DoResponse(
288
+ transaction_id=data["transaction_id"],
289
+ status=data["status"],
290
+ capability_used=data.get("capability_used", ""),
291
+ price_cents=data.get("price_cents", 0),
292
+ latency_ms=data.get("latency_ms", 0),
293
+ wallet_balance_cents=data.get("wallet_balance_cents", 0),
294
+ output=data.get("output", {}),
295
+ provenance=Provenance(
296
+ source=prov["source"], fetched_at=prov["fetched_at"]
297
+ )
298
+ if prov
299
+ else Provenance(source="unknown", fetched_at=""),
300
+ )
301
+
302
+ @staticmethod
303
+ def _parse_capability(data: Dict[str, Any]) -> Capability:
304
+ return Capability(
305
+ slug=data["slug"],
306
+ name=data["name"],
307
+ description=data["description"],
308
+ category=data["category"],
309
+ price_cents=data["price_cents"],
310
+ input_schema=data.get("input_schema"),
311
+ output_schema=data.get("output_schema"),
312
+ avg_latency_ms=data.get("avg_latency_ms", 0),
313
+ success_rate=data.get("success_rate", 0.0),
314
+ )
straleio/errors.py ADDED
@@ -0,0 +1,95 @@
1
+ """Error classes for the Strale Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ class StraleError(Exception):
9
+ """Base error class for all Strale API errors."""
10
+
11
+ def __init__(
12
+ self,
13
+ error_code: str,
14
+ message: str,
15
+ status_code: int,
16
+ details: Optional[Dict[str, Any]] = None,
17
+ ):
18
+ super().__init__(message)
19
+ self.error_code = error_code
20
+ self.status_code = status_code
21
+ self.details = details or {}
22
+
23
+
24
+ class InsufficientBalanceError(StraleError):
25
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
26
+ super().__init__("insufficient_balance", message, 402, details)
27
+ self.wallet_balance_cents: int = (details or {}).get("wallet_balance_cents", 0)
28
+ self.required_cents: int = (details or {}).get("required_cents", 0)
29
+
30
+
31
+ class NoMatchingCapabilityError(StraleError):
32
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
33
+ super().__init__("no_matching_capability", message, 404, details)
34
+
35
+
36
+ class CapabilityUnavailableError(StraleError):
37
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
38
+ super().__init__("capability_unavailable", message, 503, details)
39
+
40
+
41
+ class ExecutionFailedError(StraleError):
42
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
43
+ super().__init__("execution_failed", message, 500, details)
44
+ self.transaction_id: Optional[str] = (details or {}).get("transaction_id")
45
+
46
+
47
+ class TimeoutExceededError(StraleError):
48
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
49
+ super().__init__("timeout_exceeded", message, 408, details)
50
+
51
+
52
+ class InvalidRequestError(StraleError):
53
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
54
+ super().__init__("invalid_request", message, 400, details)
55
+
56
+
57
+ class RateLimitedError(StraleError):
58
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
59
+ super().__init__("rate_limited", message, 429, details)
60
+
61
+
62
+ class UnauthorizedError(StraleError):
63
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
64
+ super().__init__("unauthorized", message, 401, details)
65
+
66
+
67
+ class NotFoundError(StraleError):
68
+ def __init__(self, message: str, details: Optional[Dict[str, Any]] = None):
69
+ super().__init__("not_found", message, 404, details)
70
+
71
+
72
+ _ERROR_MAP = {
73
+ "insufficient_balance": InsufficientBalanceError,
74
+ "no_matching_capability": NoMatchingCapabilityError,
75
+ "capability_unavailable": CapabilityUnavailableError,
76
+ "execution_failed": ExecutionFailedError,
77
+ "timeout_exceeded": TimeoutExceededError,
78
+ "invalid_request": InvalidRequestError,
79
+ "rate_limited": RateLimitedError,
80
+ "unauthorized": UnauthorizedError,
81
+ "not_found": NotFoundError,
82
+ }
83
+
84
+
85
+ def create_error(
86
+ error_code: str,
87
+ message: str,
88
+ status_code: int,
89
+ details: Optional[Dict[str, Any]] = None,
90
+ ) -> StraleError:
91
+ """Create a typed error from an API error response."""
92
+ cls = _ERROR_MAP.get(error_code, StraleError)
93
+ if cls is StraleError:
94
+ return StraleError(error_code, message, status_code, details)
95
+ return cls(message, details)
straleio/py.typed ADDED
File without changes
straleio/types.py ADDED
@@ -0,0 +1,118 @@
1
+ """Type definitions for the Strale Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Literal, Optional
7
+
8
+
9
+ @dataclass
10
+ class DoRequest:
11
+ """Request payload for POST /v1/do."""
12
+
13
+ max_price_cents: int
14
+ """Maximum price in EUR cents you're willing to pay."""
15
+
16
+ task: Optional[str] = None
17
+ """Natural language description of what you need done."""
18
+
19
+ capability_slug: Optional[str] = None
20
+ """Direct capability slug override (bypasses matching)."""
21
+
22
+ inputs: Optional[Dict[str, Any]] = None
23
+ """Structured input for the capability."""
24
+
25
+ timeout_seconds: Optional[int] = None
26
+ """Execution timeout in seconds (max 60)."""
27
+
28
+ dry_run: bool = False
29
+ """If True, returns what would execute without charging."""
30
+
31
+ idempotency_key: Optional[str] = None
32
+ """Idempotency key for safe retries."""
33
+
34
+
35
+ @dataclass
36
+ class Provenance:
37
+ """Provenance metadata for a capability execution."""
38
+
39
+ source: str
40
+ fetched_at: str
41
+
42
+
43
+ @dataclass
44
+ class DoResponse:
45
+ """Response from POST /v1/do (completed or executing)."""
46
+
47
+ transaction_id: str
48
+ status: str
49
+ capability_used: str
50
+ price_cents: int
51
+ latency_ms: int
52
+ wallet_balance_cents: int
53
+ output: Dict[str, Any]
54
+ provenance: Provenance
55
+
56
+
57
+ @dataclass
58
+ class DryRunResponse:
59
+ """Response from POST /v1/do with dry_run=True."""
60
+
61
+ dry_run: bool
62
+ would_execute: str
63
+ price_cents: int
64
+ wallet_balance_cents: int
65
+ wallet_sufficient: bool
66
+
67
+
68
+ @dataclass
69
+ class Capability:
70
+ """A capability available on the Strale platform."""
71
+
72
+ slug: str
73
+ name: str
74
+ description: str
75
+ category: str
76
+ price_cents: int
77
+ input_schema: Any
78
+ output_schema: Any
79
+ avg_latency_ms: int
80
+ success_rate: float
81
+
82
+
83
+ @dataclass
84
+ class BalanceResponse:
85
+ """Wallet balance response."""
86
+
87
+ balance_cents: int
88
+ currency: str
89
+
90
+
91
+ @dataclass
92
+ class Transaction:
93
+ """Transaction summary from list endpoint."""
94
+
95
+ id: str
96
+ status: str
97
+ capability_slug: str
98
+ price_cents: int
99
+ latency_ms: int
100
+ created_at: str
101
+ completed_at: Optional[str]
102
+
103
+
104
+ @dataclass
105
+ class TransactionDetail:
106
+ """Full transaction detail."""
107
+
108
+ id: str
109
+ status: str
110
+ capability_slug: str
111
+ input: Dict[str, Any]
112
+ output: Optional[Dict[str, Any]]
113
+ error: Optional[str]
114
+ price_cents: int
115
+ latency_ms: int
116
+ provenance: Optional[Provenance]
117
+ created_at: str
118
+ completed_at: Optional[str]
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: straleio
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Strale API — let AI agents buy capabilities at runtime
5
+ License: MIT
6
+ Project-URL: Homepage, https://strale.io
7
+ Project-URL: Repository, https://github.com/petter_lindstrom/strale
8
+ Keywords: strale,ai,agent,capabilities,api,sdk
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.9
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: httpx>=0.24.0
@@ -0,0 +1,9 @@
1
+ straleio/__init__.py,sha256=NPUkHpRhvZ80M7ZUaGSJscxxdXbrD0LFcT_QZdCKLSI,1062
2
+ straleio/client.py,sha256=S_hksPH4g3xdsoMLWbdHqTWfAfFu9-XEgkr4Ns5wAZE,11506
3
+ straleio/errors.py,sha256=ih9lcomtC0fw2iirJ2qiknvG0pWZBDYjwrxY3bSpR2w,3302
4
+ straleio/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ straleio/types.py,sha256=e6KmjcGwwxHUG4OIhkYxgk0BAnI-fbE2V_SdGoQo-44,2481
6
+ straleio-0.1.0.dist-info/METADATA,sha256=Z-Mk-NebyMUlKcOUL5onUeLIv4shSJvsUw3zXkIL1eI,863
7
+ straleio-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
8
+ straleio-0.1.0.dist-info/top_level.txt,sha256=eg29jWr-cP60VBezo-hzPrt9wKWiELqIommJfmSTuGM,9
9
+ straleio-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ straleio