orkestr 0.1.0a1__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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Orkestr
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,201 @@
1
+ Metadata-Version: 2.4
2
+ Name: orkestr
3
+ Version: 0.1.0a1
4
+ Summary: EU-hosted sandbox VMs for AI agents - official Python SDK
5
+ Author-email: Orkestr <hello@orkestr.eu>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://orkestr.eu/sandboxes
8
+ Project-URL: Documentation, https://orkestr.eu/docs
9
+ Keywords: orkestr,sandbox,ai,agents,code-execution,eu
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: httpx>=0.27
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest>=8.0; extra == "dev"
24
+ Requires-Dist: respx>=0.21; extra == "dev"
25
+ Requires-Dist: ruff>=0.6; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # orkestr Python SDK
29
+
30
+ EU-hosted sandbox VMs for AI agents. Type-safe Python client for the
31
+ `api.orkestr.eu/v1/sandboxes` REST API.
32
+
33
+ > **Status: private beta pre-release (`0.1.0a1`).** The client surface is
34
+ > stable and tested, but the `api.orkestr.eu/v1/sandboxes` backend is not
35
+ > live yet - every endpoint returns `501 Not Implemented` until the public
36
+ > beta. This release exists so you can pin the package and review the API.
37
+ > See `docs/sandbox-mvp-plan.md` in the orkestr repo for the timeline.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install --pre orkestr
43
+ ```
44
+
45
+ Requires Python 3.10+. The `--pre` flag is needed while the SDK is a
46
+ pre-release; it is dropped once `0.1.0` ships with the public beta.
47
+
48
+ ## Authenticate
49
+
50
+ Mint an API token in the orkestr console with the `sandboxes:write` scope.
51
+
52
+ ```bash
53
+ export ORKESTR_API_KEY="sp_live_..."
54
+ ```
55
+
56
+ The SDK picks the key up from `ORKESTR_API_KEY` or you pass it explicitly.
57
+
58
+ ## Quick start
59
+
60
+ One-shot execution inside a fresh sandbox. The `with` block auto-terminates
61
+ the sandbox on exit, preventing runaway costs if the caller crashes.
62
+
63
+ ```python
64
+ from orkestr import Sandbox
65
+
66
+ with Sandbox.create(template="python-3.12") as sbx:
67
+ sbx.files.write("/workspace/main.py", "print(sum(range(1_000_000)))")
68
+ result = sbx.exec("python /workspace/main.py")
69
+ print(result.stdout) # 499999500000
70
+ print(result.duration_ms) # ~120
71
+ ```
72
+
73
+ ## API
74
+
75
+ ### Create a sandbox
76
+
77
+ ```python
78
+ sbx = Sandbox.create(
79
+ template="python-3.12", # one of the templates listed below
80
+ cpu=1.0, # vCPU (default 1, plan-capped)
81
+ memory_mb=2048, # RAM (default 2048, plan-capped)
82
+ network="off", # "off" | "restricted" | "open"
83
+ timeout_seconds=600, # auto-terminate after this many seconds
84
+ env={"OPENAI_API_KEY": "sk-..."},
85
+ metadata={"agent_run": "r_123"},
86
+ region="fsn1", # "fsn1" (DE) | "hel1" (FI) | None for auto
87
+ api_key=None, # falls back to ORKESTR_API_KEY
88
+ )
89
+ print(sbx.id) # "sbx_01HXYZ..."
90
+ print(sbx.status) # "running"
91
+ ```
92
+
93
+ ### Templates
94
+
95
+ | Template | Description |
96
+ |---------------------|------------------------------------------|
97
+ | `python-3.12` | CPython 3.12 with pip and common libs |
98
+ | `python-3.12-bare` | CPython 3.12 only, faster start |
99
+ | `node-22` | Node 22 with npm |
100
+ | `ubuntu-24.04` | Minimal Ubuntu shell environment |
101
+
102
+ ### Run a command
103
+
104
+ ```python
105
+ result = sbx.exec("python /workspace/main.py", timeout_seconds=60)
106
+ result.stdout # str
107
+ result.stderr # str
108
+ result.exit_code # int
109
+ result.duration_ms # int
110
+ ```
111
+
112
+ ### Stream a long command
113
+
114
+ ```python
115
+ for chunk in sbx.exec_stream("python long_task.py"):
116
+ if chunk.stream == "stdout":
117
+ print(chunk.data, end="", flush=True)
118
+ else:
119
+ print(chunk.data, end="", flush=True, file=sys.stderr)
120
+ ```
121
+
122
+ ### Files
123
+
124
+ ```python
125
+ sbx.files.write("/workspace/data.json", '{"x": 1}')
126
+ sbx.files.write_bytes("/workspace/blob.bin", b"\x00\x01\x02")
127
+
128
+ content = sbx.files.read("/workspace/out.txt") # returns str
129
+ raw = sbx.files.read_bytes("/workspace/blob.bin") # returns bytes
130
+
131
+ for entry in sbx.files.list("/workspace"):
132
+ print(entry.name, entry.is_dir, entry.size)
133
+
134
+ sbx.files.delete("/workspace/out.txt")
135
+ ```
136
+
137
+ ### Pause + resume
138
+
139
+ Pausing snapshots the sandbox memory + disk and stops the compute meter.
140
+ Resume restores it on the same or a different host. `pause()` returns
141
+ the sandbox id; persist it across processes and pass to `Sandbox.resume`.
142
+
143
+ ```python
144
+ sbx = Sandbox.create(template="node-22", network="restricted")
145
+ sandbox_id = sbx.pause()
146
+ # ... minutes or hours later, from any worker:
147
+ sbx = Sandbox.resume(sandbox_id)
148
+ ```
149
+
150
+ Snapshot retention is plan-capped (free: 1, pro: 3, team: 10). Calling
151
+ `pause()` over the cap raises `SnapshotCapReached`.
152
+
153
+ ### Terminate
154
+
155
+ Context-manager exit calls `terminate()`. Outside `with`:
156
+
157
+ ```python
158
+ sbx.terminate()
159
+ ```
160
+
161
+ After `terminate()` the sandbox row stays in your account history but
162
+ the VM and any in-memory state are gone.
163
+
164
+ ### List your sandboxes
165
+
166
+ ```python
167
+ for sbx in Sandbox.list(status="running"):
168
+ print(sbx.id, sbx.template, sbx.created_at)
169
+ ```
170
+
171
+ ## Errors
172
+
173
+ All SDK errors inherit from `orkestr.OrkestrError`.
174
+
175
+ | Exception | When |
176
+ |--------------------------|---------------------------------------------------|
177
+ | `AuthError` | Missing / invalid / expired API key, or scope mismatch |
178
+ | `RateLimitError` | Plan rate limit hit |
179
+ | `PlanLimitError` | Sandbox limit, concurrent limit, snapshot cap |
180
+ | `SandboxNotFound` | `sandbox_id` doesn't exist or isn't yours |
181
+ | `SandboxNotReady` | Operation called on a paused / terminated sandbox |
182
+ | `ExecTimeout` | `exec()` exceeded `timeout_seconds` |
183
+ | `NetworkPolicyError` | Command tried to reach a host the policy blocks |
184
+ | `OrkestrError` | Any other API-level error |
185
+
186
+ ## Async support
187
+
188
+ The MVP SDK is sync only. Async (`AsyncSandbox`) lands in v0.2.0 if a
189
+ design partner blocks on it; the wire format is identical.
190
+
191
+ ## Versioning
192
+
193
+ Follows [Semantic Versioning](https://semver.org). The SDK targets the
194
+ `/v1/sandboxes` API; bumps to v1 of the API are non-breaking for SDK
195
+ callers. v2 of the API will require an SDK major.
196
+
197
+ ## Links
198
+
199
+ - [Sandbox waitlist](https://orkestr.eu/sandboxes)
200
+ - [Public API docs](https://orkestr.eu/docs)
201
+ - Questions and bug reports during the private beta: <hello@orkestr.eu>
@@ -0,0 +1,174 @@
1
+ # orkestr Python SDK
2
+
3
+ EU-hosted sandbox VMs for AI agents. Type-safe Python client for the
4
+ `api.orkestr.eu/v1/sandboxes` REST API.
5
+
6
+ > **Status: private beta pre-release (`0.1.0a1`).** The client surface is
7
+ > stable and tested, but the `api.orkestr.eu/v1/sandboxes` backend is not
8
+ > live yet - every endpoint returns `501 Not Implemented` until the public
9
+ > beta. This release exists so you can pin the package and review the API.
10
+ > See `docs/sandbox-mvp-plan.md` in the orkestr repo for the timeline.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install --pre orkestr
16
+ ```
17
+
18
+ Requires Python 3.10+. The `--pre` flag is needed while the SDK is a
19
+ pre-release; it is dropped once `0.1.0` ships with the public beta.
20
+
21
+ ## Authenticate
22
+
23
+ Mint an API token in the orkestr console with the `sandboxes:write` scope.
24
+
25
+ ```bash
26
+ export ORKESTR_API_KEY="sp_live_..."
27
+ ```
28
+
29
+ The SDK picks the key up from `ORKESTR_API_KEY` or you pass it explicitly.
30
+
31
+ ## Quick start
32
+
33
+ One-shot execution inside a fresh sandbox. The `with` block auto-terminates
34
+ the sandbox on exit, preventing runaway costs if the caller crashes.
35
+
36
+ ```python
37
+ from orkestr import Sandbox
38
+
39
+ with Sandbox.create(template="python-3.12") as sbx:
40
+ sbx.files.write("/workspace/main.py", "print(sum(range(1_000_000)))")
41
+ result = sbx.exec("python /workspace/main.py")
42
+ print(result.stdout) # 499999500000
43
+ print(result.duration_ms) # ~120
44
+ ```
45
+
46
+ ## API
47
+
48
+ ### Create a sandbox
49
+
50
+ ```python
51
+ sbx = Sandbox.create(
52
+ template="python-3.12", # one of the templates listed below
53
+ cpu=1.0, # vCPU (default 1, plan-capped)
54
+ memory_mb=2048, # RAM (default 2048, plan-capped)
55
+ network="off", # "off" | "restricted" | "open"
56
+ timeout_seconds=600, # auto-terminate after this many seconds
57
+ env={"OPENAI_API_KEY": "sk-..."},
58
+ metadata={"agent_run": "r_123"},
59
+ region="fsn1", # "fsn1" (DE) | "hel1" (FI) | None for auto
60
+ api_key=None, # falls back to ORKESTR_API_KEY
61
+ )
62
+ print(sbx.id) # "sbx_01HXYZ..."
63
+ print(sbx.status) # "running"
64
+ ```
65
+
66
+ ### Templates
67
+
68
+ | Template | Description |
69
+ |---------------------|------------------------------------------|
70
+ | `python-3.12` | CPython 3.12 with pip and common libs |
71
+ | `python-3.12-bare` | CPython 3.12 only, faster start |
72
+ | `node-22` | Node 22 with npm |
73
+ | `ubuntu-24.04` | Minimal Ubuntu shell environment |
74
+
75
+ ### Run a command
76
+
77
+ ```python
78
+ result = sbx.exec("python /workspace/main.py", timeout_seconds=60)
79
+ result.stdout # str
80
+ result.stderr # str
81
+ result.exit_code # int
82
+ result.duration_ms # int
83
+ ```
84
+
85
+ ### Stream a long command
86
+
87
+ ```python
88
+ for chunk in sbx.exec_stream("python long_task.py"):
89
+ if chunk.stream == "stdout":
90
+ print(chunk.data, end="", flush=True)
91
+ else:
92
+ print(chunk.data, end="", flush=True, file=sys.stderr)
93
+ ```
94
+
95
+ ### Files
96
+
97
+ ```python
98
+ sbx.files.write("/workspace/data.json", '{"x": 1}')
99
+ sbx.files.write_bytes("/workspace/blob.bin", b"\x00\x01\x02")
100
+
101
+ content = sbx.files.read("/workspace/out.txt") # returns str
102
+ raw = sbx.files.read_bytes("/workspace/blob.bin") # returns bytes
103
+
104
+ for entry in sbx.files.list("/workspace"):
105
+ print(entry.name, entry.is_dir, entry.size)
106
+
107
+ sbx.files.delete("/workspace/out.txt")
108
+ ```
109
+
110
+ ### Pause + resume
111
+
112
+ Pausing snapshots the sandbox memory + disk and stops the compute meter.
113
+ Resume restores it on the same or a different host. `pause()` returns
114
+ the sandbox id; persist it across processes and pass to `Sandbox.resume`.
115
+
116
+ ```python
117
+ sbx = Sandbox.create(template="node-22", network="restricted")
118
+ sandbox_id = sbx.pause()
119
+ # ... minutes or hours later, from any worker:
120
+ sbx = Sandbox.resume(sandbox_id)
121
+ ```
122
+
123
+ Snapshot retention is plan-capped (free: 1, pro: 3, team: 10). Calling
124
+ `pause()` over the cap raises `SnapshotCapReached`.
125
+
126
+ ### Terminate
127
+
128
+ Context-manager exit calls `terminate()`. Outside `with`:
129
+
130
+ ```python
131
+ sbx.terminate()
132
+ ```
133
+
134
+ After `terminate()` the sandbox row stays in your account history but
135
+ the VM and any in-memory state are gone.
136
+
137
+ ### List your sandboxes
138
+
139
+ ```python
140
+ for sbx in Sandbox.list(status="running"):
141
+ print(sbx.id, sbx.template, sbx.created_at)
142
+ ```
143
+
144
+ ## Errors
145
+
146
+ All SDK errors inherit from `orkestr.OrkestrError`.
147
+
148
+ | Exception | When |
149
+ |--------------------------|---------------------------------------------------|
150
+ | `AuthError` | Missing / invalid / expired API key, or scope mismatch |
151
+ | `RateLimitError` | Plan rate limit hit |
152
+ | `PlanLimitError` | Sandbox limit, concurrent limit, snapshot cap |
153
+ | `SandboxNotFound` | `sandbox_id` doesn't exist or isn't yours |
154
+ | `SandboxNotReady` | Operation called on a paused / terminated sandbox |
155
+ | `ExecTimeout` | `exec()` exceeded `timeout_seconds` |
156
+ | `NetworkPolicyError` | Command tried to reach a host the policy blocks |
157
+ | `OrkestrError` | Any other API-level error |
158
+
159
+ ## Async support
160
+
161
+ The MVP SDK is sync only. Async (`AsyncSandbox`) lands in v0.2.0 if a
162
+ design partner blocks on it; the wire format is identical.
163
+
164
+ ## Versioning
165
+
166
+ Follows [Semantic Versioning](https://semver.org). The SDK targets the
167
+ `/v1/sandboxes` API; bumps to v1 of the API are non-breaking for SDK
168
+ callers. v2 of the API will require an SDK major.
169
+
170
+ ## Links
171
+
172
+ - [Sandbox waitlist](https://orkestr.eu/sandboxes)
173
+ - [Public API docs](https://orkestr.eu/docs)
174
+ - Questions and bug reports during the private beta: <hello@orkestr.eu>
@@ -0,0 +1,59 @@
1
+ """orkestr Python SDK.
2
+
3
+ EU-hosted sandbox VMs for AI agents. See README.md or
4
+ https://orkestr.eu/sandboxes for the full API.
5
+
6
+ from orkestr import Sandbox
7
+
8
+ with Sandbox.create(template="python-3.12") as sbx:
9
+ sbx.files.write("/workspace/main.py", "print(2+2)")
10
+ result = sbx.exec("python /workspace/main.py")
11
+ print(result.stdout)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ._client import OrkestrClient, get_default_client, set_default_client
17
+ from .errors import (
18
+ APIError,
19
+ AuthError,
20
+ ConnectionError,
21
+ ExecKilled,
22
+ ExecTimeout,
23
+ NetworkPolicyError,
24
+ OrkestrError,
25
+ PlanLimitError,
26
+ RateLimitError,
27
+ SandboxNotFound,
28
+ SandboxNotReady,
29
+ SnapshotCapReached,
30
+ )
31
+ from .sandbox import ExecChunk, ExecResult, FileEntry, Sandbox
32
+
33
+ __version__ = "0.1.0a1"
34
+
35
+ __all__ = [
36
+ # Core
37
+ "Sandbox",
38
+ "ExecResult",
39
+ "ExecChunk",
40
+ "FileEntry",
41
+ # Client (advanced)
42
+ "OrkestrClient",
43
+ "get_default_client",
44
+ "set_default_client",
45
+ # Errors
46
+ "OrkestrError",
47
+ "AuthError",
48
+ "RateLimitError",
49
+ "PlanLimitError",
50
+ "SnapshotCapReached",
51
+ "SandboxNotFound",
52
+ "SandboxNotReady",
53
+ "ExecTimeout",
54
+ "ExecKilled",
55
+ "NetworkPolicyError",
56
+ "APIError",
57
+ "ConnectionError",
58
+ "__version__",
59
+ ]
@@ -0,0 +1,239 @@
1
+ """HTTP client for the orkestr API.
2
+
3
+ Wraps httpx.Client with bearer-token auth, error mapping, and a
4
+ configurable base URL. Most users go through Sandbox / Sandbox.create
5
+ rather than constructing this directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from collections.abc import Iterator
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from .errors import (
18
+ AuthError,
19
+ OrkestrError,
20
+ from_response,
21
+ )
22
+ from .errors import (
23
+ ConnectionError as OrkestrConnectionError,
24
+ )
25
+
26
+ DEFAULT_BASE_URL = "https://api.orkestr.eu"
27
+ DEFAULT_TIMEOUT_SECONDS = 30.0
28
+ _VERSION = "0.1.0"
29
+ _USER_AGENT = f"orkestr-python/{_VERSION}"
30
+
31
+
32
+ class OrkestrClient:
33
+ """Thin HTTPX wrapper with auth and error mapping.
34
+
35
+ Constructed lazily by the SDK; users usually only see it through
36
+ ``Sandbox.create(api_key=...)``. Holding a single client and reusing
37
+ it lets httpx keep its connection pool warm across calls.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str | None = None,
43
+ base_url: str | None = None,
44
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
45
+ ) -> None:
46
+ resolved_key = api_key or os.environ.get("ORKESTR_API_KEY")
47
+ if not resolved_key:
48
+ raise AuthError(
49
+ "ORKESTR_API_KEY is not set. Pass api_key=... or export "
50
+ "ORKESTR_API_KEY before calling the SDK."
51
+ )
52
+
53
+ resolved_base = (
54
+ base_url
55
+ or os.environ.get("ORKESTR_BASE_URL")
56
+ or DEFAULT_BASE_URL
57
+ ).rstrip("/")
58
+
59
+ self._api_key = resolved_key
60
+ self._base_url = resolved_base
61
+ self._http = httpx.Client(
62
+ base_url=resolved_base,
63
+ timeout=timeout,
64
+ headers={
65
+ "Authorization": f"Bearer {resolved_key}",
66
+ "User-Agent": _USER_AGENT,
67
+ "Accept": "application/json",
68
+ },
69
+ )
70
+
71
+ # ------------------------------------------------------------------
72
+ # Lifecycle
73
+ # ------------------------------------------------------------------
74
+
75
+ def close(self) -> None:
76
+ self._http.close()
77
+
78
+ def __enter__(self) -> OrkestrClient:
79
+ return self
80
+
81
+ def __exit__(self, *args: object) -> None:
82
+ self.close()
83
+
84
+ # ------------------------------------------------------------------
85
+ # Request helpers
86
+ # ------------------------------------------------------------------
87
+
88
+ def request(
89
+ self,
90
+ method: str,
91
+ path: str,
92
+ *,
93
+ json_body: dict[str, Any] | None = None,
94
+ params: dict[str, Any] | None = None,
95
+ ) -> dict[str, Any] | None:
96
+ """Issue a request and return the parsed JSON body (or None for 204).
97
+
98
+ Non-2xx responses are mapped to the right SDK exception via
99
+ errors.from_response. Transport-level failures (DNS, connection
100
+ refused, timeout before bytes arrive) become OrkestrConnectionError.
101
+ """
102
+ try:
103
+ response = self._http.request(
104
+ method, path, json=json_body, params=params
105
+ )
106
+ except httpx.TimeoutException as exc:
107
+ raise OrkestrConnectionError(
108
+ f"Timeout connecting to {self._base_url}{path}"
109
+ ) from exc
110
+ except httpx.TransportError as exc:
111
+ raise OrkestrConnectionError(
112
+ f"Transport error reaching {self._base_url}: {exc}"
113
+ ) from exc
114
+
115
+ return self._handle_response(response)
116
+
117
+ def get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any] | None:
118
+ return self.request("GET", path, params=params)
119
+
120
+ def post(
121
+ self,
122
+ path: str,
123
+ *,
124
+ json_body: dict[str, Any] | None = None,
125
+ params: dict[str, Any] | None = None,
126
+ ) -> dict[str, Any] | None:
127
+ return self.request("POST", path, json_body=json_body, params=params)
128
+
129
+ def delete(
130
+ self,
131
+ path: str,
132
+ *,
133
+ params: dict[str, Any] | None = None,
134
+ ) -> dict[str, Any] | None:
135
+ return self.request("DELETE", path, params=params)
136
+
137
+ def stream(
138
+ self,
139
+ method: str,
140
+ path: str,
141
+ *,
142
+ json_body: dict[str, Any] | None = None,
143
+ ) -> Iterator[str]:
144
+ """Open a streaming response and yield raw decoded text lines.
145
+
146
+ Used by the streaming-exec path to parse SSE. Caller is responsible
147
+ for closing iteration; the underlying response is closed when the
148
+ generator exits.
149
+ """
150
+ try:
151
+ with self._http.stream(
152
+ method, path, json=json_body
153
+ ) as response:
154
+ if response.status_code >= 400:
155
+ # Drain the body so we can map the error. Streaming
156
+ # responses with an error usually carry a small JSON
157
+ # body, so the cost is negligible.
158
+ body_text = response.read().decode(errors="replace")
159
+ self._raise_for_status(response, body_text=body_text)
160
+ yield from response.iter_lines()
161
+ except httpx.TimeoutException as exc:
162
+ raise OrkestrConnectionError(
163
+ f"Timeout streaming from {self._base_url}{path}"
164
+ ) from exc
165
+ except httpx.TransportError as exc:
166
+ raise OrkestrConnectionError(
167
+ f"Transport error reaching {self._base_url}: {exc}"
168
+ ) from exc
169
+
170
+ # ------------------------------------------------------------------
171
+ # Internals
172
+ # ------------------------------------------------------------------
173
+
174
+ def _handle_response(self, response: httpx.Response) -> dict[str, Any] | None:
175
+ if response.status_code == 204:
176
+ return None
177
+ if 200 <= response.status_code < 300:
178
+ if not response.content:
179
+ return None
180
+ try:
181
+ return response.json()
182
+ except json.JSONDecodeError as exc:
183
+ raise OrkestrError(
184
+ f"Non-JSON 2xx response from {response.url}: "
185
+ f"{response.text[:200]}",
186
+ status_code=response.status_code,
187
+ ) from exc
188
+
189
+ self._raise_for_status(response)
190
+ return None # unreachable
191
+
192
+ def _raise_for_status(
193
+ self,
194
+ response: httpx.Response,
195
+ *,
196
+ body_text: str | None = None,
197
+ ) -> None:
198
+ request_id = response.headers.get("x-request-id")
199
+ text = body_text if body_text is not None else response.text
200
+ body: dict[str, Any] | None
201
+ try:
202
+ body = json.loads(text) if text else None
203
+ except json.JSONDecodeError:
204
+ body = None
205
+ raise from_response(response.status_code, body, request_id=request_id)
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Default client management
210
+ # ---------------------------------------------------------------------------
211
+
212
+ _default_client: OrkestrClient | None = None
213
+
214
+
215
+ def get_default_client() -> OrkestrClient:
216
+ """Lazily create the module-level client used by Sandbox classmethods.
217
+
218
+ Constructed on first use from ``ORKESTR_API_KEY`` and ``ORKESTR_BASE_URL``.
219
+ Tests can override via ``set_default_client``.
220
+ """
221
+ global _default_client
222
+ if _default_client is None:
223
+ _default_client = OrkestrClient()
224
+ return _default_client
225
+
226
+
227
+ def set_default_client(client: OrkestrClient | None) -> None:
228
+ """Override (or clear) the module-level client. Test seam."""
229
+ global _default_client
230
+ _default_client = client
231
+
232
+
233
+ __all__ = [
234
+ "OrkestrClient",
235
+ "DEFAULT_BASE_URL",
236
+ "DEFAULT_TIMEOUT_SECONDS",
237
+ "get_default_client",
238
+ "set_default_client",
239
+ ]