From 32bfd6532a8790f989c076c3de3b14ae8ca2c7c0 Mon Sep 17 00:00:00 2001 From: Devin Date: Thu, 22 Oct 2026 13:19:16 -0700 Subject: [PATCH] Add GPU sandbox provisioning, image builds, and computer actions --- README.md | 86 ++++ .../client/managers/async_manager/sandbox.py | 246 ++++++++- .../managers/sandboxes/image_resolution.py | 17 +- .../client/managers/sandboxes/shared.py | 7 + .../client/managers/sync_manager/sandbox.py | 238 ++++++++- hyperbrowser/models/__init__.py | 6 + hyperbrowser/models/sandbox.py | 86 +++- hyperbrowser/types/__init__.py | 4 + hyperbrowser/types/sandbox.py | 27 +- tests/fixtures/gpu_contract.json | 182 +++++++ tests/sandbox/e2e/test_gpu.py | 78 +++ tests/test_gpu_sandboxes.py | 476 ++++++++++++++++++ tests/typecheck/invalid_requests.py | 12 + tests/typecheck/valid_requests.py | 26 + 14 files changed, 1456 insertions(+), 35 deletions(-) create mode 100644 tests/fixtures/gpu_contract.json create mode 100644 tests/sandbox/e2e/test_gpu.py create mode 100644 tests/test_gpu_sandboxes.py diff --git a/README.md b/README.md index 5b5b730f..ca3b6a71 100644 --- a/README.md +++ b/README.md @@ -1042,3 +1042,89 @@ their explicit-name behavior and continue to report build conflicts directly. ## License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +### GPU sandboxes + +Build a GPU-ready image from a remote Dockerfile context, then select a GPU when +creating the sandbox. GPU image publication happens after the ordinary build; +`enable_gpu_builds=True` makes the build helpers wait for `gpu_image_ready` and +keeps CPU-only images out of GPU cache reuse. Local/prebuilt Docker image imports +do not support GPU publication. + +```python +from hyperbrowser import Hyperbrowser + +client = Hyperbrowser() +image = client.sandboxes.get_or_build_image( + context_path="./gpu-image", # Your CUDA-compatible Dockerfile and context + enable_gpu_builds=True, +) +sandbox = client.sandboxes.create({ + "image_name": image.image_name, + "image_id": image.image_id, + "gpu": "H100", + "gpu_count": 2, + "preempt": False, # Require on-demand capacity; account enablement required + "region": "us", + "cpu": 8, + "memory_mib": 32768, + "disk_mib": 65536, + "exposed_ports": [{"port": 8000, "auth": True}], +}) +try: + # create() can return status='provisioning', runtime=None, and token=None. + sandbox.wait_until_ready(timeout=20 * 60) + result = sandbox.exec("nvidia-smi -L") + print(result.stdout) + sandbox.update_network({"allow_out": ["example.com"], "deny_out": []}) +finally: + sandbox.stop() +``` + +`gpu_count` defaults to one and accepts 1–8; the API checks that the requested +GPU, CPU, and memory combination is available. Omitted `preempt` (or `True`) +allows spot capacity. `False` requires on-demand capacity. GPU launches require +`region="us"`; `runtime_class="gvisor-cpu"` is inferred by the API. The disk is +quota-limited ephemeral storage, from 12 GiB (the default) up to 512 GiB, subject +to your plan and host capacity. Snapshots, volume mounts, and startup secrets are +not supported for GPU launches. + +`wait_until_ready()` returns the same handle, retries transient reads with +backoff, and reports failures with the sandbox ID and close reason. Its timeout +is independent of `timeout_minutes`, which controls the sandbox lifetime. +Timeout or cancellation stops only the wait. For synchronous cancellation, pass +a `threading.Event` as `cancel_event`; it is checked between HTTP reads. Async +clients support the same methods with `await`, and the waiting task can be +cancelled. After cancelling a wait, use the saved ID to inspect or stop the +sandbox. `get()` and `list()` can also return provisioning sandboxes; the list +API's existing `status="active"` filter includes provisioning requests. + +GPU handles expose `gpu_type`, `gpu_count`, `preempt`, `runtime_class`, +`capabilities`, `recording_enabled`, and `close_reason`. Existing files, commands, +PTY, exposed-port authentication, and network-update methods work after readiness. +`credits_used` and termination details remain available through `info()`. + +For a custom image that starts an X11 desktop and installs the receiver's input +and screenshot tools, use the sandbox runtime computer-action endpoint: + +```python +screenshot = sandbox.computer_action({"action": "screenshot"}) +sandbox.computer_action({"action": "click", "x": 300, "y": 200}) +sandbox.computer_action({"action": "type_text", "text": "Hello"}) +``` + +These calls belong inside the sandbox's active lifetime (before `stop()`). They +use the sandbox runtime token, including token refresh, and accept the existing +computer-action TypedDicts and legacy Pydantic request models. The image owns +display startup; runtime readiness does not imply desktop readiness. Set +`enable_recording=True` at creation to record a supported image's display and +retrieve the video through `client.sessions.get_video_recording_url(sandbox.id)`. + +GPU cache lookup verifies the API's latest 100 completed builds. An older image +may be rebuilt when its GPU readiness cannot be verified from that window. +For live validation, set `HYPERBROWSER_API_KEY` and +`HYPERBROWSER_GPU_IMAGE_NAME` (an already published image containing `nvidia-smi`), +then run `pytest tests/sandbox/e2e/test_gpu.py`. Optional `HYPERBROWSER_GPU_TYPE`, +`HYPERBROWSER_GPU_COUNT`, and `HYPERBROWSER_GPU_PREEMPT=false` select the GPU +configuration; `HYPERBROWSER_GPU_DESKTOP=1` also checks screenshots. These tests +create billable sandboxes and stop them in cleanup. diff --git a/hyperbrowser/client/managers/async_manager/sandbox.py b/hyperbrowser/client/managers/async_manager/sandbox.py index 5ac14d1b..751aaee7 100644 --- a/hyperbrowser/client/managers/async_manager/sandbox.py +++ b/hyperbrowser/client/managers/async_manager/sandbox.py @@ -1,11 +1,16 @@ import asyncio import functools import time +import math +from threading import Event from pathlib import Path from typing import Dict, List, Optional, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError +from ....models.computer_action import ComputerActionParams, ComputerActionResponse +from ....types.computer_action import ComputerActionParams as ComputerActionParamsDict +from .computer_action import _action_param_model from ....models.secret import SandboxResolvedSecret from ....models.sandbox import ( CompleteSandboxImageBuildParams, @@ -13,6 +18,8 @@ CreateSandboxImageBuildParams, ReuseSandboxDockerImageParams, SandboxDetail, + SandboxGpuType, + SandboxCapabilities, SandboxExecParams, SandboxExposeParams, SandboxExposeResult, @@ -91,7 +98,6 @@ docker_build_context_fingerprint, docker_image_digest, build_docker_image_from_dockerfile, - is_terminal_image_build_status, make_temp_docker_tag, merge_image_init, package_docker_build_context_manifest, @@ -214,6 +220,34 @@ def disk_mib(self): def timeout_minutes(self): return self._detail.timeout_minutes + @property + def gpu_type(self) -> Optional[SandboxGpuType]: + return self._detail.gpu_type + + @property + def gpu_count(self) -> Optional[int]: + return self._detail.gpu_count + + @property + def preempt(self) -> Optional[bool]: + return self._detail.preempt + + @property + def capabilities(self) -> Optional[SandboxCapabilities]: + return self._detail.capabilities + + @property + def runtime_class(self) -> Optional[str]: + return self._detail.runtime_class + + @property + def recording_enabled(self) -> Optional[bool]: + return self._detail.recording_enabled + + @property + def close_reason(self) -> Optional[str]: + return self._detail.close_reason + @property def exposed_ports(self): return self._detail.exposed_ports @@ -237,6 +271,115 @@ async def refresh(self) -> "SandboxHandle": await self.info() return self + async def computer_action( + self, params: Union[ComputerActionParamsDict, ComputerActionParams] + ) -> ComputerActionResponse: + """Run an action on the image's desktop using sandbox runtime authentication.""" + payload = dump_request(params, _action_param_model(params), name="params") + response = await self._transport.request_json( + "/computer-action", method="POST", json_body=payload + ) + return ComputerActionResponse(**response) + + async def wait_until_ready( + self, + *, + timeout: Optional[float] = 20 * 60, + poll_interval: float = 3.0, + cancel_event: Optional[Event] = None, + ) -> "SandboxHandle": + """Wait for a runtime without stopping the sandbox on timeout or cancellation. + + The interval backs off to 15 seconds. A sync cancellation event is checked + between reads; async callers can also cancel the awaiting task. + """ + if ( + not math.isfinite(poll_interval) + or poll_interval < 0 + or (timeout is not None and (not math.isfinite(timeout) or timeout < 0)) + ): + raise ValueError( + "timeout and poll_interval must be nonnegative finite seconds" + ) + if not self.id.strip(): + raise HyperbrowserError( + "Sandbox response is missing its ID", service="control" + ) + deadline = None if timeout is None else time.monotonic() + timeout + delay = min(poll_interval, 15.0) + while True: + if cancel_event is not None and cancel_event.is_set(): + raise HyperbrowserError( + f"Stopped waiting for sandbox {self.id}; the sandbox was not stopped", + code="wait_cancelled", + service="control", + details={"sandboxId": self.id}, + ) + if self.status in {"closed", "error", "close-error"}: + raise HyperbrowserError( + f"Sandbox {self.id} ended before becoming ready: {self._detail.close_reason or self.status}", + code="sandbox_not_running", + service="control", + details={ + "sandboxId": self.id, + "status": self.status, + "closeReason": self._detail.close_reason, + }, + ) + if ( + self.status == "active" + and self.runtime is not None + and self._detail.token + ): + return self + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise HyperbrowserError( + f"Timed out waiting for sandbox {self.id}; the sandbox was not stopped", + code="wait_timeout", + service="control", + details={"sandboxId": self.id}, + ) + try: + detail = await asyncio.wait_for( + self._service.get_detail( + self.id, + request_timeout=min(self._service.runtime_timeout, remaining) + if remaining is not None + else self._service.runtime_timeout, + retry=False, + ), + timeout=remaining, + ) + if detail.id != self.id: + raise HyperbrowserError( + "Sandbox response returned a different ID", service="control" + ) + self._hydrate(detail) + if self.status != "provisioning" and ( + self.status != "active" + or (self.runtime is not None and self._detail.token) + ): + continue + except asyncio.TimeoutError: + # The wait deadline includes the entire read, not each HTTP phase. + continue + except HyperbrowserError as error: + if not error.retryable: + raise + pause = ( + delay + if deadline is None + else min(delay, max(0, deadline - time.monotonic())) + ) + if cancel_event is None: + await asyncio.sleep(pause) + else: + pause_until = time.monotonic() + pause + while not cancel_event.is_set() and time.monotonic() < pause_until: + await asyncio.sleep(min(0.1, pause_until - time.monotonic())) + delay = min(delay * 2, 15.0) + async def connect(self) -> "SandboxHandle": await self.create_runtime_session(force_refresh=True) return self @@ -416,6 +559,13 @@ def _clear_runtime_session(self, status: Optional[str] = None) -> None: ) def _assert_runtime_available(self) -> None: + if self._detail.status == "provisioning": + raise HyperbrowserError( + f"Sandbox {self.id} is provisioning; call wait_until_ready() first", + status_code=409, + code="sandbox_not_ready", + service="runtime", + ) if self._detail.status in {"closed", "error", "close-error"}: raise HyperbrowserError( f"Sandbox {self.id} is not running", @@ -427,7 +577,7 @@ def _assert_runtime_available(self) -> None: @staticmethod def _to_runtime_session(detail: SandboxDetail) -> Optional[SandboxRuntimeSession]: - if not detail.token: + if detail.status != "active" or not detail.token or detail.runtime is None: return None return SandboxRuntimeSession( sandbox_id=detail.id, @@ -508,8 +658,20 @@ async def list_images( ) return SandboxImageListResponse(**payload) - async def find_ready_image(self, image_name: str) -> Optional[SandboxImageSummary]: + async def find_ready_image( + self, image_name: str, *, enable_gpu_builds: bool = False + ) -> Optional[SandboxImageSummary]: """Find an exact ready team image, including revisions awaiting backup.""" + gpu_ids = None + if enable_gpu_builds: + builds = await self.list_image_builds({"status": "completed", "limit": 100}) + gpu_ids = { + build.image_id + for build in builds.builds + if build.image_name == image_name and build.gpu_image_ready + } + if not gpu_ids: + return None page = 1 while True: response = await self.list_images( @@ -518,8 +680,10 @@ async def find_ready_image(self, image_name: str) -> Optional[SandboxImageSummar ) ) for image in response.images: - if image.image_name == image_name and ( - image.uploaded or getattr(image, "ready", False) + if ( + image.image_name == image_name + and (gpu_ids is None or image.id in gpu_ids) + and (image.uploaded or getattr(image, "ready", False)) ): return image if len(response.images) < 100: @@ -541,6 +705,7 @@ async def get_or_build_image( expected_image_digest: Optional[str] = None, image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, + enable_gpu_builds: bool = False, builder_cpus: Optional[int] = None, builder_memory_mib: Optional[int] = None, builder_scratch_mib: Optional[int] = None, @@ -560,6 +725,8 @@ async def get_or_build_image( wait_timeout applies to this caller's polling, independently of uploads. This composes existing APIs; lookup plus creation is not server-atomic. """ + if enable_gpu_builds and docker_image is not None: + raise ValueError("GPU image builds require a remote Dockerfile context") platform = platform.strip().lower() if platform != "linux/amd64": raise ValueError("Image builds require platform='linux/amd64'") @@ -599,9 +766,12 @@ async def get_or_build_image( platform=platform, image_init=image_init, image_config_user=image_config_user, + enable_gpu_builds=enable_gpu_builds, ) if not force_build: - image = await self.find_ready_image(image_name) + image = await self.find_ready_image( + image_name, **({"enable_gpu_builds": True} if enable_gpu_builds else {}) + ) if image is not None: return SandboxImageBuildResolution( outcome="reused", @@ -616,6 +786,7 @@ async def get_or_build_image( builder_cpus=builder_cpus, builder_memory_mib=builder_memory_mib, builder_scratch_mib=builder_scratch_mib, + **({"enable_gpu_builds": True} if enable_gpu_builds else {}), wait=False, upload_timeout=upload_timeout, temp_dir=temp_dir, @@ -643,21 +814,30 @@ async def get_or_build_image( **common, ) except HyperbrowserError as error: - existing = matching_image_build(error, image_name, input_format) + existing = matching_image_build( + error, image_name, input_format, enable_gpu_builds=enable_gpu_builds + ) if existing is None: raise build = existing outcome = "joined" - if wait and build.status != "completed": + if wait and ( + build.status != "completed" + or ( + (enable_gpu_builds or build.enable_gpu_builds) + and not build.gpu_image_ready + ) + ): build = await self.wait_for_image_build( build.id, poll_interval=poll_interval, timeout=wait_timeout, + **({"enable_gpu_builds": True} if enable_gpu_builds else {}), ) return SandboxImageBuildResolution( outcome=outcome, image_name=image_name, - image_id=completed_image_id(build), + image_id=completed_image_id(build, enable_gpu_builds=enable_gpu_builds), build=build, ) @@ -760,20 +940,30 @@ async def wait_for_image_build( self, build_id: str, *, + enable_gpu_builds: bool = False, poll_interval: float = 3.0, timeout: Optional[float] = 35 * 60, ) -> SandboxImageBuild: deadline = None if timeout is None else time.monotonic() + timeout while True: build = await self.get_image_build(build_id) - if build.status == "completed": + needs_gpu = enable_gpu_builds or build.enable_gpu_builds + if ( + needs_gpu + and build.metadata + and build.metadata.get("ociPublicationError") + ): + raise RuntimeError( + "GPU image publication failed. Please rebuild the image." + ) + if build.status == "completed" and (not needs_gpu or build.gpu_image_ready): return build if build.status == "failed": message = build.error_message or "image build failed" if build.error_code: message = f"image build failed [{build.error_code}]: {message}" raise RuntimeError(message) - if is_terminal_image_build_status(build.status): + if build.status == "canceled": raise RuntimeError(f"image build {build.status}") if deadline is not None and time.monotonic() >= deadline: raise TimeoutError(f"timed out waiting for image build {build_id}") @@ -788,6 +978,7 @@ async def build_image_from_docker_image( platform: str = IMAGE_BUILD_SOURCE_PLATFORM, image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, + enable_gpu_builds: bool = False, builder_cpus: Optional[int] = None, builder_memory_mib: Optional[int] = None, builder_scratch_mib: Optional[int] = None, @@ -797,6 +988,8 @@ async def build_image_from_docker_image( temp_dir: Optional[str] = None, upload_timeout: Optional[float] = None, ) -> SandboxImageBuild: + if enable_gpu_builds: + raise ValueError("GPU image builds require a remote Dockerfile context") source = await _run_blocking( prepare_docker_image_manifest_source, docker_image, @@ -933,6 +1126,7 @@ async def _build_image_from_remote_dockerfile( *, context_path, image_name: str, + enable_gpu_builds: bool, dockerfile, platform: str, remote_full_context: bool, @@ -968,6 +1162,7 @@ async def _build_image_from_remote_dockerfile( create_result = await self.create_image_build( CreateSandboxImageBuildParams( image_name=image_name, + enable_gpu_builds=True if enable_gpu_builds else None, input_sha256=artifact.sha256_hex, input_size_bytes=artifact.size_bytes, input_format=artifact.input_format, @@ -996,6 +1191,7 @@ async def _build_image_from_remote_dockerfile( build.id, poll_interval=poll_interval, timeout=wait_timeout, + **({"enable_gpu_builds": True} if enable_gpu_builds else {}), ) return build except Exception: @@ -1022,6 +1218,7 @@ async def build_image_from_dockerfile( build_args: Optional[Dict[str, str]] = None, image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, + enable_gpu_builds: bool = False, builder_cpus: Optional[int] = None, builder_memory_mib: Optional[int] = None, builder_scratch_mib: Optional[int] = None, @@ -1031,6 +1228,8 @@ async def build_image_from_dockerfile( temp_dir: Optional[str] = None, upload_timeout: Optional[float] = None, ) -> SandboxImageBuild: + if enable_gpu_builds and not remote: + raise ValueError("GPU image builds require remote=True") if remote: if docker_tag is not None or build_args: raise ValueError( @@ -1040,6 +1239,7 @@ async def build_image_from_dockerfile( return await self._build_image_from_remote_dockerfile( context_path=context_path, image_name=image_name, + enable_gpu_builds=enable_gpu_builds, dockerfile=dockerfile, platform=platform, remote_full_context=remote_full_context, @@ -1100,8 +1300,19 @@ async def get_runtime_session(self, sandbox_id: str) -> SandboxRuntimeSession: ) return session - async def get_detail(self, sandbox_id: str) -> SandboxDetail: - payload = await self._request("GET", f"/sandbox/{sandbox_id}") + async def get_detail( + self, + sandbox_id: str, + *, + request_timeout: Optional[float] = None, + retry: bool = True, + ) -> SandboxDetail: + payload = await self._request( + "GET", + f"/sandbox/{sandbox_id}", + request_timeout=request_timeout, + retry=retry, + ) return SandboxDetail(**payload) def attach(self, detail: SandboxDetail) -> SandboxHandle: @@ -1219,6 +1430,8 @@ async def _request( *, params: Optional[Dict[str, object]] = None, data: Optional[Dict[str, object]] = None, + request_timeout: Optional[float] = None, + retry: bool = True, ): failed_attempt = 1 while True: @@ -1232,6 +1445,11 @@ async def _request( if value is not None }, json=data, + **( + {"timeout": request_timeout} + if request_timeout is not None + else {} + ), ) ensure_response_ok(response, "control") except BaseException as cause: @@ -1241,7 +1459,7 @@ async def _request( "Unknown error occurred", request_context(method, path), ) - if not should_retry_get(method, error, failed_attempt): + if not retry or not should_retry_get(method, error, failed_attempt): raise error await asyncio.sleep(get_retry_delay_seconds(failed_attempt)) failed_attempt += 1 diff --git a/hyperbrowser/client/managers/sandboxes/image_resolution.py b/hyperbrowser/client/managers/sandboxes/image_resolution.py index 7d8e9c11..457fea82 100644 --- a/hyperbrowser/client/managers/sandboxes/image_resolution.py +++ b/hyperbrowser/client/managers/sandboxes/image_resolution.py @@ -20,6 +20,7 @@ def image_build_name( platform: str = "linux/amd64", image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, + enable_gpu_builds: bool = False, ) -> str: """Name an immutable input identity without packaging or uploading it. @@ -49,6 +50,8 @@ def image_build_name( else: raise ValueError("source must be 'dockerfile' or 'prebuilt'") options = {} + if enable_gpu_builds: + options["enableGPUBuilds"] = True if image_init is not None: normalized = coerce_request(image_init, SandboxImageInit, name="image_init") initialization = normalized.model_dump(by_alias=True, exclude_none=True) @@ -72,7 +75,11 @@ def image_build_name( def matching_image_build( - error: HyperbrowserError, image_name: str, input_format: str + error: HyperbrowserError, + image_name: str, + input_format: str, + *, + enable_gpu_builds: bool = False, ) -> Optional[SandboxImageBuild]: if error.status_code != 409 or error.code != "image_build_in_progress": return None @@ -90,12 +97,18 @@ def matching_image_build( build = SandboxImageBuild(**dict(data)) except (TypeError, ValueError): return None + if enable_gpu_builds and not build.enable_gpu_builds: + return None if not build.id or build.image_name != image_name: return None return build -def completed_image_id(build: SandboxImageBuild) -> Optional[str]: +def completed_image_id( + build: SandboxImageBuild, *, enable_gpu_builds: bool = False +) -> Optional[str]: + if (enable_gpu_builds or build.enable_gpu_builds) and not build.gpu_image_ready: + return None if build.status != "completed": return None if not build.image_id: diff --git a/hyperbrowser/client/managers/sandboxes/shared.py b/hyperbrowser/client/managers/sandboxes/shared.py index 51b8ce4b..11798e4a 100644 --- a/hyperbrowser/client/managers/sandboxes/shared.py +++ b/hyperbrowser/client/managers/sandboxes/shared.py @@ -207,6 +207,13 @@ def _resolve_sandbox_runtime_session_host(runtime, base_url) -> str: def _build_sandbox_exposed_url(runtime, port: int) -> str: + if runtime is None: + raise HyperbrowserError( + "Sandbox runtime is not ready; call wait_until_ready() first", + code="sandbox_not_ready", + status_code=409, + service="runtime", + ) parsed = urlsplit(runtime.base_url) session_host = _resolve_sandbox_runtime_session_host(runtime, parsed) if not session_host: diff --git a/hyperbrowser/client/managers/sync_manager/sandbox.py b/hyperbrowser/client/managers/sync_manager/sandbox.py index 76519aef..261c62a8 100644 --- a/hyperbrowser/client/managers/sync_manager/sandbox.py +++ b/hyperbrowser/client/managers/sync_manager/sandbox.py @@ -1,9 +1,14 @@ import time +import math +from threading import Event from pathlib import Path from typing import Dict, List, Optional, Union from ..._request import coerce_request, dump_request from ....exceptions import HyperbrowserError +from ....models.computer_action import ComputerActionParams, ComputerActionResponse +from ....types.computer_action import ComputerActionParams as ComputerActionParamsDict +from .computer_action import _action_param_model from ....models.secret import SandboxResolvedSecret from ....models.sandbox import ( CompleteSandboxImageBuildParams, @@ -11,6 +16,8 @@ CreateSandboxImageBuildParams, ReuseSandboxDockerImageParams, SandboxDetail, + SandboxGpuType, + SandboxCapabilities, SandboxExecParams, SandboxExposeParams, SandboxExposeResult, @@ -89,7 +96,6 @@ docker_build_context_fingerprint, docker_image_digest, build_docker_image_from_dockerfile, - is_terminal_image_build_status, make_temp_docker_tag, merge_image_init, package_docker_build_context_manifest, @@ -206,6 +212,34 @@ def disk_mib(self): def timeout_minutes(self): return self._detail.timeout_minutes + @property + def gpu_type(self) -> Optional[SandboxGpuType]: + return self._detail.gpu_type + + @property + def gpu_count(self) -> Optional[int]: + return self._detail.gpu_count + + @property + def preempt(self) -> Optional[bool]: + return self._detail.preempt + + @property + def capabilities(self) -> Optional[SandboxCapabilities]: + return self._detail.capabilities + + @property + def runtime_class(self) -> Optional[str]: + return self._detail.runtime_class + + @property + def recording_enabled(self) -> Optional[bool]: + return self._detail.recording_enabled + + @property + def close_reason(self) -> Optional[str]: + return self._detail.close_reason + @property def exposed_ports(self): return self._detail.exposed_ports @@ -229,6 +263,107 @@ def refresh(self) -> "SandboxHandle": self.info() return self + def computer_action( + self, params: Union[ComputerActionParamsDict, ComputerActionParams] + ) -> ComputerActionResponse: + """Run an action on the image's desktop using sandbox runtime authentication.""" + payload = dump_request(params, _action_param_model(params), name="params") + response = self._transport.request_json( + "/computer-action", method="POST", json_body=payload + ) + return ComputerActionResponse(**response) + + def wait_until_ready( + self, + *, + timeout: Optional[float] = 20 * 60, + poll_interval: float = 3.0, + cancel_event: Optional[Event] = None, + ) -> "SandboxHandle": + """Wait for a runtime without stopping the sandbox on timeout or cancellation. + + The interval backs off to 15 seconds. A sync cancellation event is checked + between reads; async callers can also cancel the awaiting task. + """ + if ( + not math.isfinite(poll_interval) + or poll_interval < 0 + or (timeout is not None and (not math.isfinite(timeout) or timeout < 0)) + ): + raise ValueError( + "timeout and poll_interval must be nonnegative finite seconds" + ) + if not self.id.strip(): + raise HyperbrowserError( + "Sandbox response is missing its ID", service="control" + ) + deadline = None if timeout is None else time.monotonic() + timeout + delay = min(poll_interval, 15.0) + while True: + if cancel_event is not None and cancel_event.is_set(): + raise HyperbrowserError( + f"Stopped waiting for sandbox {self.id}; the sandbox was not stopped", + code="wait_cancelled", + service="control", + details={"sandboxId": self.id}, + ) + if self.status in {"closed", "error", "close-error"}: + raise HyperbrowserError( + f"Sandbox {self.id} ended before becoming ready: {self._detail.close_reason or self.status}", + code="sandbox_not_running", + service="control", + details={ + "sandboxId": self.id, + "status": self.status, + "closeReason": self._detail.close_reason, + }, + ) + if ( + self.status == "active" + and self.runtime is not None + and self._detail.token + ): + return self + remaining = None if deadline is None else deadline - time.monotonic() + if remaining is not None and remaining <= 0: + raise HyperbrowserError( + f"Timed out waiting for sandbox {self.id}; the sandbox was not stopped", + code="wait_timeout", + service="control", + details={"sandboxId": self.id}, + ) + try: + detail = self._service.get_detail( + self.id, + request_timeout=min(self._service.runtime_timeout, remaining) + if remaining is not None + else self._service.runtime_timeout, + retry=False, + ) + if detail.id != self.id: + raise HyperbrowserError( + "Sandbox response returned a different ID", service="control" + ) + self._hydrate(detail) + if self.status != "provisioning" and ( + self.status != "active" + or (self.runtime is not None and self._detail.token) + ): + continue + except HyperbrowserError as error: + if not error.retryable: + raise + pause = ( + delay + if deadline is None + else min(delay, max(0, deadline - time.monotonic())) + ) + if cancel_event is None: + time.sleep(pause) + else: + cancel_event.wait(pause) + delay = min(delay * 2, 15.0) + def connect(self) -> "SandboxHandle": self.create_runtime_session(force_refresh=True) return self @@ -408,6 +543,13 @@ def _clear_runtime_session(self, status: Optional[str] = None) -> None: ) def _assert_runtime_available(self) -> None: + if self._detail.status == "provisioning": + raise HyperbrowserError( + f"Sandbox {self.id} is provisioning; call wait_until_ready() first", + status_code=409, + code="sandbox_not_ready", + service="runtime", + ) if self._detail.status in {"closed", "error", "close-error"}: raise HyperbrowserError( f"Sandbox {self.id} is not running", @@ -419,7 +561,7 @@ def _assert_runtime_available(self) -> None: @staticmethod def _to_runtime_session(detail: SandboxDetail) -> Optional[SandboxRuntimeSession]: - if not detail.token: + if detail.status != "active" or not detail.token or detail.runtime is None: return None return SandboxRuntimeSession( sandbox_id=detail.id, @@ -500,8 +642,20 @@ def list_images( ) return SandboxImageListResponse(**payload) - def find_ready_image(self, image_name: str) -> Optional[SandboxImageSummary]: + def find_ready_image( + self, image_name: str, *, enable_gpu_builds: bool = False + ) -> Optional[SandboxImageSummary]: """Find an exact ready team image, including revisions awaiting backup.""" + gpu_ids = None + if enable_gpu_builds: + builds = self.list_image_builds({"status": "completed", "limit": 100}) + gpu_ids = { + build.image_id + for build in builds.builds + if build.image_name == image_name and build.gpu_image_ready + } + if not gpu_ids: + return None page = 1 while True: response = self.list_images( @@ -510,8 +664,10 @@ def find_ready_image(self, image_name: str) -> Optional[SandboxImageSummary]: ) ) for image in response.images: - if image.image_name == image_name and ( - image.uploaded or getattr(image, "ready", False) + if ( + image.image_name == image_name + and (gpu_ids is None or image.id in gpu_ids) + and (image.uploaded or getattr(image, "ready", False)) ): return image if len(response.images) < 100: @@ -533,6 +689,7 @@ def get_or_build_image( expected_image_digest: Optional[str] = None, image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, + enable_gpu_builds: bool = False, builder_cpus: Optional[int] = None, builder_memory_mib: Optional[int] = None, builder_scratch_mib: Optional[int] = None, @@ -552,6 +709,8 @@ def get_or_build_image( wait_timeout applies to this caller's polling, independently of uploads. This composes existing APIs; lookup plus creation is not server-atomic. """ + if enable_gpu_builds and docker_image is not None: + raise ValueError("GPU image builds require a remote Dockerfile context") platform = platform.strip().lower() if platform != "linux/amd64": raise ValueError("Image builds require platform='linux/amd64'") @@ -588,9 +747,12 @@ def get_or_build_image( platform=platform, image_init=image_init, image_config_user=image_config_user, + enable_gpu_builds=enable_gpu_builds, ) if not force_build: - image = self.find_ready_image(image_name) + image = self.find_ready_image( + image_name, **({"enable_gpu_builds": True} if enable_gpu_builds else {}) + ) if image is not None: return SandboxImageBuildResolution( outcome="reused", @@ -605,6 +767,7 @@ def get_or_build_image( builder_cpus=builder_cpus, builder_memory_mib=builder_memory_mib, builder_scratch_mib=builder_scratch_mib, + **({"enable_gpu_builds": True} if enable_gpu_builds else {}), wait=False, upload_timeout=upload_timeout, temp_dir=temp_dir, @@ -632,21 +795,30 @@ def get_or_build_image( **common, ) except HyperbrowserError as error: - existing = matching_image_build(error, image_name, input_format) + existing = matching_image_build( + error, image_name, input_format, enable_gpu_builds=enable_gpu_builds + ) if existing is None: raise build = existing outcome = "joined" - if wait and build.status != "completed": + if wait and ( + build.status != "completed" + or ( + (enable_gpu_builds or build.enable_gpu_builds) + and not build.gpu_image_ready + ) + ): build = self.wait_for_image_build( build.id, poll_interval=poll_interval, timeout=wait_timeout, + **({"enable_gpu_builds": True} if enable_gpu_builds else {}), ) return SandboxImageBuildResolution( outcome=outcome, image_name=image_name, - image_id=completed_image_id(build), + image_id=completed_image_id(build, enable_gpu_builds=enable_gpu_builds), build=build, ) @@ -749,20 +921,30 @@ def wait_for_image_build( self, build_id: str, *, + enable_gpu_builds: bool = False, poll_interval: float = 3.0, timeout: Optional[float] = 35 * 60, ) -> SandboxImageBuild: deadline = None if timeout is None else time.monotonic() + timeout while True: build = self.get_image_build(build_id) - if build.status == "completed": + needs_gpu = enable_gpu_builds or build.enable_gpu_builds + if ( + needs_gpu + and build.metadata + and build.metadata.get("ociPublicationError") + ): + raise RuntimeError( + "GPU image publication failed. Please rebuild the image." + ) + if build.status == "completed" and (not needs_gpu or build.gpu_image_ready): return build if build.status == "failed": message = build.error_message or "image build failed" if build.error_code: message = f"image build failed [{build.error_code}]: {message}" raise RuntimeError(message) - if is_terminal_image_build_status(build.status): + if build.status == "canceled": raise RuntimeError(f"image build {build.status}") if deadline is not None and time.monotonic() >= deadline: raise TimeoutError(f"timed out waiting for image build {build_id}") @@ -777,6 +959,7 @@ def build_image_from_docker_image( platform: str = IMAGE_BUILD_SOURCE_PLATFORM, image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, + enable_gpu_builds: bool = False, builder_cpus: Optional[int] = None, builder_memory_mib: Optional[int] = None, builder_scratch_mib: Optional[int] = None, @@ -786,6 +969,8 @@ def build_image_from_docker_image( temp_dir: Optional[str] = None, upload_timeout: Optional[float] = None, ) -> SandboxImageBuild: + if enable_gpu_builds: + raise ValueError("GPU image builds require a remote Dockerfile context") source = prepare_docker_image_manifest_source( docker_image, platform=platform, @@ -916,6 +1101,7 @@ def _build_image_from_remote_dockerfile( *, context_path, image_name: str, + enable_gpu_builds: bool, dockerfile, platform: str, remote_full_context: bool, @@ -950,6 +1136,7 @@ def _build_image_from_remote_dockerfile( create_result = self.create_image_build( CreateSandboxImageBuildParams( image_name=image_name, + enable_gpu_builds=True if enable_gpu_builds else None, input_sha256=artifact.sha256_hex, input_size_bytes=artifact.size_bytes, input_format=artifact.input_format, @@ -977,6 +1164,7 @@ def _build_image_from_remote_dockerfile( build.id, poll_interval=poll_interval, timeout=wait_timeout, + **({"enable_gpu_builds": True} if enable_gpu_builds else {}), ) return build except Exception: @@ -1003,6 +1191,7 @@ def build_image_from_dockerfile( build_args: Optional[Dict[str, str]] = None, image_init: Optional[Union[SandboxImageInitDict, SandboxImageInit]] = None, image_config_user: Optional[str] = None, + enable_gpu_builds: bool = False, builder_cpus: Optional[int] = None, builder_memory_mib: Optional[int] = None, builder_scratch_mib: Optional[int] = None, @@ -1012,6 +1201,8 @@ def build_image_from_dockerfile( temp_dir: Optional[str] = None, upload_timeout: Optional[float] = None, ) -> SandboxImageBuild: + if enable_gpu_builds and not remote: + raise ValueError("GPU image builds require remote=True") if remote: if docker_tag is not None or build_args: raise ValueError( @@ -1021,6 +1212,7 @@ def build_image_from_dockerfile( return self._build_image_from_remote_dockerfile( context_path=context_path, image_name=image_name, + enable_gpu_builds=enable_gpu_builds, dockerfile=dockerfile, platform=platform, remote_full_context=remote_full_context, @@ -1080,8 +1272,19 @@ def get_runtime_session(self, sandbox_id: str) -> SandboxRuntimeSession: ) return session - def get_detail(self, sandbox_id: str) -> SandboxDetail: - payload = self._request("GET", f"/sandbox/{sandbox_id}") + def get_detail( + self, + sandbox_id: str, + *, + request_timeout: Optional[float] = None, + retry: bool = True, + ) -> SandboxDetail: + payload = self._request( + "GET", + f"/sandbox/{sandbox_id}", + request_timeout=request_timeout, + retry=retry, + ) return SandboxDetail(**payload) def attach(self, detail: SandboxDetail) -> SandboxHandle: @@ -1197,6 +1400,8 @@ def _request( *, params: Optional[Dict[str, object]] = None, data: Optional[Dict[str, object]] = None, + request_timeout: Optional[float] = None, + retry: bool = True, ): failed_attempt = 1 while True: @@ -1210,6 +1415,11 @@ def _request( if value is not None }, json=data, + **( + {"timeout": request_timeout} + if request_timeout is not None + else {} + ), ) ensure_response_ok(response, "control") except BaseException as cause: @@ -1219,7 +1429,7 @@ def _request( "Unknown error occurred", request_context(method, path), ) - if not should_retry_get(method, error, failed_attempt): + if not retry or not should_retry_get(method, error, failed_attempt): raise error time.sleep(get_retry_delay_seconds(failed_attempt)) failed_attempt += 1 diff --git a/hyperbrowser/models/__init__.py b/hyperbrowser/models/__init__.py index 33c87226..2bdd5796 100644 --- a/hyperbrowser/models/__init__.py +++ b/hyperbrowser/models/__init__.py @@ -385,8 +385,11 @@ ) from .sandbox import ( SandboxStatus, + SandboxGpuType, + SandboxRuntimeClass, SandboxRegion, SandboxRuntimeTarget, + SandboxCapabilities, Sandbox, SandboxDetail, SandboxRuntimeSession, @@ -787,8 +790,11 @@ "UpdateSessionSolveCaptchasResponse", # sandbox "SandboxStatus", + "SandboxGpuType", + "SandboxRuntimeClass", "SandboxRegion", "SandboxRuntimeTarget", + "SandboxCapabilities", "Sandbox", "SandboxDetail", "SandboxRuntimeSession", diff --git a/hyperbrowser/models/sandbox.py b/hyperbrowser/models/sandbox.py index fdf6fa0b..98d367b9 100644 --- a/hyperbrowser/models/sandbox.py +++ b/hyperbrowser/models/sandbox.py @@ -19,7 +19,21 @@ ) from .session import SessionLaunchState, SessionStatus -SandboxStatus = SessionStatus +SandboxStatus = Literal["provisioning", "active", "closed", "error", "close-error"] +SandboxGpuType = Literal[ + "L4", + "A10G", + "L40S", + "L40", + "H100", + "H200", + "B200", + "B300", + "A100-40GB", + "A100-80GB", + "RTX-PRO-6000", +] +SandboxRuntimeClass = Literal["firecracker", "gvisor-cpu"] SandboxRegion = Literal[ "us-central", "asia-south", @@ -136,7 +150,27 @@ class SandboxNetworkUpdateResult(SandboxBaseModel): network: SandboxNetworkPolicy +class SandboxCapabilities(SandboxBaseModel): + commands: bool + files: bool + pty: bool + gpu: Optional[bool] = None + snapshots: Optional[bool] = None + volumes: Optional[bool] = None + exposed_ports: Optional[bool] = Field(default=None, alias="exposedPorts") + internet_access: Optional[bool] = Field(default=None, alias="internetAccess") + network_filtering: bool = Field(default=False, alias="networkFiltering") + live_network_updates: bool = Field(default=False, alias="liveNetworkUpdates") + writable_storage: Optional[str] = Field(default=None, alias="writableStorage") + + class Sandbox(SandboxBaseModel): + runtime_class: Optional[str] = Field(default=None, alias="runtimeClass") + gpu_type: Optional[SandboxGpuType] = Field(default=None, alias="gpuType") + gpu_count: Optional[int] = Field(default=None, alias="gpuCount") + preempt: Optional[bool] = None + capabilities: Optional[SandboxCapabilities] = None + recording_enabled: Optional[bool] = Field(default=None, alias="recordingEnabled") id: str team_id: str = Field(alias="teamId") status: SandboxStatus @@ -161,7 +195,7 @@ class Sandbox(SandboxBaseModel): memory_mib: Optional[int] = Field(default=None, alias="memMiB") disk_mib: Optional[int] = Field(default=None, alias="diskSizeMiB") timeout_minutes: Optional[int] = Field(default=None, alias="timeoutMinutes") - runtime: SandboxRuntimeTarget + runtime: Optional[SandboxRuntimeTarget] exposed_ports: List[SandboxExposeResult] = Field( default_factory=list, alias="exposedPorts", @@ -242,6 +276,14 @@ class _SandboxLaunchCommonParams(SandboxBaseModel): class CreateSandboxParams(_SandboxLaunchCommonParams): + runtime_class: Optional[SandboxRuntimeClass] = Field( + default=None, serialization_alias="runtimeClass" + ) + gpu: Optional[SandboxGpuType] = None + gpu_count: Optional[int] = Field( + default=None, ge=1, le=8, strict=True, serialization_alias="gpuCount" + ) + preempt: Optional[bool] = Field(default=None, strict=True) snapshot_name: Optional[str] = Field( default=None, serialization_alias="snapshotName" ) @@ -272,6 +314,27 @@ def normalize_secrets(cls, value): @model_validator(mode="after") def validate_launch_source(self): + if self.gpu is None: + if self.gpu_count is not None or self.preempt is not None: + raise ValueError("gpu_count and preempt require gpu") + if self.runtime_class == "gvisor-cpu": + raise ValueError("runtime_class gvisor-cpu requires gpu") + else: + if self.runtime_class == "firecracker": + raise ValueError("GPU sandboxes require runtime_class gvisor-cpu") + if self.region not in (None, "us"): + raise ValueError("GPU sandboxes require region us") + if not self.image_name or self.snapshot_name or self.mounts: + raise ValueError( + "GPU sandboxes require an image and do not support snapshots or volumes" + ) + if self.secrets or self.vault is not None or self.one_password is not None: + raise ValueError("GPU sandboxes do not support startup secrets") + if ( + self.disk_mib is not None + and not 12 * 1024 <= self.disk_mib <= 512 * 1024 + ): + raise ValueError("GPU disk_mib must be between 12288 and 524288 MiB") if self.snapshot_id and not self.snapshot_name: raise ValueError("snapshot_id requires snapshot_name") if self.image_id and not self.image_name: @@ -307,7 +370,7 @@ class StartSandboxFromSnapshotParams(_SandboxLaunchCommonParams): class SandboxListParams(SandboxBaseModel): - status: Optional[SandboxStatus] = Field(default=None, exclude=None) + status: Optional[SessionStatus] = Field(default=None, exclude=None) page: int = Field(default=1, ge=1) limit: int = Field(default=10, ge=1) start: Optional[int] = None @@ -458,6 +521,9 @@ class SandboxDockerImageManifest(SandboxBaseModel): class CreateSandboxImageBuildParams(SandboxBaseModel): + enable_gpu_builds: Optional[bool] = Field( + default=None, serialization_alias="enableGPUBuilds" + ) image_name: str = Field(serialization_alias="imageName") input_sha256: str = Field(serialization_alias="inputSha256") input_size_bytes: int = Field(serialization_alias="inputSizeBytes") @@ -497,6 +563,15 @@ class CreateSandboxImageBuildParams(SandboxBaseModel): serialization_alias="dockerImageManifest", ) + @model_validator(mode="after") + def validate_gpu_build(self): + if self.enable_gpu_builds and self.input_format not in ( + "dockerfile_context_tar_gz", + "dockerfile_context_manifest_v1", + ): + raise ValueError("GPU image builds require a remote Dockerfile context") + return self + class ReuseSandboxDockerImageParams(SandboxBaseModel): image_name: str = Field(serialization_alias="imageName") @@ -535,6 +610,11 @@ class SandboxImageBuildUpload(SandboxBaseModel): class SandboxImageBuild(SandboxBaseModel): + enable_gpu_builds: Optional[bool] = Field(default=None, alias="enableGPUBuilds") + image_ready: Optional[bool] = Field(default=None, alias="imageReady") + uploaded: Optional[bool] = None + gpu_uploaded: Optional[bool] = Field(default=None, alias="gpuUploaded") + gpu_image_ready: Optional[bool] = Field(default=None, alias="gpuImageReady") id: str team_id: Optional[str] = Field(default=None, alias="teamId") user_id: Optional[str] = Field(default=None, alias="userId") diff --git a/hyperbrowser/types/__init__.py b/hyperbrowser/types/__init__.py index 0778d09c..495ad73a 100644 --- a/hyperbrowser/types/__init__.py +++ b/hyperbrowser/types/__init__.py @@ -127,6 +127,8 @@ SandboxSnapshotListParams, SandboxSnapshotStatus, SandboxStatus, + SandboxGpuType, + SandboxRuntimeClass, SandboxTerminalCreateParams, SandboxTerminalKillParams, SandboxTerminalWaitParams, @@ -325,6 +327,8 @@ "SandboxSnapshotListParams", "SandboxSnapshotStatus", "SandboxStatus", + "SandboxGpuType", + "SandboxRuntimeClass", "SandboxTerminalCreateParams", "SandboxTerminalKillParams", "SandboxTerminalWaitParams", diff --git a/hyperbrowser/types/sandbox.py b/hyperbrowser/types/sandbox.py index 6fc3f3ee..c6579596 100644 --- a/hyperbrowser/types/sandbox.py +++ b/hyperbrowser/types/sandbox.py @@ -5,7 +5,23 @@ from .secret import OnePasswordSource, SecretReferenceInput -SandboxStatus: TypeAlias = Literal["active", "closed", "error", "close-error"] +SandboxStatus: TypeAlias = Literal[ + "provisioning", "active", "closed", "error", "close-error" +] +SandboxGpuType: TypeAlias = Literal[ + "L4", + "A10G", + "L40S", + "L40", + "H100", + "H200", + "B200", + "B300", + "A100-40GB", + "A100-80GB", + "RTX-PRO-6000", +] +SandboxRuntimeClass: TypeAlias = Literal["firecracker", "gvisor-cpu"] SandboxRegion: TypeAlias = Literal[ "us-central", "asia-south", @@ -95,6 +111,10 @@ class CreateSandboxFromImageParams(_CreateSandboxCommon, total=False): """Parameters for creating a sandbox from an image.""" image_name: Required[str] + runtime_class: Optional[SandboxRuntimeClass] + gpu: Optional[SandboxGpuType] + gpu_count: Optional[int] + preempt: Optional[bool] image_id: Optional[str] cpu: Optional[int] memory_mib: Optional[int] @@ -114,7 +134,7 @@ class CreateSandboxFromImageParams(_CreateSandboxCommon, total=False): class SandboxListParams(TypedDict, total=False): """Filters and pagination for listing sandboxes.""" - status: Optional[SandboxStatus] + status: Optional[Literal["active", "closed", "error", "close-error"]] page: int limit: int start: Optional[int] @@ -211,6 +231,7 @@ class CreateSandboxImageBuildParams(TypedDict, total=False): """Metadata for starting an uploaded sandbox image build.""" image_name: Required[str] + enable_gpu_builds: Optional[bool] input_sha256: Required[str] input_size_bytes: Required[int] builder_cpus: Optional[int] @@ -524,6 +545,8 @@ class SandboxTerminalKillParams(TypedDict, total=False): "SandboxSnapshotListParams", "SandboxSnapshotStatus", "SandboxStatus", + "SandboxGpuType", + "SandboxRuntimeClass", "SandboxTerminalCreateParams", "SandboxTerminalKillParams", "SandboxTerminalWaitParams", diff --git a/tests/fixtures/gpu_contract.json b/tests/fixtures/gpu_contract.json new file mode 100644 index 00000000..65627528 --- /dev/null +++ b/tests/fixtures/gpu_contract.json @@ -0,0 +1,182 @@ +{ + "create": { + "imageName": "gpu-image", + "gpu": "H100", + "gpuCount": 2, + "preempt": false, + "region": "us", + "vcpus": 8, + "memMiB": 32768, + "diskSizeMiB": 524288, + "enableRecording": true, + "exposedPorts": [ + { + "port": 8000, + "auth": true + } + ], + "allowInternetAccess": true, + "allowOut": [ + "example.com" + ], + "denyOut": [] + }, + "provisioning": { + "id": "gpu-sandbox-1", + "teamId": "team-1", + "status": "provisioning", + "createdAt": "2026-10-22T00:00:00Z", + "updatedAt": "2026-10-22T00:00:00Z", + "region": "us", + "sessionUrl": "https://app.test/sandboxes/gpu-sandbox-1", + "duration": 0, + "runtimeClass": "gvisor-cpu", + "gpuType": "H100", + "gpuCount": 2, + "preempt": false, + "recordingEnabled": true, + "vcpus": 8, + "memMiB": 32768, + "diskSizeMiB": 524288, + "runtime": null, + "token": null, + "tokenExpiresAt": null, + "exposedPorts": [], + "network": { + "allowInternetAccess": true, + "allowOut": [], + "denyOut": [] + }, + "creditsUsed": 0, + "capabilities": { + "commands": true, + "files": true, + "pty": true, + "gpu": true, + "snapshots": false, + "volumes": false, + "exposedPorts": true, + "networkFiltering": true, + "liveNetworkUpdates": true, + "internetAccess": true, + "writableStorage": "disk" + } + }, + "active": { + "id": "gpu-sandbox-1", + "teamId": "team-1", + "status": "active", + "createdAt": "2026-10-22T00:00:00Z", + "updatedAt": "2026-10-22T00:00:00Z", + "region": "us", + "sessionUrl": "https://app.test/sandboxes/gpu-sandbox-1", + "duration": 0, + "runtimeClass": "gvisor-cpu", + "gpuType": "H100", + "gpuCount": 2, + "preempt": false, + "recordingEnabled": true, + "vcpus": 8, + "memMiB": 32768, + "diskSizeMiB": 524288, + "runtime": { + "transport": "regional_proxy", + "host": "https://runtime.test", + "baseUrl": "https://runtime.test/sandbox/gpu-sandbox-1" + }, + "token": "runtime-token", + "tokenExpiresAt": "2099-01-01T00:00:00Z", + "exposedPorts": [], + "network": { + "allowInternetAccess": true, + "allowOut": [], + "denyOut": [] + }, + "creditsUsed": 0, + "capabilities": { + "commands": true, + "files": true, + "pty": true, + "gpu": true, + "snapshots": false, + "volumes": false, + "exposedPorts": true, + "networkFiltering": true, + "liveNetworkUpdates": true, + "internetAccess": true, + "writableStorage": "disk" + } + }, + "closed": { + "id": "gpu-sandbox-1", + "teamId": "team-1", + "status": "closed", + "createdAt": "2026-10-22T00:00:00Z", + "updatedAt": "2026-10-22T00:00:00Z", + "region": "us", + "sessionUrl": "https://app.test/sandboxes/gpu-sandbox-1", + "duration": 0, + "runtimeClass": "gvisor-cpu", + "gpuType": "H100", + "gpuCount": 2, + "preempt": false, + "recordingEnabled": true, + "vcpus": 8, + "memMiB": 32768, + "diskSizeMiB": 524288, + "runtime": null, + "token": null, + "tokenExpiresAt": null, + "exposedPorts": [], + "network": { + "allowInternetAccess": true, + "allowOut": [], + "denyOut": [] + }, + "creditsUsed": 1.25, + "capabilities": { + "commands": true, + "files": true, + "pty": true, + "gpu": true, + "snapshots": false, + "volumes": false, + "exposedPorts": true, + "networkFiltering": true, + "liveNetworkUpdates": true, + "internetAccess": true, + "writableStorage": "disk" + }, + "closeReason": "preempted" + }, + "building": { + "id": "gpu-build-1", + "imageName": "gpu-image", + "imageId": "gpu-image-1", + "status": "completed", + "enableGPUBuilds": true, + "imageReady": true, + "uploaded": true, + "gpuUploaded": false, + "gpuImageReady": false, + "metadata": { + "inputFormat": "dockerfile_context_manifest_v1", + "sourcePlatform": "linux/amd64" + } + }, + "gpuReady": { + "id": "gpu-build-1", + "imageName": "gpu-image", + "imageId": "gpu-image-1", + "status": "completed", + "enableGPUBuilds": true, + "imageReady": true, + "uploaded": true, + "gpuUploaded": true, + "gpuImageReady": true, + "metadata": { + "inputFormat": "dockerfile_context_manifest_v1", + "sourcePlatform": "linux/amd64" + } + } +} diff --git a/tests/sandbox/e2e/test_gpu.py b/tests/sandbox/e2e/test_gpu.py new file mode 100644 index 00000000..77a5e29a --- /dev/null +++ b/tests/sandbox/e2e/test_gpu.py @@ -0,0 +1,78 @@ +"""Opt-in GPU smoke test. Requires a GPU-published CUDA image and a paid GPU-enabled team.""" + +import asyncio +import inspect +import os + +import pytest + +from hyperbrowser import AsyncHyperbrowser, Hyperbrowser + +pytestmark = pytest.mark.skipif( + not ( + os.getenv("HYPERBROWSER_API_KEY") and os.getenv("HYPERBROWSER_GPU_IMAGE_NAME") + ), + reason="Set HYPERBROWSER_API_KEY and HYPERBROWSER_GPU_IMAGE_NAME to run live GPU tests", +) + + +@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"]) +def test_gpu_lifecycle(asynchronous): + async def call(value): + return await value if inspect.isawaitable(value) else value + + async def run(): + client = (AsyncHyperbrowser if asynchronous else Hyperbrowser)( + api_key=os.environ["HYPERBROWSER_API_KEY"], + **( + {"base_url": os.environ["HYPERBROWSER_BASE_URL"]} + if os.getenv("HYPERBROWSER_BASE_URL") + else {} + ), + ) + sandbox = None + count = int(os.getenv("HYPERBROWSER_GPU_COUNT", "1")) + try: + sandbox = await call( + client.sandboxes.create( + { + "image_name": os.environ["HYPERBROWSER_GPU_IMAGE_NAME"], + "gpu": os.getenv("HYPERBROWSER_GPU_TYPE", "L4"), + "gpu_count": count, + "preempt": os.getenv("HYPERBROWSER_GPU_PREEMPT", "true").lower() + != "false", + "region": "us", + "disk_mib": 65536, + "timeout_minutes": 30, + } + ) + ) + await call(sandbox.wait_until_ready(timeout=20 * 60)) + devices = await call( + sandbox.exec("nvidia-smi --query-gpu=uuid --format=csv,noheader") + ) + assert devices.exit_code == 0 + assert len(devices.stdout.strip().splitlines()) == count + await call(sandbox.files.write_text("/tmp/hb-gpu-sdk.txt", "gpu-sdk-ok")) + assert ( + await call(sandbox.files.read_text("/tmp/hb-gpu-sdk.txt")) + == "gpu-sdk-ok" + ) + port = await call(sandbox.expose({"port": 8000, "auth": True})) + assert port.auth and port.url + await call(sandbox.unexpose(8000)) + await call( + sandbox.update_network({"allow_out": ["example.com"], "deny_out": []}) + ) + await call(sandbox.clear_network()) + if os.getenv("HYPERBROWSER_GPU_DESKTOP") == "1": + result = await call(sandbox.computer_action({"action": "screenshot"})) + assert result.success and result.screenshot + finally: + try: + if sandbox is not None: + await call(sandbox.stop()) + finally: + await call(client.close()) + + asyncio.run(run()) diff --git a/tests/test_gpu_sandboxes.py b/tests/test_gpu_sandboxes.py new file mode 100644 index 00000000..6eb87bc1 --- /dev/null +++ b/tests/test_gpu_sandboxes.py @@ -0,0 +1,476 @@ +"""GPU contracts shared with node-sdk, exercised through sync and async HTTP.""" + +import asyncio +import inspect +import json +from pathlib import Path +from threading import Event + +import httpx +import pytest + +from hyperbrowser import AsyncHyperbrowser, Hyperbrowser +from hyperbrowser.client._request import dump_request +from hyperbrowser.exceptions import HyperbrowserError +from hyperbrowser.image_builds import image_build_name +from hyperbrowser.models import ( + CreateSandboxImageBuildParams, + CreateSandboxParams, + SandboxDetail, + SandboxListParams, + ScreenshotActionParams, +) + +CONTRACT = json.loads( + (Path(__file__).parent / "fixtures/gpu_contract.json").read_text() +) +PARAMS = dict( + image_name="gpu-image", + gpu="H100", + gpu_count=2, + preempt=False, + region="us", + cpu=8, + memory_mib=32768, + disk_mib=524288, + enable_recording=True, + exposed_ports=[{"port": 8000, "auth": True}], + allow_internet_access=True, + allow_out=["example.com"], + deny_out=[], +) + + +async def call(result): + return await result if inspect.isawaitable(result) else result + + +@pytest.fixture(params=[False, True], ids=["sync", "async"]) +def run_sdk(request, monkeypatch): + sync_http, async_http = httpx.Client, httpx.AsyncClient + + def run(handler, scenario): + transport = httpx.MockTransport(handler) + + async def invoke(): + cls = AsyncHyperbrowser if request.param else Hyperbrowser + client = cls(api_key="control-key", base_url="https://control.test") + try: + await scenario(client.sandboxes) + finally: + await call(client.close()) + + with monkeypatch.context() as patch: + patch.setattr( + httpx, "Client", lambda **kw: sync_http(transport=transport, **kw) + ) + patch.setattr( + httpx, "AsyncClient", lambda **kw: async_http(transport=transport, **kw) + ) + asyncio.run(invoke()) + + return run + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_gpu_launch_serialization_and_provisioning(run_sdk, legacy): + def handler(request): + assert request.method == "POST" and request.url.path == "/api/sandbox" + assert json.loads(request.content) == CONTRACT["create"] + return httpx.Response(200, json=CONTRACT["provisioning"]) + + async def scenario(service): + sandbox = await call( + service.create(CreateSandboxParams(**PARAMS) if legacy else PARAMS) + ) + assert sandbox.status == "provisioning" and sandbox.runtime is None + assert sandbox.gpu_type == "H100" and sandbox.gpu_count == 2 + assert sandbox.preempt is False and sandbox.recording_enabled is True + assert sandbox.capabilities.writable_storage == "disk" + assert sandbox.capabilities.live_network_updates is True + with pytest.raises(HyperbrowserError, match="wait_until_ready"): + await call(sandbox.exec("nvidia-smi")) + with pytest.raises(HyperbrowserError, match="not ready"): + sandbox.get_exposed_url(8000) + + run_sdk(handler, scenario) + + +@pytest.mark.parametrize( + "overrides", + [ + {"gpu": None}, + {"gpu_count": 0}, + {"gpu_count": 9}, + {"gpu_count": 1.5}, + {"gpu_count": True}, + {"preempt": "false"}, + {"gpu": "A100"}, + {"runtime_class": "firecracker"}, + {"region": "us-east"}, + {"disk_mib": 12287}, + {"disk_mib": 524289}, + {"image_name": None, "snapshot_name": "snapshot"}, + {"mounts": {"/data": {"id": "volume"}}}, + {"secrets": {"TOKEN": "token"}}, + ], +) +def test_invalid_gpu_requests_are_rejected(overrides): + with pytest.raises(ValueError): + CreateSandboxParams(**dict(PARAMS, **overrides)) + + +def test_defaults_stay_server_owned_and_list_filter_remains_compatible(): + assert dump_request({"image_name": "gpu", "gpu": "B300"}, CreateSandboxParams) == { + "imageName": "gpu", + "gpu": "B300", + } + with pytest.raises(ValueError): + SandboxListParams(status="provisioning") + + +def test_wait_retries_reads_and_hydrates_runtime(run_sdk): + requests = [] + + def handler(request): + requests.append((request.method, request.url.path)) + assert request.method == "GET" + if len(requests) == 1: + return httpx.Response(503, json={"message": "temporarily unavailable"}) + return httpx.Response(200, json=CONTRACT["active"]) + + async def scenario(service): + sandbox = service.attach(SandboxDetail(**CONTRACT["provisioning"])) + assert await call(sandbox.wait_until_ready(poll_interval=0)) is sandbox + assert sandbox.status == "active" and sandbox.runtime.base_url.endswith( + sandbox.id + ) + assert (await call(sandbox.create_runtime_session())).token == "runtime-token" + + run_sdk(handler, scenario) + assert requests == [("GET", "/api/sandbox/gpu-sandbox-1")] * 2 + + +@pytest.mark.parametrize( + "failure", ["closed", "timeout", "cancelled", "forbidden", "wrong_id"] +) +def test_wait_failures_never_stop_or_recreate(run_sdk, failure): + requests = [] + + def handler(request): + requests.append(request.method) + assert request.method == "GET" + if failure == "forbidden": + return httpx.Response(403, json={"message": "forbidden"}) + if failure == "wrong_id": + return httpx.Response(200, json=dict(CONTRACT["active"], id="other")) + return httpx.Response(200, json=CONTRACT["closed"]) + + async def scenario(service): + sandbox = service.attach(SandboxDetail(**CONTRACT["provisioning"])) + event = Event() + if failure == "cancelled": + event.set() + with pytest.raises(HyperbrowserError) as caught: + await call( + sandbox.wait_until_ready( + timeout=0 if failure == "timeout" else 1, + poll_interval=0, + cancel_event=event, + ) + ) + expected = { + "closed": "sandbox_not_running", + "timeout": "wait_timeout", + "cancelled": "wait_cancelled", + } + if failure in expected: + assert caught.value.code == expected[failure] + assert caught.value.details["sandboxId"] == sandbox.id + if failure == "closed": + assert sandbox.close_reason == "preempted" + assert sandbox.info # The handle remains usable for inspection. + assert sandbox.to_dict()["credits_used"] == 1.25 + if failure == "forbidden": + assert caught.value.status_code == 403 + if failure == "wrong_id": + assert "different ID" in str(caught.value) + + run_sdk(handler, scenario) + assert requests == ([] if failure in ("timeout", "cancelled") else ["GET"]) + + +def test_runtime_actions_refresh_token_and_keep_browser_auth_separate(run_sdk): + actions = [] + + def handler(request): + if request.url.host == "control.test": + return httpx.Response( + 200, json=dict(CONTRACT["active"], token="fresh-token") + ) + assert request.url.path == "/sandbox/gpu-sandbox-1/computer-action" + assert request.method == "POST" + actions.append( + (request.headers.get("authorization"), json.loads(request.content)) + ) + if len(actions) == 1: + return httpx.Response(401, json={"error": "expired"}) + return httpx.Response(200, json={"success": True, "screenshot": "cG5n"}) + + async def scenario(service): + sandbox = service.attach(SandboxDetail(**CONTRACT["active"])) + result = await call(sandbox.computer_action(ScreenshotActionParams())) + assert result.screenshot == "cG5n" + await call( + sandbox.computer_action( + {"action": "click", "x": 4, "y": 5, "num_clicks": 2} + ) + ) + + run_sdk(handler, scenario) + assert actions[0] == ("Bearer runtime-token", {"action": "screenshot"}) + assert actions[1] == ("Bearer fresh-token", {"action": "screenshot"}) + assert actions[2][1]["numClicks"] == 2 + + +def test_gpu_network_and_authenticated_ports(run_sdk): + def handler(request): + body = json.loads(request.content) + if request.url.path.endswith("/network"): + assert body == {"allowOut": ["example.com"], "denyOut": []} + return httpx.Response( + 200, json={"network": {"allowInternetAccess": True, **body}} + ) + assert request.url.path.endswith("/expose") and body == { + "port": 8000, + "auth": True, + } + return httpx.Response( + 200, + json={ + "port": 8000, + "auth": True, + "url": "https://8000.runtime.test/", + "browserUrl": "https://8000.runtime.test/auth", + }, + ) + + async def scenario(service): + sandbox = service.attach(SandboxDetail(**CONTRACT["active"])) + await call( + sandbox.update_network({"allow_out": ["example.com"], "deny_out": []}) + ) + assert sandbox.network.allow_out == ["example.com"] + exposure = await call(sandbox.expose({"port": 8000, "auth": True})) + assert exposure.auth and exposure.browser_url.endswith("/auth") + + run_sdk(handler, scenario) + + +def test_gpu_image_publication_wait(run_sdk): + polls = [] + + def handler(request): + polls.append(request.url.path) + return httpx.Response( + 200, json={"build": CONTRACT["building" if len(polls) == 1 else "gpuReady"]} + ) + + async def scenario(service): + build = await call(service.wait_for_image_build("gpu-build-1", poll_interval=0)) + assert build.gpu_image_ready and build.gpu_uploaded + + run_sdk(handler, scenario) + assert len(polls) == 2 + + +def test_gpu_image_publication_failure(run_sdk): + def handler(request): + build = dict( + CONTRACT["building"], metadata={"ociPublicationError": "internal-error"} + ) + return httpx.Response(200, json={"build": build}) + + async def scenario(service): + with pytest.raises(RuntimeError, match="GPU image publication failed"): + await call(service.wait_for_image_build("gpu-build-1", poll_interval=0)) + + run_sdk(handler, scenario) + + +def test_gpu_image_identity_and_unsupported_input(): + cpu = image_build_name(source="dockerfile", fingerprint="a" * 64) + gpu = image_build_name( + source="dockerfile", fingerprint="a" * 64, enable_gpu_builds=True + ) + assert cpu != gpu + assert cpu == image_build_name( + source="dockerfile", fingerprint="a" * 64, enable_gpu_builds=False + ) + with pytest.raises(ValueError, match="remote Dockerfile"): + CreateSandboxImageBuildParams( + image_name="gpu", + input_sha256="a" * 64, + input_size_bytes=1, + enable_gpu_builds=True, + input_format="docker_image_manifest_v1", + ) + + +@pytest.mark.parametrize( + "mode", ["created", "joined", "reused", "cpu_only", "detached"] +) +def test_gpu_build_resolution(run_sdk, tmp_path, mode): + (tmp_path / "Dockerfile").write_text("FROM scratch\n") + name = image_build_name( + source="dockerfile", fingerprint="a" * 64, enable_gpu_builds=True + ) + calls = [] + polls = [] + + def build(ready=False): + return dict(CONTRACT["gpuReady" if ready else "building"], imageName=name) + + def handler(request): + calls.append((request.method, request.url.path)) + path = request.url.path + if path == "/api/images/builds" and request.method == "GET": + rows = ( + [build(ready=mode == "reused")] + if mode in ("reused", "cpu_only") + else [] + ) + if mode == "cpu_only": + rows[0]["enableGPUBuilds"] = False + return httpx.Response(200, json={"builds": rows}) + if path == "/api/images": + return httpx.Response( + 200, + json={ + "images": [ + dict( + id="gpu-image-1", + imageName=name, + namespace="team-1", + uploaded=True, + createdAt="2026-10-22T00:00:00Z", + updatedAt="2026-10-22T00:00:00Z", + ) + ] + }, + ) + if path == "/api/images/builds": + assert json.loads(request.content)["enableGPUBuilds"] is True + if mode == "joined": + return httpx.Response( + 409, + json={ + "message": "in progress", + "code": "image_build_in_progress", + "build": build(), + }, + ) + return httpx.Response(200, json={"build": build(), "uploads": []}) + if path.endswith("/complete"): + return httpx.Response(200, json={"build": build()}) + assert path == "/api/images/builds/gpu-build-1" and request.method == "GET" + polls.append(path) + return httpx.Response(200, json={"build": build(ready=len(polls) > 1)}) + + async def scenario(service): + # Fingerprint must describe the actual context before upload. + from hyperbrowser.build_context import docker_build_context_fingerprint + + nonlocal name + fingerprint = docker_build_context_fingerprint(tmp_path) + name = image_build_name( + source="dockerfile", fingerprint=fingerprint, enable_gpu_builds=True + ) + result = await call( + service.get_or_build_image( + context_path=tmp_path, + enable_gpu_builds=True, + poll_interval=0, + wait=mode != "detached", + ) + ) + assert result.outcome == {"joined": "joined", "reused": "reused"}.get( + mode, "created" + ) + assert result.image_id == (None if mode == "detached" else "gpu-image-1") + + run_sdk(handler, scenario) + assert not any(path.endswith("/cancel") for _, path in calls) + assert len(polls) == (0 if mode in ("reused", "detached") else 2) + + +@pytest.mark.parametrize("reason", ["timeout", "cancel"]) +def test_async_wait_interrupts_pending_read(reason): + from types import SimpleNamespace + from hyperbrowser.client.managers.async_manager.sandbox import SandboxHandle + + async def scenario(): + entered = asyncio.Event() + + async def get_detail(*args, **kwargs): + entered.set() + await asyncio.Event().wait() + + service = SimpleNamespace( + get_detail=get_detail, runtime_timeout=30, runtime_proxy_override=None + ) + sandbox = SandboxHandle(service, SandboxDetail(**CONTRACT["provisioning"])) + task = asyncio.create_task( + sandbox.wait_until_ready(timeout=0.01 if reason == "timeout" else None) + ) + await entered.wait() + if reason == "cancel": + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + else: + with pytest.raises(HyperbrowserError) as caught: + await task + assert caught.value.code == "wait_timeout" + assert sandbox.status == "provisioning" + + asyncio.run(scenario()) + + +def test_gpu_image_wait_timeout_leaves_build_running(run_sdk): + calls = [] + + def handler(request): + calls.append(request.method) + return httpx.Response(200, json={"build": CONTRACT["building"]}) + + async def scenario(service): + with pytest.raises(TimeoutError): + await call(service.wait_for_image_build("gpu-build-1", timeout=0)) + + run_sdk(handler, scenario) + assert calls == ["GET"] + + +def test_gpu_prebuilt_helpers_reject_before_packaging(run_sdk): + def handler(request): + pytest.fail("Unsupported GPU build must fail before a request") + + async def scenario(service): + with pytest.raises(ValueError, match="remote Dockerfile"): + await call( + service.build_image_from_docker_image( + docker_image="cuda", image_name="gpu", enable_gpu_builds=True + ) + ) + with pytest.raises(ValueError, match="remote=True"): + await call( + service.build_image_from_dockerfile( + context_path="/missing", + image_name="gpu", + remote=False, + enable_gpu_builds=True, + ) + ) + + run_sdk(handler, scenario) diff --git a/tests/typecheck/invalid_requests.py b/tests/typecheck/invalid_requests.py index b103780a..2e72b0da 100644 --- a/tests/typecheck/invalid_requests.py +++ b/tests/typecheck/invalid_requests.py @@ -146,3 +146,15 @@ async def invalid_async_credential_requests(client: AsyncHyperbrowser) -> None: await client.vaults.create({"entries": {}}) # M,P sandbox = await client.sandboxes.get("s") await sandbox.exec("x", one_password={"token": "t", "vault": "v", "fields": {"A": {"item": "i"}}}) # M,P + + +def invalid_gpu_requests(client: Hyperbrowser) -> None: + client.sandboxes.create({"image_name": "cuda", "gpu": "A100"}) # M,P + client.sandboxes.create({"image_name": "cuda", "gpu_count": "2"}) # M,P + client.sandboxes.create({"image_name": "cuda", "preempt": "false"}) # M,P + client.sandboxes.create({"snapshot_name": "saved", "gpu": "H100"}) # M,P + client.sandboxes.list({"status": "provisioning"}) # M,P + client.sandboxes.get_or_build_image(context_path=".", enable_gpu_builds="yes") # M,P + sandbox = client.sandboxes.get("sandbox") + sandbox.wait_until_ready(timeout="20") # M,P + sandbox.computer_action({"action": "click", "x": "left"}) # M,P diff --git a/tests/typecheck/valid_requests.py b/tests/typecheck/valid_requests.py index ca0b1489..d6b0623a 100644 --- a/tests/typecheck/valid_requests.py +++ b/tests/typecheck/valid_requests.py @@ -833,3 +833,29 @@ async def valid_async_credential_requests(client: AsyncHyperbrowser) -> None: client, session.id, {"credential": "x"} ) print(text) + + +def valid_gpu_requests(client: Hyperbrowser) -> None: + sandbox = client.sandboxes.create({ + "image_name": "cuda", "gpu": "H100", "gpu_count": 2, + "preempt": False, "disk_mib": 524288, + }) + sandbox.wait_until_ready(timeout=1200, poll_interval=3) + sandbox.computer_action({"action": "screenshot"}) + client.sandboxes.get_or_build_image(context_path=".", enable_gpu_builds=True) + client.sandboxes.create_image_build({ + "image_name": "cuda", "input_sha256": "a" * 64, "input_size_bytes": 1, + "input_format": "dockerfile_context_manifest_v1", "enable_gpu_builds": True, + }) + client.sandboxes.wait_for_image_build("build", enable_gpu_builds=True) + + +async def valid_async_gpu_requests(client: AsyncHyperbrowser) -> None: + sandbox = await client.sandboxes.create({ + "image_name": "cuda", "gpu": "B300", "gpu_count": 8, "preempt": False, + }) + await sandbox.wait_until_ready() + await sandbox.computer_action({"action": "click", "x": 1, "y": 2}) + await client.sandboxes.build_image_from_dockerfile( + context_path=".", image_name="cuda", enable_gpu_builds=True, + )