openbot-sdk 0.3.0__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.
- openbot_sdk-0.3.0/.gitignore +42 -0
- openbot_sdk-0.3.0/CHANGELOG.md +66 -0
- openbot_sdk-0.3.0/LICENSE +21 -0
- openbot_sdk-0.3.0/PKG-INFO +142 -0
- openbot_sdk-0.3.0/README.md +107 -0
- openbot_sdk-0.3.0/VERSION +1 -0
- openbot_sdk-0.3.0/docs/README.md +6 -0
- openbot_sdk-0.3.0/docs/api-reference.md +44 -0
- openbot_sdk-0.3.0/docs/getting-started.md +26 -0
- openbot_sdk-0.3.0/pyproject.toml +97 -0
- openbot_sdk-0.3.0/scripts/check_openapi_contract.py +40 -0
- openbot_sdk-0.3.0/scripts/check_version.py +61 -0
- openbot_sdk-0.3.0/scripts/test_matrix.sh +11 -0
- openbot_sdk-0.3.0/src/openbot_sdk/__init__.py +33 -0
- openbot_sdk-0.3.0/src/openbot_sdk/_client.py +281 -0
- openbot_sdk-0.3.0/src/openbot_sdk/_contract.py +31 -0
- openbot_sdk-0.3.0/src/openbot_sdk/_errors.py +40 -0
- openbot_sdk-0.3.0/src/openbot_sdk/_version.py +6 -0
- openbot_sdk-0.3.0/src/openbot_sdk/py.typed +1 -0
- openbot_sdk-0.3.0/tests/test_client.py +230 -0
- openbot_sdk-0.3.0/tests/test_contract.py +46 -0
- openbot_sdk-0.3.0/tests/test_version.py +7 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
build/
|
|
8
|
+
develop-eggs/
|
|
9
|
+
dist/
|
|
10
|
+
downloads/
|
|
11
|
+
eggs/
|
|
12
|
+
.eggs/
|
|
13
|
+
lib/
|
|
14
|
+
lib64/
|
|
15
|
+
parts/
|
|
16
|
+
sdist/
|
|
17
|
+
var/
|
|
18
|
+
wheels/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
|
|
23
|
+
# Virtual environments
|
|
24
|
+
venv/
|
|
25
|
+
env/
|
|
26
|
+
ENV/
|
|
27
|
+
.venv
|
|
28
|
+
|
|
29
|
+
# Testing
|
|
30
|
+
.pytest_cache/
|
|
31
|
+
.coverage
|
|
32
|
+
htmlcov/
|
|
33
|
+
|
|
34
|
+
# IDEs
|
|
35
|
+
.vscode/
|
|
36
|
+
.idea/
|
|
37
|
+
*.swp
|
|
38
|
+
*.swo
|
|
39
|
+
|
|
40
|
+
# OS
|
|
41
|
+
.DS_Store
|
|
42
|
+
Thumbs.db
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Removed
|
|
6
|
+
|
|
7
|
+
- Ego Semantic Annotation create/get/cancel/result wrappers. The operation is
|
|
8
|
+
discontinued, and the OpenAPI compatibility check no longer requires its routes.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Mutations carrying an `Idempotency-Key` are no longer retried after `502`:
|
|
13
|
+
the gateway burns the key when the upstream fails, so a same-key retry masked
|
|
14
|
+
the real error as `409`. `409 invocation_in_progress` is now retried with the
|
|
15
|
+
same key until the stored result is replayed.
|
|
16
|
+
- `scripts/check_openapi_contract.py` sends an SDK User-Agent; the production
|
|
17
|
+
edge rejected the default Python-urllib agent with `403`, which would have
|
|
18
|
+
failed the release workflow.
|
|
19
|
+
|
|
20
|
+
## 0.2.0
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Ego Semantic Annotation create/get/cancel/result wrappers.
|
|
25
|
+
- Mandatory stable idempotency key, source SHA-256, and bounded duration checks.
|
|
26
|
+
- OpenAPI compatibility checks for the complete asynchronous operation surface.
|
|
27
|
+
|
|
28
|
+
### Safety
|
|
29
|
+
|
|
30
|
+
- The wrapper does not perform inference locally or fabricate fallback results.
|
|
31
|
+
- Feature-gate, concurrency, provider, and billing failures remain structured API errors.
|
|
32
|
+
|
|
33
|
+
## 0.1.0
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- Removed the unreleased Bench wrapper and run polling types.
|
|
38
|
+
- Kept the SDK as a neutral authenticated client for operations published by
|
|
39
|
+
the production OpenAPI contract.
|
|
40
|
+
- Removed customer webhook signing helpers until a deployed asynchronous API
|
|
41
|
+
publishes a callback contract.
|
|
42
|
+
|
|
43
|
+
## 0.0.2
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
|
|
47
|
+
- Public `Client.request(...)` for authenticated JSON platform API calls.
|
|
48
|
+
- Public `Client.request_bytes(...)` for authenticated byte responses.
|
|
49
|
+
- Configurable request/download timeouts and bounded retry behavior.
|
|
50
|
+
- Typed network and response errors.
|
|
51
|
+
- Single-source version and release-tag validation.
|
|
52
|
+
|
|
53
|
+
### Clarified
|
|
54
|
+
|
|
55
|
+
- `openbot-sdk` is only a thin client for the OpenBot platform API.
|
|
56
|
+
- Robot/ego data processing belongs to `openbot-data`.
|
|
57
|
+
- No Hosted Data resources, jobs, uploads, review, or export helpers are part of
|
|
58
|
+
this package.
|
|
59
|
+
|
|
60
|
+
## 0.0.1
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- Initial API-key client.
|
|
65
|
+
- Bench contract wrapper and polling result helper.
|
|
66
|
+
- Webhook signature verification.
|
|
@@ -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.
|
|
@@ -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,107 @@
|
|
|
1
|
+
# openbot-sdk
|
|
2
|
+
|
|
3
|
+
`openbot-sdk` is the thin Python client for the
|
|
4
|
+
[OpenBot.ai platform API](https://openbot.ai/api/docs).
|
|
5
|
+
|
|
6
|
+
It handles API-key authentication, HTTP requests, timeouts, bounded retries,
|
|
7
|
+
and typed errors. It does not process robot data and is not tied to a Hosted
|
|
8
|
+
Data product.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install openbot-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Requires Python 3.9+.
|
|
17
|
+
|
|
18
|
+
## Authentication
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export OPENBOT_API_KEY="ob_..."
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from openbot_sdk import Client
|
|
26
|
+
|
|
27
|
+
client = Client() # reads OPENBOT_API_KEY
|
|
28
|
+
status = client.request("GET", "/status")
|
|
29
|
+
print(status)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
You can also pass the key explicitly:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
client = Client(api_key="ob_...")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Call platform APIs
|
|
39
|
+
|
|
40
|
+
Use `request` for JSON APIs and `request_bytes` for byte responses:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
payload = client.request(
|
|
44
|
+
"POST",
|
|
45
|
+
"/some-resource",
|
|
46
|
+
json={"name": "example"},
|
|
47
|
+
headers={"Idempotency-Key": "request-123"},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
content = client.request_bytes("GET", "/some-artifact")
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Only call routes published in the current OpenBot OpenAPI document. As the
|
|
54
|
+
platform adds real APIs, the SDK may add small convenience wrappers for those
|
|
55
|
+
same contracts.
|
|
56
|
+
|
|
57
|
+
The SDK intentionally has no Bench, Synth, or Hosted Data resource wrapper.
|
|
58
|
+
Convenience wrappers correspond to operations in the checked OpenAPI contract;
|
|
59
|
+
`request(...)` remains the forward-compatible escape hatch.
|
|
60
|
+
|
|
61
|
+
## Errors, retries, and security
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from openbot_sdk import APIError, NetworkError
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
payload = client.request("GET", "/status")
|
|
68
|
+
except APIError as exc:
|
|
69
|
+
print(exc.status_code)
|
|
70
|
+
except NetworkError as exc:
|
|
71
|
+
print(exc)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The client retries idempotent methods on transport errors, `429`, and
|
|
75
|
+
transient `5xx` responses. Mutations carrying an `Idempotency-Key` are retried
|
|
76
|
+
with the same key only where that is safe: transport errors, `429`, `503`
|
|
77
|
+
(for example `settlement_pending`), `504`, and `409 invocation_in_progress`.
|
|
78
|
+
A `502` is returned immediately, because the gateway burns the key when the
|
|
79
|
+
upstream fails; retry that call with a new key. For `POST /v1/invoke/:slug`,
|
|
80
|
+
create the client with `timeout` (seconds) larger than the API's
|
|
81
|
+
`x-openbot-timeout-ms`, so a slow upstream is not mistaken for a network failure.
|
|
82
|
+
Plain HTTP base URLs are rejected by default; enable them only for explicit
|
|
83
|
+
local testing.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pip install -e ".[dev]"
|
|
89
|
+
python scripts/check_version.py
|
|
90
|
+
python scripts/check_openapi_contract.py /path/to/openapi.json
|
|
91
|
+
pytest -v
|
|
92
|
+
ruff check src tests
|
|
93
|
+
mypy src
|
|
94
|
+
python -m build
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`VERSION` is the package version source of truth. Release tags use `v<version>`.
|
|
98
|
+
|
|
99
|
+
## Package boundaries
|
|
100
|
+
|
|
101
|
+
- `openbot-sdk`: OpenBot platform API client.
|
|
102
|
+
- `openbot-data`: local robot/ego data processing library.
|
|
103
|
+
- OpenBot platform: server-side API implementation and infrastructure.
|
|
104
|
+
|
|
105
|
+
## License
|
|
106
|
+
|
|
107
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.3.0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Client reference
|
|
2
|
+
|
|
3
|
+
## `Client`
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
Client(
|
|
7
|
+
api_key=None,
|
|
8
|
+
base_url="https://api.openbot.ai/v1",
|
|
9
|
+
timeout=60.0,
|
|
10
|
+
download_timeout=300.0,
|
|
11
|
+
max_retries=2,
|
|
12
|
+
retry_backoff=0.25,
|
|
13
|
+
allow_insecure_http=False,
|
|
14
|
+
)
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`api_key` falls back to `OPENBOT_API_KEY`.
|
|
18
|
+
|
|
19
|
+
## `request`
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
client.request(method, path, json=None, params=None, headers=None)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Returns a JSON object. Non-JSON or non-object responses raise
|
|
26
|
+
`APIResponseError`; HTTP failures raise `APIError`.
|
|
27
|
+
|
|
28
|
+
## `request_bytes`
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
client.request_bytes(method, path, timeout=None)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Returns raw response bytes.
|
|
35
|
+
|
|
36
|
+
## Errors
|
|
37
|
+
|
|
38
|
+
- `AuthenticationError`
|
|
39
|
+
- `APIError`
|
|
40
|
+
- `APIResponseError`
|
|
41
|
+
- `NetworkError`
|
|
42
|
+
|
|
43
|
+
Endpoint-specific wrappers are checked against the matching OpenBot OpenAPI
|
|
44
|
+
operation before release.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Getting started
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
pip install openbot-sdk
|
|
5
|
+
export OPENBOT_API_KEY="ob_..."
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from openbot_sdk import Client
|
|
10
|
+
|
|
11
|
+
with Client() as client:
|
|
12
|
+
status = client.request("GET", "/status")
|
|
13
|
+
print(status)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For local API development only:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
client = Client(
|
|
20
|
+
api_key="ob_local_test",
|
|
21
|
+
base_url="http://127.0.0.1:8787/v1",
|
|
22
|
+
allow_insecure_http=True,
|
|
23
|
+
)
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The SDK contains no Hosted Data workflow or robot-data processing logic.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "openbot-sdk"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Thin Python client for the OpenBot.ai platform API."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "OpenBot.ai", email = "hello@openbot.ai" }
|
|
10
|
+
]
|
|
11
|
+
keywords = [
|
|
12
|
+
"OpenBot.ai",
|
|
13
|
+
"API client",
|
|
14
|
+
"API key",
|
|
15
|
+
"embodied AI",
|
|
16
|
+
"VLA",
|
|
17
|
+
"HTTP client",
|
|
18
|
+
"robotics",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Intended Audience :: Science/Research",
|
|
24
|
+
"License :: OSI Approved :: MIT License",
|
|
25
|
+
"Programming Language :: Python :: 3",
|
|
26
|
+
"Programming Language :: Python :: 3.9",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
31
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
32
|
+
]
|
|
33
|
+
dependencies = [
|
|
34
|
+
"httpx>=0.27.0",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"build>=1.0.0",
|
|
40
|
+
"pytest>=7.0.0",
|
|
41
|
+
"pytest-asyncio>=0.23.0",
|
|
42
|
+
"respx>=0.22.0",
|
|
43
|
+
"ruff>=0.6.0",
|
|
44
|
+
"mypy>=1.11.0",
|
|
45
|
+
"twine>=6.2.0",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://openbot.ai"
|
|
50
|
+
Documentation = "https://openbot.ai/api/docs"
|
|
51
|
+
Repository = "https://github.com/openbotai/openbot-sdk"
|
|
52
|
+
Issues = "https://github.com/openbotai/openbot-sdk/issues"
|
|
53
|
+
|
|
54
|
+
[build-system]
|
|
55
|
+
requires = ["hatchling"]
|
|
56
|
+
build-backend = "hatchling.build"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.build.targets.wheel]
|
|
59
|
+
packages = ["src/openbot_sdk"]
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.targets.sdist]
|
|
62
|
+
include = [
|
|
63
|
+
"/docs",
|
|
64
|
+
"/LICENSE",
|
|
65
|
+
"/README.md",
|
|
66
|
+
"/CHANGELOG.md",
|
|
67
|
+
"/VERSION",
|
|
68
|
+
"/scripts",
|
|
69
|
+
"/src",
|
|
70
|
+
"/tests",
|
|
71
|
+
"/pyproject.toml",
|
|
72
|
+
]
|
|
73
|
+
exclude = [
|
|
74
|
+
"**/__pycache__",
|
|
75
|
+
"**/*.pyc",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
[tool.pytest.ini_options]
|
|
79
|
+
testpaths = ["tests"]
|
|
80
|
+
asyncio_mode = "auto"
|
|
81
|
+
|
|
82
|
+
[tool.hatch.version]
|
|
83
|
+
path = "VERSION"
|
|
84
|
+
pattern = "^(?P<version>[^\\s]+)$"
|
|
85
|
+
|
|
86
|
+
[tool.ruff]
|
|
87
|
+
line-length = 100
|
|
88
|
+
target-version = "py39"
|
|
89
|
+
|
|
90
|
+
[tool.ruff.lint]
|
|
91
|
+
select = ["E", "F", "I", "W"]
|
|
92
|
+
|
|
93
|
+
[tool.mypy]
|
|
94
|
+
python_version = "3.9"
|
|
95
|
+
strict = true
|
|
96
|
+
warn_return_any = true
|
|
97
|
+
warn_unused_configs = true
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate an OpenBot OpenAPI file or URL against the SDK contract."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
from openbot_sdk._contract import openapi_compatibility_errors
|
|
13
|
+
from openbot_sdk._version import __version__
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load(source: str) -> dict[str, Any]:
|
|
17
|
+
if source.startswith(("https://", "http://")):
|
|
18
|
+
# The production edge rejects the default Python-urllib User-Agent with 403.
|
|
19
|
+
request = Request(source, headers={"User-Agent": f"openbot_sdk-python/{__version__}"})
|
|
20
|
+
with urlopen(request, timeout=20) as response: # noqa: S310 - explicit CLI input
|
|
21
|
+
return cast(dict[str, Any], json.load(response))
|
|
22
|
+
return cast(dict[str, Any], json.loads(Path(source).read_text(encoding="utf-8")))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> int:
|
|
26
|
+
if len(sys.argv) != 2:
|
|
27
|
+
print("usage: check_openapi_contract.py FILE_OR_URL", file=sys.stderr)
|
|
28
|
+
return 2
|
|
29
|
+
errors = openapi_compatibility_errors(load(sys.argv[1]))
|
|
30
|
+
if errors:
|
|
31
|
+
print("OpenAPI contract is incompatible:", file=sys.stderr)
|
|
32
|
+
for error in errors:
|
|
33
|
+
print(f"- {error}", file=sys.stderr)
|
|
34
|
+
return 1
|
|
35
|
+
print(f"OpenAPI contract is compatible with openbot-sdk {__version__}")
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Validate the package version source, metadata, changelog, and optional tag."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
VERSION_PATTERN = re.compile(
|
|
12
|
+
r"^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
|
|
13
|
+
r"(?:(?:a|b|rc)(?:0|[1-9]\d*))?(?:\.post(?:0|[1-9]\d*))?"
|
|
14
|
+
r"(?:\.dev(?:0|[1-9]\d*))?$"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fail(message: str) -> None:
|
|
19
|
+
print(f"version check failed: {message}", file=sys.stderr)
|
|
20
|
+
raise SystemExit(1)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
parser = argparse.ArgumentParser()
|
|
25
|
+
parser.add_argument("--tag", help="Git tag to compare with v<version>")
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
|
29
|
+
if not VERSION_PATTERN.fullmatch(version):
|
|
30
|
+
fail(f"VERSION is not a supported PEP 440 release: {version!r}")
|
|
31
|
+
|
|
32
|
+
pyproject = (ROOT / "pyproject.toml").read_text(encoding="utf-8")
|
|
33
|
+
project_match = re.search(r"(?ms)^\[project\]\s*(.*?)(?=^\[|\Z)", pyproject)
|
|
34
|
+
if project_match is None:
|
|
35
|
+
fail("pyproject.toml has no [project] section")
|
|
36
|
+
project = project_match.group(1)
|
|
37
|
+
if not re.search(r'^dynamic\s*=\s*\[\s*"version"\s*\]\s*$', project, re.MULTILINE):
|
|
38
|
+
fail("pyproject.toml must declare only a dynamic project version")
|
|
39
|
+
if re.search(r"^version\s*=", project, re.MULTILINE):
|
|
40
|
+
fail("pyproject.toml must not declare a static project version")
|
|
41
|
+
|
|
42
|
+
hatch_match = re.search(
|
|
43
|
+
r"(?ms)^\[tool\.hatch\.version\]\s*(.*?)(?=^\[|\Z)", pyproject
|
|
44
|
+
)
|
|
45
|
+
if hatch_match is None or not re.search(
|
|
46
|
+
r'^path\s*=\s*"VERSION"\s*$', hatch_match.group(1), re.MULTILINE
|
|
47
|
+
):
|
|
48
|
+
fail("Hatch version source must be VERSION")
|
|
49
|
+
|
|
50
|
+
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
|
|
51
|
+
if f"## {version}" not in changelog:
|
|
52
|
+
fail(f"CHANGELOG.md has no section for {version}")
|
|
53
|
+
|
|
54
|
+
if args.tag and args.tag != f"v{version}":
|
|
55
|
+
fail(f"tag {args.tag!r} does not match v{version}")
|
|
56
|
+
|
|
57
|
+
print(f"version {version} is consistent")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
if __name__ == "__main__":
|
|
61
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
for version in 3.9 3.10 3.11 3.12; do
|
|
5
|
+
echo "Testing openbot-sdk on Python ${version}"
|
|
6
|
+
uv run --isolated --python "${version}" --extra dev pytest -q
|
|
7
|
+
done
|
|
8
|
+
|
|
9
|
+
echo "Running static checks against the Python 3.9 compatibility target"
|
|
10
|
+
uv run --isolated --python 3.9 --extra dev ruff check .
|
|
11
|
+
uv run --isolated --python 3.9 --extra dev mypy src
|
|
@@ -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
|
+
]
|
|
@@ -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
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import respx
|
|
5
|
+
from httpx import ConnectError, Request, Response
|
|
6
|
+
|
|
7
|
+
import openbot_sdk
|
|
8
|
+
from openbot_sdk import AuthenticationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def mock_api() -> respx.MockRouter:
|
|
13
|
+
with respx.mock(base_url="https://api.openbot.ai/v1") as router:
|
|
14
|
+
yield router
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_client_requires_api_key() -> None:
|
|
18
|
+
os.environ.pop("OPENBOT_API_KEY", None)
|
|
19
|
+
with pytest.raises(AuthenticationError):
|
|
20
|
+
openbot_sdk.Client()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_client_accepts_api_key_argument() -> None:
|
|
24
|
+
client = openbot_sdk.Client(api_key="test-key")
|
|
25
|
+
assert client.api_key == "test-key"
|
|
26
|
+
client.close()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_client_uses_environment_variable() -> None:
|
|
30
|
+
os.environ["OPENBOT_API_KEY"] = "env-key"
|
|
31
|
+
client = openbot_sdk.Client()
|
|
32
|
+
assert client.api_key == "env-key"
|
|
33
|
+
client.close()
|
|
34
|
+
os.environ.pop("OPENBOT_API_KEY", None)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_client_request_success(mock_api: respx.MockRouter) -> None:
|
|
38
|
+
mock_api.get("/me").respond(200, json={"key_id": "key_123", "scopes": ["api:invoke"]})
|
|
39
|
+
|
|
40
|
+
client = openbot_sdk.Client(api_key="test-key")
|
|
41
|
+
data = client.request("GET", "/me")
|
|
42
|
+
|
|
43
|
+
assert data["key_id"] == "key_123"
|
|
44
|
+
client.close()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_client_exposes_only_platform_resources() -> None:
|
|
48
|
+
client = openbot_sdk.Client(api_key="test-key")
|
|
49
|
+
assert not hasattr(client, "bench")
|
|
50
|
+
assert not hasattr(client, "synth")
|
|
51
|
+
assert not hasattr(client, "data")
|
|
52
|
+
client.close()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_client_request_raises_api_error(mock_api: respx.MockRouter) -> None:
|
|
56
|
+
mock_api.get("/unknown").respond(404, text="Not found")
|
|
57
|
+
|
|
58
|
+
client = openbot_sdk.Client(api_key="test-key")
|
|
59
|
+
with pytest.raises(openbot_sdk.APIError) as exc_info:
|
|
60
|
+
client._request("GET", "/unknown")
|
|
61
|
+
|
|
62
|
+
assert exc_info.value.status_code == 404
|
|
63
|
+
client.close()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_client_exposes_structured_api_error(mock_api: respx.MockRouter) -> None:
|
|
67
|
+
mock_api.get("/status").respond(
|
|
68
|
+
409,
|
|
69
|
+
json={
|
|
70
|
+
"error": {
|
|
71
|
+
"code": "conflict",
|
|
72
|
+
"message": "The request conflicts with current state",
|
|
73
|
+
"retryable": False,
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
client = openbot_sdk.Client(api_key="test-key")
|
|
78
|
+
|
|
79
|
+
with pytest.raises(openbot_sdk.APIError) as exc_info:
|
|
80
|
+
client.request("GET", "/status")
|
|
81
|
+
|
|
82
|
+
assert exc_info.value.status_code == 409
|
|
83
|
+
assert exc_info.value.code == "conflict"
|
|
84
|
+
assert exc_info.value.retryable is False
|
|
85
|
+
client.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_client_request_bytes(mock_api: respx.MockRouter) -> None:
|
|
89
|
+
mock_api.get("/artifact").respond(200, content=b"artifact-bytes")
|
|
90
|
+
client = openbot_sdk.Client(api_key="test-key")
|
|
91
|
+
|
|
92
|
+
assert client.request_bytes("GET", "/artifact") == b"artifact-bytes"
|
|
93
|
+
client.close()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_closed_client_rejects_requests() -> None:
|
|
97
|
+
client = openbot_sdk.Client(api_key="test-key")
|
|
98
|
+
client.close()
|
|
99
|
+
|
|
100
|
+
with pytest.raises(openbot_sdk.ClientClosedError):
|
|
101
|
+
client.request("GET", "/status")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_client_rejects_insecure_base_url() -> None:
|
|
105
|
+
with pytest.raises(ValueError, match="HTTPS"):
|
|
106
|
+
openbot_sdk.Client(api_key="test-key", base_url="http://api.example.test/v1")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_client_allows_explicit_local_http_for_testing() -> None:
|
|
110
|
+
client = openbot_sdk.Client(
|
|
111
|
+
api_key="test-key",
|
|
112
|
+
base_url="http://127.0.0.1:8787/v1",
|
|
113
|
+
allow_insecure_http=True,
|
|
114
|
+
)
|
|
115
|
+
client.close()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_client_wraps_network_errors(mock_api: respx.MockRouter) -> None:
|
|
119
|
+
mock_api.get("/me").mock(
|
|
120
|
+
side_effect=ConnectError("offline", request=Request("GET", "https://api.openbot.ai"))
|
|
121
|
+
)
|
|
122
|
+
client = openbot_sdk.Client(api_key="test-key", max_retries=0)
|
|
123
|
+
|
|
124
|
+
with pytest.raises(openbot_sdk.NetworkError, match="offline"):
|
|
125
|
+
client._request("GET", "/me")
|
|
126
|
+
client.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_client_rejects_invalid_success_payload(mock_api: respx.MockRouter) -> None:
|
|
130
|
+
mock_api.get("/me").respond(200, text="not-json")
|
|
131
|
+
client = openbot_sdk.Client(api_key="test-key")
|
|
132
|
+
|
|
133
|
+
with pytest.raises(openbot_sdk.APIResponseError, match="non-JSON"):
|
|
134
|
+
client._request("GET", "/me")
|
|
135
|
+
client.close()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_client_retries_idempotent_request(mock_api: respx.MockRouter) -> None:
|
|
139
|
+
route = mock_api.get("/me")
|
|
140
|
+
route.side_effect = [
|
|
141
|
+
Response(503, text="busy"),
|
|
142
|
+
Response(200, json={"key_id": "key_123", "scopes": ["api:invoke"]}),
|
|
143
|
+
]
|
|
144
|
+
client = openbot_sdk.Client(api_key="test-key", retry_backoff=0)
|
|
145
|
+
|
|
146
|
+
data = client._request("GET", "/me")
|
|
147
|
+
|
|
148
|
+
assert data["key_id"] == "key_123"
|
|
149
|
+
assert route.call_count == 2
|
|
150
|
+
client.close()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_keyed_mutation_does_not_retry_a_burned_key_after_502(
|
|
154
|
+
mock_api: respx.MockRouter,
|
|
155
|
+
) -> None:
|
|
156
|
+
route = mock_api.post("/invoke/judge")
|
|
157
|
+
route.side_effect = [
|
|
158
|
+
Response(502, json={"error": {"code": "upstream_error", "message": "HTTP 500"}}),
|
|
159
|
+
Response(409, json={"error": {"code": "upstream_error", "message": "failed"}}),
|
|
160
|
+
]
|
|
161
|
+
client = openbot_sdk.Client(api_key="test-key", retry_backoff=0)
|
|
162
|
+
|
|
163
|
+
with pytest.raises(openbot_sdk.APIError) as exc_info:
|
|
164
|
+
client.request("POST", "/invoke/judge", json={}, headers={"Idempotency-Key": "key-00001"})
|
|
165
|
+
|
|
166
|
+
assert exc_info.value.status_code == 502
|
|
167
|
+
assert exc_info.value.code == "upstream_error"
|
|
168
|
+
assert route.call_count == 1
|
|
169
|
+
client.close()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_keyed_mutation_waits_for_an_in_progress_invocation(
|
|
173
|
+
mock_api: respx.MockRouter,
|
|
174
|
+
) -> None:
|
|
175
|
+
route = mock_api.post("/invoke/judge")
|
|
176
|
+
route.side_effect = [
|
|
177
|
+
ConnectError("reset"),
|
|
178
|
+
Response(409, json={"error": {"code": "invocation_in_progress", "message": "busy"}}),
|
|
179
|
+
Response(200, json={"verdict": "accept"}),
|
|
180
|
+
]
|
|
181
|
+
client = openbot_sdk.Client(api_key="test-key", retry_backoff=0)
|
|
182
|
+
|
|
183
|
+
data = client.request(
|
|
184
|
+
"POST", "/invoke/judge", json={}, headers={"Idempotency-Key": "key-00001"}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
assert data == {"verdict": "accept"}
|
|
188
|
+
assert route.call_count == 3
|
|
189
|
+
assert {call.request.headers["Idempotency-Key"] for call in route.calls} == {"key-00001"}
|
|
190
|
+
client.close()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_keyed_mutation_retries_settlement_pending(mock_api: respx.MockRouter) -> None:
|
|
194
|
+
route = mock_api.post("/invoke/judge")
|
|
195
|
+
route.side_effect = [
|
|
196
|
+
Response(503, json={"error": {"code": "settlement_pending", "message": "retry"}}),
|
|
197
|
+
Response(200, json={"verdict": "accept"}),
|
|
198
|
+
]
|
|
199
|
+
client = openbot_sdk.Client(api_key="test-key", retry_backoff=0)
|
|
200
|
+
|
|
201
|
+
data = client.request(
|
|
202
|
+
"POST", "/invoke/judge", json={}, headers={"Idempotency-Key": "key-00001"}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
assert data == {"verdict": "accept"}
|
|
206
|
+
assert route.call_count == 2
|
|
207
|
+
client.close()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_keyed_mutation_does_not_retry_other_conflicts(mock_api: respx.MockRouter) -> None:
|
|
211
|
+
route = mock_api.post("/invoke/judge")
|
|
212
|
+
route.respond(409, json={"error": {"code": "idempotency_key_conflict", "message": "x"}})
|
|
213
|
+
client = openbot_sdk.Client(api_key="test-key", retry_backoff=0)
|
|
214
|
+
|
|
215
|
+
with pytest.raises(openbot_sdk.APIError) as exc_info:
|
|
216
|
+
client.request("POST", "/invoke/judge", json={}, headers={"Idempotency-Key": "key-00001"})
|
|
217
|
+
|
|
218
|
+
assert exc_info.value.code == "idempotency_key_conflict"
|
|
219
|
+
assert route.call_count == 1
|
|
220
|
+
client.close()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_idempotent_request_still_retries_502(mock_api: respx.MockRouter) -> None:
|
|
224
|
+
route = mock_api.get("/me")
|
|
225
|
+
route.side_effect = [Response(502, text="bad gateway"), Response(200, json={"ok": True})]
|
|
226
|
+
client = openbot_sdk.Client(api_key="test-key", retry_backoff=0)
|
|
227
|
+
|
|
228
|
+
assert client.request("GET", "/me") == {"ok": True}
|
|
229
|
+
assert route.call_count == 2
|
|
230
|
+
client.close()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from openbot_sdk._contract import openapi_compatibility_errors
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def required_paths() -> dict[str, dict[str, object]]:
|
|
5
|
+
return {
|
|
6
|
+
"/v1/me": {"get": {}},
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_neutral_sdk_accepts_api_context_contract() -> None:
|
|
11
|
+
spec = {
|
|
12
|
+
"paths": required_paths(),
|
|
13
|
+
"components": {
|
|
14
|
+
"securitySchemes": {
|
|
15
|
+
"ApiKeyAuth": {"type": "http", "scheme": "bearer"},
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
assert openapi_compatibility_errors(spec) == []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_neutral_sdk_rejects_removed_product_paths() -> None:
|
|
23
|
+
spec = {
|
|
24
|
+
"paths": {
|
|
25
|
+
**required_paths(),
|
|
26
|
+
"/v1/bench/rollouts": {"post": {}},
|
|
27
|
+
"/v1/synth/jobs": {"post": {}},
|
|
28
|
+
},
|
|
29
|
+
"components": {
|
|
30
|
+
"securitySchemes": {
|
|
31
|
+
"ApiKeyAuth": {"type": "http", "scheme": "bearer"},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
assert openapi_compatibility_errors(spec) == [
|
|
36
|
+
"removed product path is still published: /v1/bench/rollouts",
|
|
37
|
+
"removed product path is still published: /v1/synth/jobs",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_sdk_requires_the_api_key_context_route() -> None:
|
|
42
|
+
errors = openapi_compatibility_errors({
|
|
43
|
+
"paths": {},
|
|
44
|
+
"components": {"securitySchemes": {"Bearer": {"type": "http", "scheme": "bearer"}}},
|
|
45
|
+
})
|
|
46
|
+
assert errors == ["GET /v1/me is required for API-key context probing"]
|