openbot-sdk 0.3.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.
@@ -0,0 +1,33 @@
1
+ """
2
+ openbot-sdk — Python SDK for OpenBot.ai.
3
+
4
+ openbot-sdk is a thin authenticated client for the OpenBot platform API.
5
+
6
+ Example:
7
+ >>> import openbot_sdk
8
+ >>> client = openbot_sdk.Client()
9
+ >>> status = client.request("GET", "/status")
10
+ >>> print(status)
11
+ """
12
+
13
+ from openbot_sdk._client import Client
14
+ from openbot_sdk._errors import (
15
+ APIError,
16
+ APIResponseError,
17
+ AuthenticationError,
18
+ ClientClosedError,
19
+ NetworkError,
20
+ OpenBotError,
21
+ )
22
+ from openbot_sdk._version import __version__
23
+
24
+ __all__ = [
25
+ "Client",
26
+ "OpenBotError",
27
+ "AuthenticationError",
28
+ "ClientClosedError",
29
+ "APIError",
30
+ "APIResponseError",
31
+ "NetworkError",
32
+ "__version__",
33
+ ]
openbot_sdk/_client.py ADDED
@@ -0,0 +1,281 @@
1
+ """OpenBot.ai Python client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from typing import Any, Callable, cast
8
+ from urllib.parse import urlparse
9
+
10
+ import httpx
11
+
12
+ from openbot_sdk._errors import (
13
+ APIError,
14
+ APIResponseError,
15
+ AuthenticationError,
16
+ ClientClosedError,
17
+ NetworkError,
18
+ )
19
+
20
+ DEFAULT_BASE_URL = "https://api.openbot.ai/v1"
21
+ RETRYABLE_STATUS_CODES = frozenset({429, 502, 503, 504})
22
+ # A mutation's Idempotency-Key is bound to its first outcome. The gateway burns
23
+ # the key when the upstream fails (502), so a same-key retry would only turn the
24
+ # real error into a 409; 502 is therefore never retried for mutations.
25
+ KEYED_MUTATION_RETRYABLE_STATUS_CODES = frozenset({429, 503, 504})
26
+ # The first call with this key is still running; waiting and replaying the same
27
+ # key eventually returns the stored result without a second charge.
28
+ IN_PROGRESS_ERROR_CODES = frozenset({"invocation_in_progress"})
29
+ IDEMPOTENT_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "DELETE"})
30
+
31
+
32
+ class Client:
33
+ """
34
+ Client for the OpenBot.ai API.
35
+
36
+ Args:
37
+ api_key: OpenBot.ai API key. Falls back to the ``OPENBOT_API_KEY``
38
+ environment variable if not provided.
39
+ base_url: Base URL for the OpenBot.ai API.
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ api_key: str | None = None,
45
+ base_url: str = DEFAULT_BASE_URL,
46
+ timeout: float = 60.0,
47
+ download_timeout: float = 300.0,
48
+ max_retries: int = 2,
49
+ retry_backoff: float = 0.25,
50
+ allow_insecure_http: bool = False,
51
+ sleeper: Callable[[float], None] = time.sleep,
52
+ clock: Callable[[], float] = time.monotonic,
53
+ ) -> None:
54
+ self.api_key = api_key or os.environ.get("OPENBOT_API_KEY")
55
+ if not self.api_key:
56
+ raise AuthenticationError(
57
+ "API key is required. Provide it via the api_key argument "
58
+ "or set the OPENBOT_API_KEY environment variable."
59
+ )
60
+
61
+ parsed_url = urlparse(base_url)
62
+ if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
63
+ raise ValueError("base_url must be an absolute HTTP(S) URL")
64
+ if parsed_url.scheme != "https" and not allow_insecure_http:
65
+ raise ValueError(
66
+ "base_url must use HTTPS; pass allow_insecure_http=True only for local testing"
67
+ )
68
+ if timeout <= 0 or download_timeout <= 0:
69
+ raise ValueError("timeouts must be greater than zero")
70
+ if max_retries < 0 or retry_backoff < 0:
71
+ raise ValueError("retry settings cannot be negative")
72
+
73
+ self.base_url = base_url.rstrip("/")
74
+ self.timeout = timeout
75
+ self.download_timeout = download_timeout
76
+ self.max_retries = max_retries
77
+ self.retry_backoff = retry_backoff
78
+ self._sleep = sleeper
79
+ self._clock = clock
80
+ self._http = httpx.Client(
81
+ base_url=self.base_url,
82
+ headers={
83
+ "Authorization": f"Bearer {self.api_key}",
84
+ "User-Agent": f"openbot_sdk-python/{self._version()}",
85
+ },
86
+ timeout=self.timeout,
87
+ )
88
+ self._closed = False
89
+
90
+ def _version(self) -> str:
91
+ from openbot_sdk._version import __version__
92
+
93
+ return __version__
94
+
95
+ def _request(
96
+ self,
97
+ method: str,
98
+ path: str,
99
+ *,
100
+ json: dict[str, Any] | None = None,
101
+ params: dict[str, Any] | None = None,
102
+ headers: dict[str, str] | None = None,
103
+ ) -> dict[str, Any]:
104
+ """Make an HTTP request and return the JSON response."""
105
+ self._ensure_open()
106
+ request_headers = {**headers} if headers else None
107
+ response = self._send_with_retries(
108
+ method,
109
+ path,
110
+ json=json,
111
+ params=params,
112
+ headers=request_headers,
113
+ timeout=self.timeout,
114
+ )
115
+ self._raise_for_error(response)
116
+ try:
117
+ payload = response.json()
118
+ except ValueError as exc:
119
+ raise APIResponseError("API returned a non-JSON response") from exc
120
+ if not isinstance(payload, dict):
121
+ raise APIResponseError("API returned JSON with an unexpected top-level type")
122
+ return cast(dict[str, Any], payload)
123
+
124
+ def request(
125
+ self,
126
+ method: str,
127
+ path: str,
128
+ *,
129
+ json: dict[str, Any] | None = None,
130
+ params: dict[str, Any] | None = None,
131
+ headers: dict[str, str] | None = None,
132
+ ) -> dict[str, Any]:
133
+ """Call any OpenBot platform JSON API endpoint with this client's API key."""
134
+ return self._request(
135
+ method,
136
+ path,
137
+ json=json,
138
+ params=params,
139
+ headers=headers,
140
+ )
141
+
142
+ def _request_bytes(
143
+ self,
144
+ method: str,
145
+ path: str,
146
+ *,
147
+ timeout: float | None = None,
148
+ ) -> bytes:
149
+ """Make an authenticated request and return the raw response body."""
150
+ self._ensure_open()
151
+ request_timeout = self.download_timeout if timeout is None else timeout
152
+ if request_timeout <= 0:
153
+ raise ValueError("timeout must be greater than zero")
154
+ response = self._send_with_retries(method, path, timeout=request_timeout)
155
+ self._raise_for_error(response)
156
+ return response.content
157
+
158
+ def request_bytes(
159
+ self,
160
+ method: str,
161
+ path: str,
162
+ *,
163
+ timeout: float | None = None,
164
+ ) -> bytes:
165
+ """Call an OpenBot platform endpoint and return its authenticated byte response."""
166
+ return self._request_bytes(method, path, timeout=timeout)
167
+
168
+ def _send_with_retries(
169
+ self,
170
+ method: str,
171
+ path: str,
172
+ *,
173
+ json: dict[str, Any] | None = None,
174
+ params: dict[str, Any] | None = None,
175
+ headers: dict[str, str] | None = None,
176
+ timeout: float,
177
+ ) -> httpx.Response:
178
+ normalized_method = method.upper()
179
+ idempotent_method = normalized_method in IDEMPOTENT_METHODS
180
+ keyed_mutation = not idempotent_method and bool(
181
+ headers and headers.get("Idempotency-Key")
182
+ )
183
+ can_retry = idempotent_method or keyed_mutation
184
+ attempts = self.max_retries + 1 if can_retry else 1
185
+
186
+ for attempt in range(attempts):
187
+ try:
188
+ response = self._http.request(
189
+ normalized_method,
190
+ path,
191
+ json=json,
192
+ params=params,
193
+ headers=headers,
194
+ timeout=timeout,
195
+ )
196
+ except httpx.RequestError as exc:
197
+ if attempt + 1 >= attempts:
198
+ raise NetworkError(f"API request failed: {exc}") from exc
199
+ self._sleep_before_retry(attempt, None)
200
+ continue
201
+
202
+ retryable = (
203
+ self._keyed_mutation_should_retry(response)
204
+ if keyed_mutation
205
+ else response.status_code in RETRYABLE_STATUS_CODES
206
+ )
207
+ if not retryable or attempt + 1 >= attempts:
208
+ return response
209
+ self._sleep_before_retry(attempt, response.headers.get("Retry-After"))
210
+
211
+ raise NetworkError("API request failed after retries")
212
+
213
+ def _keyed_mutation_should_retry(self, response: httpx.Response) -> bool:
214
+ if response.status_code in KEYED_MUTATION_RETRYABLE_STATUS_CODES:
215
+ return True
216
+ return response.status_code == 409 and self._error_code(response) in IN_PROGRESS_ERROR_CODES
217
+
218
+ @staticmethod
219
+ def _error_code(response: httpx.Response) -> str | None:
220
+ try:
221
+ payload = response.json()
222
+ except ValueError:
223
+ return None
224
+ if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
225
+ code = payload["error"].get("code")
226
+ return code if isinstance(code, str) else None
227
+ return None
228
+
229
+ def _sleep_before_retry(self, attempt: int, retry_after: str | None) -> None:
230
+ delay = self.retry_backoff * (2**attempt)
231
+ if retry_after is not None:
232
+ try:
233
+ delay = min(float(retry_after), 60.0)
234
+ except ValueError:
235
+ pass
236
+ if delay > 0:
237
+ self._sleep(delay)
238
+
239
+ def _raise_for_error(self, response: httpx.Response) -> None:
240
+ if response.status_code < 400:
241
+ return
242
+
243
+ message = f"API request failed ({response.status_code})"
244
+ code: str | None = None
245
+ retryable: bool | None = None
246
+ try:
247
+ payload = response.json()
248
+ except ValueError:
249
+ payload = None
250
+ if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
251
+ error = payload["error"]
252
+ if isinstance(error.get("message"), str):
253
+ message = error["message"]
254
+ if isinstance(error.get("code"), str):
255
+ code = error["code"]
256
+ if isinstance(error.get("retryable"), bool):
257
+ retryable = error["retryable"]
258
+ elif response.text:
259
+ message = f"{message}: {response.text}"
260
+ raise APIError(
261
+ message,
262
+ status_code=response.status_code,
263
+ code=code,
264
+ retryable=retryable,
265
+ )
266
+
267
+ def _ensure_open(self) -> None:
268
+ if self._closed:
269
+ raise ClientClosedError("Client is closed")
270
+
271
+ def close(self) -> None:
272
+ """Close the underlying HTTP client."""
273
+ if not self._closed:
274
+ self._http.close()
275
+ self._closed = True
276
+
277
+ def __enter__(self) -> Client:
278
+ return self
279
+
280
+ def __exit__(self, *args: object) -> None:
281
+ self.close()
@@ -0,0 +1,31 @@
1
+ """Compatibility checks between the SDK and the served OpenBot OpenAPI contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+
8
+ def openapi_compatibility_errors(spec: dict[str, Any]) -> list[str]:
9
+ errors: list[str] = []
10
+ paths = spec.get("paths")
11
+ if not isinstance(paths, dict):
12
+ return ["OpenAPI paths must be an object"]
13
+ me = paths.get("/v1/me")
14
+ if not isinstance(me, dict) or "get" not in me:
15
+ errors.append("GET /v1/me is required for API-key context probing")
16
+ forbidden = ("/v1/bench", "/v1/synth", "/v1/data/")
17
+ for path in paths:
18
+ removed = isinstance(path, str) and any(
19
+ path == prefix or path.startswith(prefix) for prefix in forbidden
20
+ )
21
+ if removed:
22
+ errors.append(f"removed product path is still published: {path}")
23
+ schemes = spec.get("components", {}).get("securitySchemes", {})
24
+ if not isinstance(schemes, dict) or not any(
25
+ isinstance(value, dict)
26
+ and value.get("type") == "http"
27
+ and value.get("scheme") == "bearer"
28
+ for value in schemes.values()
29
+ ):
30
+ errors.append("a Bearer security scheme is required")
31
+ return errors
openbot_sdk/_errors.py ADDED
@@ -0,0 +1,40 @@
1
+ """OpenBot.ai SDK errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class OpenBotError(Exception):
7
+ """Base error for all OpenBot.ai SDK errors."""
8
+
9
+
10
+ class AuthenticationError(OpenBotError):
11
+ """Raised when the API key is missing or invalid."""
12
+
13
+
14
+ class APIError(OpenBotError):
15
+ """Raised when the API returns a non-2xx response."""
16
+
17
+ def __init__(
18
+ self,
19
+ message: str,
20
+ status_code: int | None = None,
21
+ *,
22
+ code: str | None = None,
23
+ retryable: bool | None = None,
24
+ ) -> None:
25
+ super().__init__(message)
26
+ self.status_code = status_code
27
+ self.code = code
28
+ self.retryable = retryable
29
+
30
+
31
+ class APIResponseError(OpenBotError):
32
+ """Raised when a successful API response has an invalid payload."""
33
+
34
+
35
+ class NetworkError(OpenBotError):
36
+ """Raised when the API cannot be reached or a request times out."""
37
+
38
+
39
+ class ClientClosedError(OpenBotError):
40
+ """Raised when a closed client is used."""
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("openbot-sdk")
5
+ except PackageNotFoundError: # pragma: no cover - raw source tree without installation
6
+ __version__ = "0+unknown"
openbot_sdk/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.5
2
+ Name: openbot-sdk
3
+ Version: 0.3.0
4
+ Summary: Thin Python client for the OpenBot.ai platform API.
5
+ Project-URL: Homepage, https://openbot.ai
6
+ Project-URL: Documentation, https://openbot.ai/api/docs
7
+ Project-URL: Repository, https://github.com/openbotai/openbot-sdk
8
+ Project-URL: Issues, https://github.com/openbotai/openbot-sdk/issues
9
+ Author-email: "OpenBot.ai" <hello@openbot.ai>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: API client,API key,HTTP client,OpenBot.ai,VLA,embodied AI,robotics
13
+ Classifier: Development Status :: 2 - Pre-Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx>=0.27.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: build>=1.0.0; extra == 'dev'
28
+ Requires-Dist: mypy>=1.11.0; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
30
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
31
+ Requires-Dist: respx>=0.22.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.6.0; extra == 'dev'
33
+ Requires-Dist: twine>=6.2.0; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # openbot-sdk
37
+
38
+ `openbot-sdk` is the thin Python client for the
39
+ [OpenBot.ai platform API](https://openbot.ai/api/docs).
40
+
41
+ It handles API-key authentication, HTTP requests, timeouts, bounded retries,
42
+ and typed errors. It does not process robot data and is not tied to a Hosted
43
+ Data product.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install openbot-sdk
49
+ ```
50
+
51
+ Requires Python 3.9+.
52
+
53
+ ## Authentication
54
+
55
+ ```bash
56
+ export OPENBOT_API_KEY="ob_..."
57
+ ```
58
+
59
+ ```python
60
+ from openbot_sdk import Client
61
+
62
+ client = Client() # reads OPENBOT_API_KEY
63
+ status = client.request("GET", "/status")
64
+ print(status)
65
+ ```
66
+
67
+ You can also pass the key explicitly:
68
+
69
+ ```python
70
+ client = Client(api_key="ob_...")
71
+ ```
72
+
73
+ ## Call platform APIs
74
+
75
+ Use `request` for JSON APIs and `request_bytes` for byte responses:
76
+
77
+ ```python
78
+ payload = client.request(
79
+ "POST",
80
+ "/some-resource",
81
+ json={"name": "example"},
82
+ headers={"Idempotency-Key": "request-123"},
83
+ )
84
+
85
+ content = client.request_bytes("GET", "/some-artifact")
86
+ ```
87
+
88
+ Only call routes published in the current OpenBot OpenAPI document. As the
89
+ platform adds real APIs, the SDK may add small convenience wrappers for those
90
+ same contracts.
91
+
92
+ The SDK intentionally has no Bench, Synth, or Hosted Data resource wrapper.
93
+ Convenience wrappers correspond to operations in the checked OpenAPI contract;
94
+ `request(...)` remains the forward-compatible escape hatch.
95
+
96
+ ## Errors, retries, and security
97
+
98
+ ```python
99
+ from openbot_sdk import APIError, NetworkError
100
+
101
+ try:
102
+ payload = client.request("GET", "/status")
103
+ except APIError as exc:
104
+ print(exc.status_code)
105
+ except NetworkError as exc:
106
+ print(exc)
107
+ ```
108
+
109
+ The client retries idempotent methods on transport errors, `429`, and
110
+ transient `5xx` responses. Mutations carrying an `Idempotency-Key` are retried
111
+ with the same key only where that is safe: transport errors, `429`, `503`
112
+ (for example `settlement_pending`), `504`, and `409 invocation_in_progress`.
113
+ A `502` is returned immediately, because the gateway burns the key when the
114
+ upstream fails; retry that call with a new key. For `POST /v1/invoke/:slug`,
115
+ create the client with `timeout` (seconds) larger than the API's
116
+ `x-openbot-timeout-ms`, so a slow upstream is not mistaken for a network failure.
117
+ Plain HTTP base URLs are rejected by default; enable them only for explicit
118
+ local testing.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ pip install -e ".[dev]"
124
+ python scripts/check_version.py
125
+ python scripts/check_openapi_contract.py /path/to/openapi.json
126
+ pytest -v
127
+ ruff check src tests
128
+ mypy src
129
+ python -m build
130
+ ```
131
+
132
+ `VERSION` is the package version source of truth. Release tags use `v<version>`.
133
+
134
+ ## Package boundaries
135
+
136
+ - `openbot-sdk`: OpenBot platform API client.
137
+ - `openbot-data`: local robot/ego data processing library.
138
+ - OpenBot platform: server-side API implementation and infrastructure.
139
+
140
+ ## License
141
+
142
+ MIT
@@ -0,0 +1,10 @@
1
+ openbot_sdk/__init__.py,sha256=Gxqx_OHi4lOFmUDXq8nzAMXooiRsMnmElE3ExbFRX3k,693
2
+ openbot_sdk/_client.py,sha256=LPiB2ZvtijuJDel9nyWx4gQ4FD8j0cqC8oo_hSPHsDs,9736
3
+ openbot_sdk/_contract.py,sha256=ubku0k8eqNN9lNS7Wk-j3WzoMLP66qv9st1_SpCWfYU,1188
4
+ openbot_sdk/_errors.py,sha256=xnzQm-2HVN5FsE41EqGpZW43laDovDvWV9Rrn_IEmPU,974
5
+ openbot_sdk/_version.py,sha256=bS1AeZ1VrsFMXQNQwSETkDdXyNoBsjXkLw7qDYbTRYs,226
6
+ openbot_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
7
+ openbot_sdk-0.3.0.dist-info/METADATA,sha256=X4F37COsbM9AGPIK9n4OE5mvk2fKcFpxQtI6c35F-F8,4313
8
+ openbot_sdk-0.3.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ openbot_sdk-0.3.0.dist-info/licenses/LICENSE,sha256=b0Vh5bHTOKkuhoBZ6HsAZyXm2Dm9p0q2MoUqYNZ15tc,1067
10
+ openbot_sdk-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpenBot.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.