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.
@@ -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,6 @@
1
+ # Documentation
2
+
3
+ - [Getting started](getting-started.md)
4
+ - [API reference](api-reference.md)
5
+
6
+ The live OpenBot OpenAPI document remains the endpoint source of truth.
@@ -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,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"
@@ -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"]
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import version
2
+
3
+ import openbot_sdk
4
+
5
+
6
+ def test_runtime_version_matches_package_metadata() -> None:
7
+ assert openbot_sdk.__version__ == version("openbot-sdk")