From 34d278995270780b07ad174b2928b5b268e26bff Mon Sep 17 00:00:00 2001 From: Zack McKenna Date: Mon, 9 Nov 2026 11:20:58 -0500 Subject: [PATCH 1/3] feat(sandbox): add options lookup for enabled datacenters and CPU flavors --- runpod/api/sandboxes.py | 10 ++++++++++ runpod/sandbox/__init__.py | 2 ++ runpod/sandbox/asyncio.py | 20 ++++++++++++++++++++ runpod/sandbox/models.py | 15 +++++++++++++++ runpod/sandbox/sync.py | 22 ++++++++++++++++++++++ tests/test_sandbox.py | 30 ++++++++++++++++++++++++++++++ 6 files changed, 99 insertions(+) diff --git a/runpod/api/sandboxes.py b/runpod/api/sandboxes.py index e5e6663d9..99226b1f0 100644 --- a/runpod/api/sandboxes.py +++ b/runpod/api/sandboxes.py @@ -471,6 +471,16 @@ async def list_registry_auths(self) -> list[dict[str, Any]]: raise ValueError("Registry listing returned no response") return payload["registries"] + async def options(self) -> dict[str, Any]: + path = f"{_SANDBOX_PATH}/options" + payload = await self._request("GET", path) + if not isinstance(payload, dict) or not all( + isinstance(payload.get(key), list) + for key in ("dataCenterIds", "cpuFlavorIds") + ): + raise error.QueryError("sandbox options response is malformed", f"GET {path}") + return payload + async def create(self, body: Mapping[str, Any]) -> dict[str, Any]: payload = await self._request( "POST", diff --git a/runpod/sandbox/__init__.py b/runpod/sandbox/__init__.py index f6d2fd128..4a890d009 100644 --- a/runpod/sandbox/__init__.py +++ b/runpod/sandbox/__init__.py @@ -10,6 +10,7 @@ SandboxCompute, SandboxExecutionError, SandboxInfo, + SandboxOptions, SandboxStartupTimeout, SandboxState, SandboxStateError, @@ -28,6 +29,7 @@ "SandboxExecutionError", "SandboxCreationError", "SandboxInfo", + "SandboxOptions", "SandboxStartupTimeout", "SandboxState", "SandboxStateError", diff --git a/runpod/sandbox/asyncio.py b/runpod/sandbox/asyncio.py index e1c602791..e5e12824a 100644 --- a/runpod/sandbox/asyncio.py +++ b/runpod/sandbox/asyncio.py @@ -23,6 +23,7 @@ SandboxCompute, SandboxExecutionError, SandboxInfo, + SandboxOptions, SandboxStartupTimeout, SandboxState, SandboxStateError, @@ -244,6 +245,25 @@ async def get( raise return sandbox + @classmethod + async def options( + cls, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + request_timeout: float = 30, + ) -> SandboxOptions: + """Return the data centers and CPU flavors a create may choose from.""" + _timeout(request_timeout, "request_timeout") + api = AsyncSandboxAPI(api_key, base_url, request_timeout) + try: + result = SandboxOptions.from_dict(await api.options()) + except BaseException as error: + await _cleanup(api.close(), request_timeout, error) + raise + await _cleanup(api.close(), request_timeout) + return result + @classmethod async def list( cls, diff --git a/runpod/sandbox/models.py b/runpod/sandbox/models.py index f65da1fed..b7e3e787c 100644 --- a/runpod/sandbox/models.py +++ b/runpod/sandbox/models.py @@ -110,6 +110,21 @@ def from_dict(cls, data: Mapping[str, Any]) -> "SandboxInfo": ) +@dataclass(frozen=True) +class SandboxOptions: + """what a sandbox create may choose from right now.""" + + data_center_ids: tuple[str, ...] + cpu_flavor_ids: tuple[str, ...] + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "SandboxOptions": + return cls( + data_center_ids=tuple(payload["dataCenterIds"]), + cpu_flavor_ids=tuple(payload["cpuFlavorIds"]), + ) + + @dataclass(frozen=True) class ExecResult: """command output with nullable host-reported execution details.""" diff --git a/runpod/sandbox/sync.py b/runpod/sandbox/sync.py index f01acf625..1fffe68b2 100644 --- a/runpod/sandbox/sync.py +++ b/runpod/sandbox/sync.py @@ -23,6 +23,7 @@ LogSource, SandboxCompute, SandboxInfo, + SandboxOptions, SandboxState, ) @@ -400,6 +401,27 @@ async def get() -> AsyncioSandbox: raise original from error raise + @classmethod + def options( + cls, + *, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + request_timeout: float = 30, + ) -> SandboxOptions: + """Return the data centers and CPU flavors a create may choose from.""" + runner = _LoopRunner() + try: + return runner.run( + AsyncioSandbox.options( + api_key=api_key, + base_url=base_url, + request_timeout=request_timeout, + ) + ) + finally: + runner.stop() + @classmethod def list( cls, diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index af24e9523..d4417ef12 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -140,6 +140,10 @@ async def handle(request): ) if request.headers.get("Authorization") != "Bearer sandbox-test-key": return failure(401, "invalid key") + if request.path == "/v2/sandboxes/options": + return web.json_response( + {"dataCenterIds": ["EU-RO-1", "US-CA-2"], "cpuFlavorIds": ["cpu3c"]} + ) if request.path == "/v2/global-volumes": if service.global_error: return failure(403, service.global_error) @@ -300,6 +304,7 @@ async def serve(): "/v2/sandboxes", "/v2/network-volumes", "/v2/registries", + "/v2/sandboxes/options", "/v2/sandboxes/{id}", "/v2/sandboxes/{id}/{operation}", ): @@ -1381,3 +1386,28 @@ async def test_same_resolved_volume_id_cannot_be_mounted_as_two_kinds(peer): **peer.options, ) assert peer.records == {} + + +def _connection(peer): + return {key: value for key, value in peer.options.items() if key != "startup_timeout"} + + +@pytest.mark.asyncio +async def test_options_returns_what_create_may_choose(peer): + options = await AsyncioSandbox.options(**_connection(peer)) + assert options.data_center_ids == ("EU-RO-1", "US-CA-2") + assert options.cpu_flavor_ids == ("cpu3c",) + assert peer.requests[-1][:2] == ("GET", "/v2/sandboxes/options") + + +def test_sync_options_matches_async(peer): + options = Sandbox.options(**_connection(peer)) + assert options.data_center_ids == ("EU-RO-1", "US-CA-2") + assert options.cpu_flavor_ids == ("cpu3c",) + + +@pytest.mark.asyncio +async def test_options_authentication_failure_propagates(peer): + bad = {**_connection(peer), "api_key": "wrong"} + with pytest.raises(Exception, match="invalid key"): + await AsyncioSandbox.options(**bad) From 2defd3184dbe65696482e9283970bcb9c24966df Mon Sep 17 00:00:00 2001 From: Zack McKenna Date: Mon, 9 Nov 2026 11:25:22 -0500 Subject: [PATCH 2/3] test(sandbox): assert the options auth failure type --- tests/test_sandbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index d4417ef12..fb9d10aff 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -1409,5 +1409,5 @@ def test_sync_options_matches_async(peer): @pytest.mark.asyncio async def test_options_authentication_failure_propagates(peer): bad = {**_connection(peer), "api_key": "wrong"} - with pytest.raises(Exception, match="invalid key"): + with pytest.raises(AuthenticationError): await AsyncioSandbox.options(**bad) From 26ce11ca88ea9d3675a0752255ffef756cf037fe Mon Sep 17 00:00:00 2001 From: Zack McKenna Date: Mon, 9 Nov 2026 12:20:03 -0500 Subject: [PATCH 3/3] feat(sandbox): return account limits from the options lookup --- runpod/api/sandboxes.py | 10 +++++++--- runpod/sandbox/__init__.py | 2 ++ runpod/sandbox/models.py | 21 ++++++++++++++++++++- tests/test_sandbox.py | 17 ++++++++++++++++- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/runpod/api/sandboxes.py b/runpod/api/sandboxes.py index 99226b1f0..932e1e371 100644 --- a/runpod/api/sandboxes.py +++ b/runpod/api/sandboxes.py @@ -474,9 +474,13 @@ async def list_registry_auths(self) -> list[dict[str, Any]]: async def options(self) -> dict[str, Any]: path = f"{_SANDBOX_PATH}/options" payload = await self._request("GET", path) - if not isinstance(payload, dict) or not all( - isinstance(payload.get(key), list) - for key in ("dataCenterIds", "cpuFlavorIds") + if ( + not isinstance(payload, dict) + or not all( + isinstance(payload.get(key), list) + for key in ("dataCenterIds", "cpuFlavorIds") + ) + or not isinstance(payload.get("limits"), dict) ): raise error.QueryError("sandbox options response is malformed", f"GET {path}") return payload diff --git a/runpod/sandbox/__init__.py b/runpod/sandbox/__init__.py index 4a890d009..ec7604e6b 100644 --- a/runpod/sandbox/__init__.py +++ b/runpod/sandbox/__init__.py @@ -10,6 +10,7 @@ SandboxCompute, SandboxExecutionError, SandboxInfo, + SandboxLimits, SandboxOptions, SandboxStartupTimeout, SandboxState, @@ -29,6 +30,7 @@ "SandboxExecutionError", "SandboxCreationError", "SandboxInfo", + "SandboxLimits", "SandboxOptions", "SandboxStartupTimeout", "SandboxState", diff --git a/runpod/sandbox/models.py b/runpod/sandbox/models.py index b7e3e787c..c10ee7ea5 100644 --- a/runpod/sandbox/models.py +++ b/runpod/sandbox/models.py @@ -110,18 +110,37 @@ def from_dict(cls, data: Mapping[str, Any]) -> "SandboxInfo": ) +@dataclass(frozen=True) +class SandboxLimits: + """limits for this account; capacity and other caps can still refuse a create.""" + + max_concurrent_sandboxes: int + max_idle_timeout_seconds: int + max_lifetime_seconds: int + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "SandboxLimits": + return cls( + max_concurrent_sandboxes=payload["maxConcurrentSandboxes"], + max_idle_timeout_seconds=payload["maxIdleTimeoutSeconds"], + max_lifetime_seconds=payload["maxLifetimeSeconds"], + ) + + @dataclass(frozen=True) class SandboxOptions: - """what a sandbox create may choose from right now.""" + """what this account may choose when creating a sandbox.""" data_center_ids: tuple[str, ...] cpu_flavor_ids: tuple[str, ...] + limits: SandboxLimits @classmethod def from_dict(cls, payload: Mapping[str, Any]) -> "SandboxOptions": return cls( data_center_ids=tuple(payload["dataCenterIds"]), cpu_flavor_ids=tuple(payload["cpuFlavorIds"]), + limits=SandboxLimits.from_dict(payload["limits"]), ) diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py index fb9d10aff..88358e4e0 100644 --- a/tests/test_sandbox.py +++ b/tests/test_sandbox.py @@ -21,6 +21,7 @@ from runpod.sandbox import ( SandboxCreationError, SandboxExecutionError, + SandboxLimits, SandboxStartupTimeout, SandboxStateError, ) @@ -142,7 +143,15 @@ async def handle(request): return failure(401, "invalid key") if request.path == "/v2/sandboxes/options": return web.json_response( - {"dataCenterIds": ["EU-RO-1", "US-CA-2"], "cpuFlavorIds": ["cpu3c"]} + { + "dataCenterIds": ["EU-RO-1", "US-CA-2"], + "cpuFlavorIds": ["cpu3c"], + "limits": { + "maxConcurrentSandboxes": 10, + "maxIdleTimeoutSeconds": 7200, + "maxLifetimeSeconds": 86400, + }, + } ) if request.path == "/v2/global-volumes": if service.global_error: @@ -1397,6 +1406,11 @@ async def test_options_returns_what_create_may_choose(peer): options = await AsyncioSandbox.options(**_connection(peer)) assert options.data_center_ids == ("EU-RO-1", "US-CA-2") assert options.cpu_flavor_ids == ("cpu3c",) + assert options.limits == SandboxLimits( + max_concurrent_sandboxes=10, + max_idle_timeout_seconds=7200, + max_lifetime_seconds=86400, + ) assert peer.requests[-1][:2] == ("GET", "/v2/sandboxes/options") @@ -1404,6 +1418,7 @@ def test_sync_options_matches_async(peer): options = Sandbox.options(**_connection(peer)) assert options.data_center_ids == ("EU-RO-1", "US-CA-2") assert options.cpu_flavor_ids == ("cpu3c",) + assert options.limits.max_concurrent_sandboxes == 10 @pytest.mark.asyncio