openrelay-sdk 0.1.0__py3-none-any.whl
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.
- openrelay/__init__.py +54 -0
- openrelay/_base_client.py +173 -0
- openrelay/_client.py +233 -0
- openrelay/_constants.py +25 -0
- openrelay/_exceptions.py +72 -0
- openrelay/_types.py +9 -0
- openrelay/_version.py +1 -0
- openrelay/py.typed +0 -0
- openrelay/resources/__init__.py +9 -0
- openrelay/resources/gpus.py +109 -0
- openrelay/resources/vms.py +352 -0
- openrelay/types/__init__.py +21 -0
- openrelay/types/gpus.py +48 -0
- openrelay/types/shared.py +14 -0
- openrelay/types/vms.py +77 -0
- openrelay_sdk-0.1.0.dist-info/METADATA +101 -0
- openrelay_sdk-0.1.0.dist-info/RECORD +18 -0
- openrelay_sdk-0.1.0.dist-info/WHEEL +4 -0
openrelay/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""OpenRelay Python SDK."""
|
|
2
|
+
|
|
3
|
+
from ._client import AsyncClient, Client
|
|
4
|
+
from ._exceptions import (
|
|
5
|
+
APIConnectionError,
|
|
6
|
+
APIError,
|
|
7
|
+
APITimeoutError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
InternalServerError,
|
|
10
|
+
InvalidRequestError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
OpenRelayError,
|
|
13
|
+
PermissionDeniedError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
)
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
from .types.gpus import (
|
|
18
|
+
GpuAvailability,
|
|
19
|
+
GpuAvailabilityRegion,
|
|
20
|
+
GpuModel,
|
|
21
|
+
GpuModelAvailability,
|
|
22
|
+
GpuPricing,
|
|
23
|
+
GpuPricingTier,
|
|
24
|
+
)
|
|
25
|
+
from .types.vms import Vm, VmActionResponse, VmCreateResponse
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"__version__",
|
|
29
|
+
# Clients
|
|
30
|
+
"Client",
|
|
31
|
+
"AsyncClient",
|
|
32
|
+
# Exceptions
|
|
33
|
+
"OpenRelayError",
|
|
34
|
+
"APIError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"PermissionDeniedError",
|
|
37
|
+
"NotFoundError",
|
|
38
|
+
"InvalidRequestError",
|
|
39
|
+
"RateLimitError",
|
|
40
|
+
"InternalServerError",
|
|
41
|
+
"APIConnectionError",
|
|
42
|
+
"APITimeoutError",
|
|
43
|
+
# GPU types
|
|
44
|
+
"GpuModel",
|
|
45
|
+
"GpuModelAvailability",
|
|
46
|
+
"GpuAvailability",
|
|
47
|
+
"GpuAvailabilityRegion",
|
|
48
|
+
"GpuPricing",
|
|
49
|
+
"GpuPricingTier",
|
|
50
|
+
# VM types
|
|
51
|
+
"Vm",
|
|
52
|
+
"VmCreateResponse",
|
|
53
|
+
"VmActionResponse",
|
|
54
|
+
]
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, List, Optional, Tuple, Type, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from ._constants import (
|
|
12
|
+
API_KEY_ENV_VAR,
|
|
13
|
+
CAPACITY_CODES,
|
|
14
|
+
BASE_URL_ENV_VAR,
|
|
15
|
+
DEFAULT_BASE_URL,
|
|
16
|
+
DEFAULT_MAX_RETRIES,
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
INITIAL_RETRY_DELAY,
|
|
19
|
+
MAX_RETRY_DELAY,
|
|
20
|
+
)
|
|
21
|
+
from ._exceptions import (
|
|
22
|
+
APIError,
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
InternalServerError,
|
|
25
|
+
InvalidRequestError,
|
|
26
|
+
NotFoundError,
|
|
27
|
+
OpenRelayError,
|
|
28
|
+
PermissionDeniedError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
)
|
|
31
|
+
from ._version import __version__
|
|
32
|
+
|
|
33
|
+
T = TypeVar("T", bound=BaseModel)
|
|
34
|
+
|
|
35
|
+
_UUID_RE = re.compile(
|
|
36
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
_ERROR_TYPE_MAP: Dict[str, Type[APIError]] = {
|
|
40
|
+
"authentication_error": AuthenticationError,
|
|
41
|
+
"permission_error": PermissionDeniedError,
|
|
42
|
+
"not_found_error": NotFoundError,
|
|
43
|
+
"invalid_request_error": InvalidRequestError,
|
|
44
|
+
"rate_limit_error": RateLimitError,
|
|
45
|
+
"server_error": InternalServerError,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_uuid(value: str) -> bool:
|
|
50
|
+
return bool(_UUID_RE.match(value))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# The error type a flat control-plane body implies, which carries no type field.
|
|
54
|
+
_STATUS_ERROR_TYPE: Dict[int, str] = {
|
|
55
|
+
400: "invalid_request_error",
|
|
56
|
+
401: "authentication_error",
|
|
57
|
+
403: "permission_error",
|
|
58
|
+
404: "not_found_error",
|
|
59
|
+
429: "rate_limit_error",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_error(response: httpx.Response) -> Tuple[str, str, Optional[str]]:
|
|
64
|
+
"""(type, message, code) from an error response. The control plane writes a
|
|
65
|
+
flat {"error": "<message>", "code": "<CODE>"}, which carries no type, so the
|
|
66
|
+
type follows from the status. The nested {"error": {"type", "message",
|
|
67
|
+
"code"}} shape is read as it comes. A body that is neither yields a
|
|
68
|
+
server_error with a status-only message and no code."""
|
|
69
|
+
try:
|
|
70
|
+
body = response.json()
|
|
71
|
+
except Exception:
|
|
72
|
+
body = None
|
|
73
|
+
if isinstance(body, dict):
|
|
74
|
+
error = body.get("error", {})
|
|
75
|
+
if isinstance(error, str):
|
|
76
|
+
status = response.status_code
|
|
77
|
+
code = body.get("code")
|
|
78
|
+
return (
|
|
79
|
+
"server_error" if status >= 500 else _STATUS_ERROR_TYPE.get(status, "api_error"),
|
|
80
|
+
error,
|
|
81
|
+
code if isinstance(code, str) else None,
|
|
82
|
+
)
|
|
83
|
+
if isinstance(error, dict):
|
|
84
|
+
code = error.get("code")
|
|
85
|
+
return (
|
|
86
|
+
error.get("type", "server_error"),
|
|
87
|
+
error.get("message", "Unknown error"),
|
|
88
|
+
code if isinstance(code, str) else None,
|
|
89
|
+
)
|
|
90
|
+
return "server_error", f"HTTP {response.status_code}", None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class BaseClient:
|
|
94
|
+
"""Shared configuration for sync and async clients."""
|
|
95
|
+
|
|
96
|
+
_gpu_name_cache: Optional[Dict[str, str]]
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
api_key: Optional[str] = None,
|
|
102
|
+
base_url: Optional[str] = None,
|
|
103
|
+
timeout: Optional[float] = None,
|
|
104
|
+
max_retries: Optional[int] = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.api_key = api_key or os.environ.get(API_KEY_ENV_VAR)
|
|
107
|
+
if not self.api_key:
|
|
108
|
+
raise OpenRelayError(
|
|
109
|
+
f"API key is required. Pass api_key= or set {API_KEY_ENV_VAR}."
|
|
110
|
+
)
|
|
111
|
+
self.base_url = (
|
|
112
|
+
base_url or os.environ.get(BASE_URL_ENV_VAR) or DEFAULT_BASE_URL
|
|
113
|
+
).rstrip("/")
|
|
114
|
+
self.timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
|
|
115
|
+
self.max_retries = (
|
|
116
|
+
max_retries if max_retries is not None else DEFAULT_MAX_RETRIES
|
|
117
|
+
)
|
|
118
|
+
self._gpu_name_cache = None
|
|
119
|
+
|
|
120
|
+
def _build_headers(self) -> Dict[str, str]:
|
|
121
|
+
return {
|
|
122
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
"User-Agent": f"openrelay-python/{__version__}",
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def _build_url(self, path: str) -> str:
|
|
128
|
+
return f"{self.base_url}{path}"
|
|
129
|
+
|
|
130
|
+
def _should_retry(self, response: httpx.Response) -> bool:
|
|
131
|
+
if response.status_code == 503 and _parse_error(response)[2] in CAPACITY_CODES:
|
|
132
|
+
return False
|
|
133
|
+
return response.status_code in (429, 500, 502, 503, 504)
|
|
134
|
+
|
|
135
|
+
def _retry_delay(
|
|
136
|
+
self, attempt: int, response_headers: Optional[Dict[str, str]] = None
|
|
137
|
+
) -> float:
|
|
138
|
+
if response_headers:
|
|
139
|
+
retry_after = response_headers.get("retry-after")
|
|
140
|
+
if retry_after:
|
|
141
|
+
try:
|
|
142
|
+
return float(retry_after)
|
|
143
|
+
except (ValueError, TypeError):
|
|
144
|
+
pass
|
|
145
|
+
delay = min(INITIAL_RETRY_DELAY * (2**attempt), MAX_RETRY_DELAY)
|
|
146
|
+
jitter = random.random() * 0.5 * delay
|
|
147
|
+
return delay + jitter
|
|
148
|
+
|
|
149
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
150
|
+
if response.is_success:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
error_type, message, code = _parse_error(response)
|
|
154
|
+
exc_class = _ERROR_TYPE_MAP.get(error_type, APIError)
|
|
155
|
+
raise exc_class(
|
|
156
|
+
message,
|
|
157
|
+
status_code=response.status_code,
|
|
158
|
+
type=error_type,
|
|
159
|
+
code=code,
|
|
160
|
+
headers=dict(response.headers),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def _parse_response(self, response: httpx.Response, model: Type[T]) -> T:
|
|
164
|
+
body = response.json()
|
|
165
|
+
data = body.get("data", body)
|
|
166
|
+
return model.model_validate(data)
|
|
167
|
+
|
|
168
|
+
def _parse_response_list(
|
|
169
|
+
self, response: httpx.Response, model: Type[T]
|
|
170
|
+
) -> List[T]:
|
|
171
|
+
body = response.json()
|
|
172
|
+
data = body.get("data", [])
|
|
173
|
+
return [model.model_validate(item) for item in data]
|
openrelay/_client.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._base_client import BaseClient
|
|
9
|
+
from ._exceptions import APIConnectionError, APITimeoutError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Client(BaseClient):
|
|
13
|
+
"""Synchronous OpenRelay API client."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, **kwargs: object) -> None:
|
|
16
|
+
super().__init__(**kwargs) # type: ignore[arg-type]
|
|
17
|
+
self._http = httpx.Client(
|
|
18
|
+
headers=self._build_headers(),
|
|
19
|
+
timeout=self.timeout,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from .resources.gpus import SyncGpus
|
|
23
|
+
from .resources.vms import SyncVms
|
|
24
|
+
|
|
25
|
+
self.gpus = SyncGpus(self)
|
|
26
|
+
self.vms = SyncVms(self)
|
|
27
|
+
|
|
28
|
+
def __enter__(self) -> Client:
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def __exit__(self, *args: object) -> None:
|
|
32
|
+
self.close()
|
|
33
|
+
|
|
34
|
+
def close(self) -> None:
|
|
35
|
+
self._http.close()
|
|
36
|
+
|
|
37
|
+
def request(
|
|
38
|
+
self,
|
|
39
|
+
method: str,
|
|
40
|
+
path: str,
|
|
41
|
+
*,
|
|
42
|
+
json: Optional[dict] = None,
|
|
43
|
+
params: Optional[dict] = None,
|
|
44
|
+
) -> httpx.Response:
|
|
45
|
+
url = self._build_url(path)
|
|
46
|
+
last_exc: Optional[Exception] = None
|
|
47
|
+
|
|
48
|
+
for attempt in range(self.max_retries + 1):
|
|
49
|
+
try:
|
|
50
|
+
response = self._http.request(
|
|
51
|
+
method, url, json=json, params=params
|
|
52
|
+
)
|
|
53
|
+
except httpx.TimeoutException as e:
|
|
54
|
+
last_exc = APITimeoutError(f"Request timed out: {e}")
|
|
55
|
+
if attempt < self.max_retries:
|
|
56
|
+
time.sleep(self._retry_delay(attempt))
|
|
57
|
+
continue
|
|
58
|
+
raise last_exc from e
|
|
59
|
+
except httpx.HTTPError as e:
|
|
60
|
+
last_exc = APIConnectionError(
|
|
61
|
+
f"Connection error: {e}", cause=e
|
|
62
|
+
)
|
|
63
|
+
if attempt < self.max_retries:
|
|
64
|
+
time.sleep(self._retry_delay(attempt))
|
|
65
|
+
continue
|
|
66
|
+
raise last_exc from e
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
self._should_retry(response)
|
|
70
|
+
and attempt < self.max_retries
|
|
71
|
+
):
|
|
72
|
+
time.sleep(
|
|
73
|
+
self._retry_delay(attempt, dict(response.headers))
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
self._raise_for_status(response)
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
raise last_exc or APIConnectionError("Request failed after retries")
|
|
81
|
+
|
|
82
|
+
def _resolve_gpu_name(self, gpu_type: str) -> str:
|
|
83
|
+
"""Resolve a friendly GPU name to its UUID, fetching models if needed."""
|
|
84
|
+
from ._base_client import _is_uuid
|
|
85
|
+
|
|
86
|
+
if _is_uuid(gpu_type):
|
|
87
|
+
return gpu_type
|
|
88
|
+
|
|
89
|
+
if self._gpu_name_cache is None:
|
|
90
|
+
self._refresh_gpu_cache()
|
|
91
|
+
|
|
92
|
+
assert self._gpu_name_cache is not None
|
|
93
|
+
gpu_id = self._gpu_name_cache.get(gpu_type.lower())
|
|
94
|
+
if gpu_id is None:
|
|
95
|
+
# Refresh cache once in case new models were added
|
|
96
|
+
self._refresh_gpu_cache()
|
|
97
|
+
gpu_id = self._gpu_name_cache.get(gpu_type.lower())
|
|
98
|
+
|
|
99
|
+
if gpu_id is None:
|
|
100
|
+
from ._exceptions import InvalidRequestError
|
|
101
|
+
|
|
102
|
+
raise InvalidRequestError(
|
|
103
|
+
f"Unknown GPU type: '{gpu_type}'. Use client.gpus.list() to see available models.",
|
|
104
|
+
status_code=400,
|
|
105
|
+
type="invalid_request_error",
|
|
106
|
+
code="UNKNOWN_GPU_TYPE",
|
|
107
|
+
)
|
|
108
|
+
return gpu_id
|
|
109
|
+
|
|
110
|
+
def _refresh_gpu_cache(self) -> None:
|
|
111
|
+
from .types.gpus import GpuModel
|
|
112
|
+
|
|
113
|
+
response = self.request("GET", "/v1/gpus/models")
|
|
114
|
+
models = self._parse_response_list(response, GpuModel)
|
|
115
|
+
self._gpu_name_cache = {}
|
|
116
|
+
for m in models:
|
|
117
|
+
self._gpu_name_cache[m.name.lower()] = m.id
|
|
118
|
+
# Also add common short names (e.g., "a100" for "A100 80GB")
|
|
119
|
+
short = m.name.split()[0].lower()
|
|
120
|
+
if short not in self._gpu_name_cache:
|
|
121
|
+
self._gpu_name_cache[short] = m.id
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AsyncClient(BaseClient):
|
|
125
|
+
"""Asynchronous OpenRelay API client."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, **kwargs: object) -> None:
|
|
128
|
+
super().__init__(**kwargs) # type: ignore[arg-type]
|
|
129
|
+
self._http = httpx.AsyncClient(
|
|
130
|
+
headers=self._build_headers(),
|
|
131
|
+
timeout=self.timeout,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
from .resources.gpus import AsyncGpus
|
|
135
|
+
from .resources.vms import AsyncVms
|
|
136
|
+
|
|
137
|
+
self.gpus = AsyncGpus(self)
|
|
138
|
+
self.vms = AsyncVms(self)
|
|
139
|
+
|
|
140
|
+
async def __aenter__(self) -> AsyncClient:
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
async def __aexit__(self, *args: object) -> None:
|
|
144
|
+
await self.close()
|
|
145
|
+
|
|
146
|
+
async def close(self) -> None:
|
|
147
|
+
await self._http.aclose()
|
|
148
|
+
|
|
149
|
+
async def request(
|
|
150
|
+
self,
|
|
151
|
+
method: str,
|
|
152
|
+
path: str,
|
|
153
|
+
*,
|
|
154
|
+
json: Optional[dict] = None,
|
|
155
|
+
params: Optional[dict] = None,
|
|
156
|
+
) -> httpx.Response:
|
|
157
|
+
import asyncio
|
|
158
|
+
|
|
159
|
+
url = self._build_url(path)
|
|
160
|
+
last_exc: Optional[Exception] = None
|
|
161
|
+
|
|
162
|
+
for attempt in range(self.max_retries + 1):
|
|
163
|
+
try:
|
|
164
|
+
response = await self._http.request(
|
|
165
|
+
method, url, json=json, params=params
|
|
166
|
+
)
|
|
167
|
+
except httpx.TimeoutException as e:
|
|
168
|
+
last_exc = APITimeoutError(f"Request timed out: {e}")
|
|
169
|
+
if attempt < self.max_retries:
|
|
170
|
+
await asyncio.sleep(self._retry_delay(attempt))
|
|
171
|
+
continue
|
|
172
|
+
raise last_exc from e
|
|
173
|
+
except httpx.HTTPError as e:
|
|
174
|
+
last_exc = APIConnectionError(
|
|
175
|
+
f"Connection error: {e}", cause=e
|
|
176
|
+
)
|
|
177
|
+
if attempt < self.max_retries:
|
|
178
|
+
await asyncio.sleep(self._retry_delay(attempt))
|
|
179
|
+
continue
|
|
180
|
+
raise last_exc from e
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
self._should_retry(response)
|
|
184
|
+
and attempt < self.max_retries
|
|
185
|
+
):
|
|
186
|
+
await asyncio.sleep(
|
|
187
|
+
self._retry_delay(attempt, dict(response.headers))
|
|
188
|
+
)
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
self._raise_for_status(response)
|
|
192
|
+
return response
|
|
193
|
+
|
|
194
|
+
raise last_exc or APIConnectionError("Request failed after retries")
|
|
195
|
+
|
|
196
|
+
async def _resolve_gpu_name(self, gpu_type: str) -> str:
|
|
197
|
+
"""Resolve a friendly GPU name to its UUID, fetching models if needed."""
|
|
198
|
+
from ._base_client import _is_uuid
|
|
199
|
+
|
|
200
|
+
if _is_uuid(gpu_type):
|
|
201
|
+
return gpu_type
|
|
202
|
+
|
|
203
|
+
if self._gpu_name_cache is None:
|
|
204
|
+
await self._refresh_gpu_cache()
|
|
205
|
+
|
|
206
|
+
assert self._gpu_name_cache is not None
|
|
207
|
+
gpu_id = self._gpu_name_cache.get(gpu_type.lower())
|
|
208
|
+
if gpu_id is None:
|
|
209
|
+
await self._refresh_gpu_cache()
|
|
210
|
+
gpu_id = self._gpu_name_cache.get(gpu_type.lower())
|
|
211
|
+
|
|
212
|
+
if gpu_id is None:
|
|
213
|
+
from ._exceptions import InvalidRequestError
|
|
214
|
+
|
|
215
|
+
raise InvalidRequestError(
|
|
216
|
+
f"Unknown GPU type: '{gpu_type}'. Use client.gpus.list() to see available models.",
|
|
217
|
+
status_code=400,
|
|
218
|
+
type="invalid_request_error",
|
|
219
|
+
code="UNKNOWN_GPU_TYPE",
|
|
220
|
+
)
|
|
221
|
+
return gpu_id
|
|
222
|
+
|
|
223
|
+
async def _refresh_gpu_cache(self) -> None:
|
|
224
|
+
from .types.gpus import GpuModel
|
|
225
|
+
|
|
226
|
+
response = await self.request("GET", "/v1/gpus/models")
|
|
227
|
+
models = self._parse_response_list(response, GpuModel)
|
|
228
|
+
self._gpu_name_cache = {}
|
|
229
|
+
for m in models:
|
|
230
|
+
self._gpu_name_cache[m.name.lower()] = m.id
|
|
231
|
+
short = m.name.split()[0].lower()
|
|
232
|
+
if short not in self._gpu_name_cache:
|
|
233
|
+
self._gpu_name_cache[short] = m.id
|
openrelay/_constants.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
DEFAULT_BASE_URL = "https://api.openrelay.inc"
|
|
2
|
+
DEFAULT_TIMEOUT = 60.0
|
|
3
|
+
DEFAULT_MAX_RETRIES = 2
|
|
4
|
+
INITIAL_RETRY_DELAY = 0.5
|
|
5
|
+
MAX_RETRY_DELAY = 8.0
|
|
6
|
+
|
|
7
|
+
# The variable names the `orl` CLI reads, so one shell setup serves both.
|
|
8
|
+
API_KEY_ENV_VAR = "OPENRELAY_API_KEY"
|
|
9
|
+
BASE_URL_ENV_VAR = "OPENRELAY_API_URL"
|
|
10
|
+
|
|
11
|
+
# The control plane's capacity refusal codes (capacityCodes in
|
|
12
|
+
# services/control-plane-go/capacity_refusal.go). A 503 carrying one means there
|
|
13
|
+
# is no capacity for the request right now; the same request does not make room,
|
|
14
|
+
# so the client returns it to the caller instead of retrying.
|
|
15
|
+
CAPACITY_CODES = frozenset(
|
|
16
|
+
{
|
|
17
|
+
"NODE_AT_CAPACITY",
|
|
18
|
+
"INSUFFICIENT_GPU_CAPACITY",
|
|
19
|
+
"INSUFFICIENT_CPU_CAPACITY",
|
|
20
|
+
"INSUFFICIENT_HOST_CAPACITY",
|
|
21
|
+
"INSUFFICIENT_IP_CAPACITY",
|
|
22
|
+
"VM_NODE_BUSY",
|
|
23
|
+
"POD_NODE_BUSY",
|
|
24
|
+
}
|
|
25
|
+
)
|
openrelay/_exceptions.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class OpenRelayError(Exception):
|
|
7
|
+
"""Base exception for all OpenRelay SDK errors."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(OpenRelayError):
|
|
11
|
+
"""Error returned by the OpenRelay API."""
|
|
12
|
+
|
|
13
|
+
status_code: int
|
|
14
|
+
type: str
|
|
15
|
+
code: Optional[str]
|
|
16
|
+
headers: Dict[str, str]
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
message: str,
|
|
21
|
+
*,
|
|
22
|
+
status_code: int,
|
|
23
|
+
type: str,
|
|
24
|
+
code: Optional[str] = None,
|
|
25
|
+
headers: Optional[Dict[str, str]] = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.type = type
|
|
30
|
+
self.code = code
|
|
31
|
+
self.headers = headers or {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthenticationError(APIError):
|
|
35
|
+
"""401 - Invalid, expired, or revoked API key."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PermissionDeniedError(APIError):
|
|
39
|
+
"""403 - Insufficient permissions or balance."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NotFoundError(APIError):
|
|
43
|
+
"""404 - Resource not found."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InvalidRequestError(APIError):
|
|
47
|
+
"""400 - Validation or bad request error."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RateLimitError(APIError):
|
|
51
|
+
"""429 - Rate limit exceeded."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InternalServerError(APIError):
|
|
55
|
+
"""500+ - Server-side error."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class APIConnectionError(OpenRelayError):
|
|
59
|
+
"""Network connectivity error."""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self, message: str = "Connection error", *, cause: Optional[Exception] = None
|
|
63
|
+
) -> None:
|
|
64
|
+
super().__init__(message)
|
|
65
|
+
self.__cause__ = cause
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class APITimeoutError(APIConnectionError):
|
|
69
|
+
"""Request timed out."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, message: str = "Request timed out") -> None:
|
|
72
|
+
super().__init__(message)
|
openrelay/_types.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
Tier = Literal["community", "verified"]
|
|
6
|
+
ResourceSize = Literal["small", "medium", "large", "xl"]
|
|
7
|
+
VmStatus = Literal[
|
|
8
|
+
"pending", "deploying", "running", "stopped", "failed", "terminated"
|
|
9
|
+
]
|
openrelay/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
openrelay/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, List, Optional
|
|
4
|
+
|
|
5
|
+
from .._types import Tier
|
|
6
|
+
from ..types.gpus import GpuAvailability, GpuModel, GpuPricing
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .._client import AsyncClient, Client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_models_params(
|
|
13
|
+
tier: Optional[Tier] = None,
|
|
14
|
+
regions: Optional[List[str]] = None,
|
|
15
|
+
) -> Optional[dict]:
|
|
16
|
+
params: dict = {}
|
|
17
|
+
if tier:
|
|
18
|
+
params["tier"] = tier
|
|
19
|
+
if regions:
|
|
20
|
+
params["regions"] = ",".join(regions)
|
|
21
|
+
return params or None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_availability_params(
|
|
25
|
+
tier: Optional[Tier] = None,
|
|
26
|
+
region: Optional[str] = None,
|
|
27
|
+
) -> Optional[dict]:
|
|
28
|
+
params: dict = {}
|
|
29
|
+
if tier:
|
|
30
|
+
params["tier"] = tier
|
|
31
|
+
if region:
|
|
32
|
+
params["region"] = region
|
|
33
|
+
return params or None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SyncGpus:
|
|
37
|
+
"""Synchronous GPU operations."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, client: Client) -> None:
|
|
40
|
+
self._client = client
|
|
41
|
+
|
|
42
|
+
def list(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
tier: Optional[Tier] = None,
|
|
46
|
+
regions: Optional[List[str]] = None,
|
|
47
|
+
) -> List[GpuModel]:
|
|
48
|
+
"""List available GPU models."""
|
|
49
|
+
params = _build_models_params(tier, regions)
|
|
50
|
+
response = self._client.request("GET", "/v1/gpus/models", params=params)
|
|
51
|
+
return self._client._parse_response_list(response, GpuModel)
|
|
52
|
+
|
|
53
|
+
def availability(
|
|
54
|
+
self,
|
|
55
|
+
gpu_type: Optional[str] = None,
|
|
56
|
+
*,
|
|
57
|
+
tier: Optional[Tier] = None,
|
|
58
|
+
region: Optional[str] = None,
|
|
59
|
+
) -> GpuAvailability:
|
|
60
|
+
"""Check GPU availability by region."""
|
|
61
|
+
params = _build_availability_params(tier, region)
|
|
62
|
+
response = self._client.request(
|
|
63
|
+
"GET", "/v1/gpus/availability", params=params
|
|
64
|
+
)
|
|
65
|
+
return self._client._parse_response(response, GpuAvailability)
|
|
66
|
+
|
|
67
|
+
def pricing(self) -> List[GpuPricing]:
|
|
68
|
+
"""Get GPU pricing."""
|
|
69
|
+
response = self._client.request("GET", "/v1/gpus/pricing")
|
|
70
|
+
return self._client._parse_response_list(response, GpuPricing)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AsyncGpus:
|
|
74
|
+
"""Asynchronous GPU operations."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
77
|
+
self._client = client
|
|
78
|
+
|
|
79
|
+
async def list(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
tier: Optional[Tier] = None,
|
|
83
|
+
regions: Optional[List[str]] = None,
|
|
84
|
+
) -> List[GpuModel]:
|
|
85
|
+
"""List available GPU models."""
|
|
86
|
+
params = _build_models_params(tier, regions)
|
|
87
|
+
response = await self._client.request(
|
|
88
|
+
"GET", "/v1/gpus/models", params=params
|
|
89
|
+
)
|
|
90
|
+
return self._client._parse_response_list(response, GpuModel)
|
|
91
|
+
|
|
92
|
+
async def availability(
|
|
93
|
+
self,
|
|
94
|
+
gpu_type: Optional[str] = None,
|
|
95
|
+
*,
|
|
96
|
+
tier: Optional[Tier] = None,
|
|
97
|
+
region: Optional[str] = None,
|
|
98
|
+
) -> GpuAvailability:
|
|
99
|
+
"""Check GPU availability by region."""
|
|
100
|
+
params = _build_availability_params(tier, region)
|
|
101
|
+
response = await self._client.request(
|
|
102
|
+
"GET", "/v1/gpus/availability", params=params
|
|
103
|
+
)
|
|
104
|
+
return self._client._parse_response(response, GpuAvailability)
|
|
105
|
+
|
|
106
|
+
async def pricing(self) -> List[GpuPricing]:
|
|
107
|
+
"""Get GPU pricing."""
|
|
108
|
+
response = await self._client.request("GET", "/v1/gpus/pricing")
|
|
109
|
+
return self._client._parse_response_list(response, GpuPricing)
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import TYPE_CHECKING, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .._exceptions import OpenRelayError
|
|
7
|
+
from .._types import ResourceSize, Tier
|
|
8
|
+
from ..types.vms import Vm, VmActionResponse, VmCreateResponse
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .._client import AsyncClient, Client
|
|
12
|
+
|
|
13
|
+
_TERMINAL_STATUSES = {"running", "failed", "stopped", "terminated"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_create_body(
|
|
17
|
+
*,
|
|
18
|
+
name: str,
|
|
19
|
+
tier: Tier = "community",
|
|
20
|
+
# Friendly aliases
|
|
21
|
+
gpu_type: Optional[str] = None,
|
|
22
|
+
container: Optional[str] = None,
|
|
23
|
+
env: Optional[Dict[str, str]] = None,
|
|
24
|
+
# Direct API parameter names
|
|
25
|
+
gpu_model_id: Optional[str] = None,
|
|
26
|
+
gpu_count: int = 0,
|
|
27
|
+
resource_size: ResourceSize = "large",
|
|
28
|
+
guest_mem_mb: Optional[int] = None,
|
|
29
|
+
preferred_regions: Optional[List[str]] = None,
|
|
30
|
+
allow_fallback: bool = False,
|
|
31
|
+
image_url: Optional[str] = None,
|
|
32
|
+
container_port: Optional[int] = None,
|
|
33
|
+
container_command: Optional[List[str]] = None,
|
|
34
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
35
|
+
health_checks: Optional[List[str]] = None,
|
|
36
|
+
health_check_path: str = "/health",
|
|
37
|
+
health_check_port: Optional[int] = None,
|
|
38
|
+
disk_size_gb: Optional[int] = None,
|
|
39
|
+
privileged: bool = False,
|
|
40
|
+
kernel_modules: Optional[List[str]] = None,
|
|
41
|
+
cloud_init_runcmd: Optional[List[str]] = None,
|
|
42
|
+
template_id: Optional[str] = None,
|
|
43
|
+
ssh_key_ids: Optional[List[str]] = None,
|
|
44
|
+
) -> dict:
|
|
45
|
+
"""Build the VM creation request body."""
|
|
46
|
+
body: dict = {
|
|
47
|
+
"name": name,
|
|
48
|
+
"tier": tier,
|
|
49
|
+
"gpuCount": gpu_count,
|
|
50
|
+
"allowFallback": allow_fallback,
|
|
51
|
+
"healthChecks": health_checks or [],
|
|
52
|
+
"healthCheckPath": health_check_path,
|
|
53
|
+
"privileged": privileged,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# resourceSize is the CPU-only VM tier picker and the API rejects it alongside
|
|
57
|
+
# a GPU count, so a GPU create omits it (any value passed for one is dropped
|
|
58
|
+
# rather than turned into a 400). It keeps its "large" default for CPU VMs:
|
|
59
|
+
# the API resolves an omitted size to "small", so sending nothing would
|
|
60
|
+
# quietly hand every existing caller a 2 vCPU / 4 GiB guest instead of the
|
|
61
|
+
# 8 / 16 they have been getting. A GPU VM is sized by guest_mem_mb and
|
|
62
|
+
# disk_size_gb instead, and omitting both asks the API to size it.
|
|
63
|
+
if gpu_count <= 0:
|
|
64
|
+
body["resourceSize"] = resource_size
|
|
65
|
+
if guest_mem_mb is not None:
|
|
66
|
+
body["guestMemMb"] = guest_mem_mb
|
|
67
|
+
if disk_size_gb is not None:
|
|
68
|
+
body["diskSizeGb"] = disk_size_gb
|
|
69
|
+
|
|
70
|
+
if ssh_key_ids:
|
|
71
|
+
body["sshKeyIds"] = ssh_key_ids
|
|
72
|
+
|
|
73
|
+
resolved_gpu = gpu_model_id or gpu_type
|
|
74
|
+
if resolved_gpu:
|
|
75
|
+
body["gpuModelId"] = resolved_gpu
|
|
76
|
+
|
|
77
|
+
resolved_image = image_url or container
|
|
78
|
+
if resolved_image:
|
|
79
|
+
body["imageUrl"] = resolved_image
|
|
80
|
+
|
|
81
|
+
resolved_env = env_vars or env
|
|
82
|
+
if resolved_env:
|
|
83
|
+
body["envVars"] = resolved_env
|
|
84
|
+
|
|
85
|
+
if preferred_regions:
|
|
86
|
+
body["preferredRegions"] = preferred_regions
|
|
87
|
+
if container_port is not None:
|
|
88
|
+
body["containerPort"] = container_port
|
|
89
|
+
if container_command:
|
|
90
|
+
body["containerCommand"] = container_command
|
|
91
|
+
if health_check_port is not None:
|
|
92
|
+
body["healthCheckPort"] = health_check_port
|
|
93
|
+
if kernel_modules:
|
|
94
|
+
body["kernelModules"] = kernel_modules
|
|
95
|
+
if cloud_init_runcmd:
|
|
96
|
+
body["cloudInitRuncmd"] = cloud_init_runcmd
|
|
97
|
+
if template_id:
|
|
98
|
+
body["templateId"] = template_id
|
|
99
|
+
|
|
100
|
+
return body
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SyncVms:
|
|
104
|
+
"""Synchronous VM operations."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, client: Client) -> None:
|
|
107
|
+
self._client = client
|
|
108
|
+
|
|
109
|
+
def get(self, vm_id: str) -> Vm:
|
|
110
|
+
"""Get VM details."""
|
|
111
|
+
response = self._client.request("GET", f"/api/v1/vms/{vm_id}")
|
|
112
|
+
return self._client._parse_response(response, Vm)
|
|
113
|
+
|
|
114
|
+
def create(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
name: str,
|
|
118
|
+
ssh_key_ids: List[str],
|
|
119
|
+
tier: Tier = "community",
|
|
120
|
+
gpu_type: Optional[str] = None,
|
|
121
|
+
container: Optional[str] = None,
|
|
122
|
+
env: Optional[Dict[str, str]] = None,
|
|
123
|
+
gpu_model_id: Optional[str] = None,
|
|
124
|
+
gpu_count: int = 0,
|
|
125
|
+
resource_size: ResourceSize = "large",
|
|
126
|
+
guest_mem_mb: Optional[int] = None,
|
|
127
|
+
preferred_regions: Optional[List[str]] = None,
|
|
128
|
+
allow_fallback: bool = False,
|
|
129
|
+
image_url: Optional[str] = None,
|
|
130
|
+
container_port: Optional[int] = None,
|
|
131
|
+
container_command: Optional[List[str]] = None,
|
|
132
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
133
|
+
health_checks: Optional[List[str]] = None,
|
|
134
|
+
health_check_path: str = "/health",
|
|
135
|
+
health_check_port: Optional[int] = None,
|
|
136
|
+
disk_size_gb: Optional[int] = None,
|
|
137
|
+
privileged: bool = False,
|
|
138
|
+
kernel_modules: Optional[List[str]] = None,
|
|
139
|
+
cloud_init_runcmd: Optional[List[str]] = None,
|
|
140
|
+
template_id: Optional[str] = None,
|
|
141
|
+
) -> VmCreateResponse:
|
|
142
|
+
"""Create a new VM."""
|
|
143
|
+
resolved_gpu = gpu_model_id or gpu_type
|
|
144
|
+
if resolved_gpu:
|
|
145
|
+
resolved_gpu = self._client._resolve_gpu_name(resolved_gpu)
|
|
146
|
+
|
|
147
|
+
body = _build_create_body(
|
|
148
|
+
name=name,
|
|
149
|
+
tier=tier,
|
|
150
|
+
gpu_model_id=resolved_gpu,
|
|
151
|
+
gpu_count=gpu_count,
|
|
152
|
+
resource_size=resource_size,
|
|
153
|
+
guest_mem_mb=guest_mem_mb,
|
|
154
|
+
preferred_regions=preferred_regions,
|
|
155
|
+
allow_fallback=allow_fallback,
|
|
156
|
+
container=container,
|
|
157
|
+
image_url=image_url,
|
|
158
|
+
container_port=container_port,
|
|
159
|
+
container_command=container_command,
|
|
160
|
+
env=env,
|
|
161
|
+
env_vars=env_vars,
|
|
162
|
+
health_checks=health_checks,
|
|
163
|
+
health_check_path=health_check_path,
|
|
164
|
+
health_check_port=health_check_port,
|
|
165
|
+
disk_size_gb=disk_size_gb,
|
|
166
|
+
privileged=privileged,
|
|
167
|
+
kernel_modules=kernel_modules,
|
|
168
|
+
cloud_init_runcmd=cloud_init_runcmd,
|
|
169
|
+
template_id=template_id,
|
|
170
|
+
ssh_key_ids=ssh_key_ids,
|
|
171
|
+
)
|
|
172
|
+
response = self._client.request("POST", "/api/v1/vms", json=body)
|
|
173
|
+
return self._client._parse_response(response, VmCreateResponse)
|
|
174
|
+
|
|
175
|
+
def delete(self, vm_id: str) -> None:
|
|
176
|
+
"""Terminate a VM."""
|
|
177
|
+
self._client.request("DELETE", f"/api/v1/vms/{vm_id}")
|
|
178
|
+
|
|
179
|
+
def stop(self, vm_id: str) -> VmActionResponse:
|
|
180
|
+
"""Stop a running VM."""
|
|
181
|
+
response = self._client.request(
|
|
182
|
+
"POST", f"/api/v1/vms/{vm_id}/stop"
|
|
183
|
+
)
|
|
184
|
+
return self._client._parse_response(response, VmActionResponse)
|
|
185
|
+
|
|
186
|
+
def restart(self, vm_id: str) -> VmActionResponse:
|
|
187
|
+
"""Restart a stopped VM."""
|
|
188
|
+
response = self._client.request(
|
|
189
|
+
"POST", f"/api/v1/vms/{vm_id}/restart"
|
|
190
|
+
)
|
|
191
|
+
return self._client._parse_response(response, VmActionResponse)
|
|
192
|
+
|
|
193
|
+
def reboot(self, vm_id: str) -> VmActionResponse:
|
|
194
|
+
"""Gracefully reboot a running VM."""
|
|
195
|
+
response = self._client.request(
|
|
196
|
+
"POST", f"/api/v1/vms/{vm_id}/reboot"
|
|
197
|
+
)
|
|
198
|
+
return self._client._parse_response(response, VmActionResponse)
|
|
199
|
+
|
|
200
|
+
def wait_for_status(
|
|
201
|
+
self,
|
|
202
|
+
vm_id: str,
|
|
203
|
+
*,
|
|
204
|
+
target: str = "running",
|
|
205
|
+
timeout: float = 600,
|
|
206
|
+
poll_interval: float = 5.0,
|
|
207
|
+
) -> Vm:
|
|
208
|
+
"""Poll until VM reaches target status or a terminal state."""
|
|
209
|
+
start = time.monotonic()
|
|
210
|
+
while True:
|
|
211
|
+
vm = self.get(vm_id)
|
|
212
|
+
if vm.status == target:
|
|
213
|
+
return vm
|
|
214
|
+
if vm.status in _TERMINAL_STATUSES and vm.status != target:
|
|
215
|
+
raise OpenRelayError(
|
|
216
|
+
f"VM reached terminal status '{vm.status}' "
|
|
217
|
+
f"instead of '{target}'"
|
|
218
|
+
)
|
|
219
|
+
elapsed = time.monotonic() - start
|
|
220
|
+
if elapsed >= timeout:
|
|
221
|
+
raise OpenRelayError(
|
|
222
|
+
f"Timed out waiting for VM to reach '{target}' "
|
|
223
|
+
f"(current: '{vm.status}')"
|
|
224
|
+
)
|
|
225
|
+
time.sleep(poll_interval)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class AsyncVms:
|
|
229
|
+
"""Asynchronous VM operations."""
|
|
230
|
+
|
|
231
|
+
def __init__(self, client: AsyncClient) -> None:
|
|
232
|
+
self._client = client
|
|
233
|
+
|
|
234
|
+
async def get(self, vm_id: str) -> Vm:
|
|
235
|
+
"""Get VM details."""
|
|
236
|
+
response = await self._client.request("GET", f"/api/v1/vms/{vm_id}")
|
|
237
|
+
return self._client._parse_response(response, Vm)
|
|
238
|
+
|
|
239
|
+
async def create(
|
|
240
|
+
self,
|
|
241
|
+
*,
|
|
242
|
+
name: str,
|
|
243
|
+
ssh_key_ids: List[str],
|
|
244
|
+
tier: Tier = "community",
|
|
245
|
+
gpu_type: Optional[str] = None,
|
|
246
|
+
container: Optional[str] = None,
|
|
247
|
+
env: Optional[Dict[str, str]] = None,
|
|
248
|
+
gpu_model_id: Optional[str] = None,
|
|
249
|
+
gpu_count: int = 0,
|
|
250
|
+
resource_size: ResourceSize = "large",
|
|
251
|
+
guest_mem_mb: Optional[int] = None,
|
|
252
|
+
preferred_regions: Optional[List[str]] = None,
|
|
253
|
+
allow_fallback: bool = False,
|
|
254
|
+
image_url: Optional[str] = None,
|
|
255
|
+
container_port: Optional[int] = None,
|
|
256
|
+
container_command: Optional[List[str]] = None,
|
|
257
|
+
env_vars: Optional[Dict[str, str]] = None,
|
|
258
|
+
health_checks: Optional[List[str]] = None,
|
|
259
|
+
health_check_path: str = "/health",
|
|
260
|
+
health_check_port: Optional[int] = None,
|
|
261
|
+
disk_size_gb: Optional[int] = None,
|
|
262
|
+
privileged: bool = False,
|
|
263
|
+
kernel_modules: Optional[List[str]] = None,
|
|
264
|
+
cloud_init_runcmd: Optional[List[str]] = None,
|
|
265
|
+
template_id: Optional[str] = None,
|
|
266
|
+
) -> VmCreateResponse:
|
|
267
|
+
"""Create a new VM."""
|
|
268
|
+
resolved_gpu = gpu_model_id or gpu_type
|
|
269
|
+
if resolved_gpu:
|
|
270
|
+
resolved_gpu = await self._client._resolve_gpu_name(resolved_gpu)
|
|
271
|
+
|
|
272
|
+
body = _build_create_body(
|
|
273
|
+
name=name,
|
|
274
|
+
tier=tier,
|
|
275
|
+
gpu_model_id=resolved_gpu,
|
|
276
|
+
gpu_count=gpu_count,
|
|
277
|
+
resource_size=resource_size,
|
|
278
|
+
guest_mem_mb=guest_mem_mb,
|
|
279
|
+
preferred_regions=preferred_regions,
|
|
280
|
+
allow_fallback=allow_fallback,
|
|
281
|
+
container=container,
|
|
282
|
+
image_url=image_url,
|
|
283
|
+
container_port=container_port,
|
|
284
|
+
container_command=container_command,
|
|
285
|
+
env=env,
|
|
286
|
+
env_vars=env_vars,
|
|
287
|
+
health_checks=health_checks,
|
|
288
|
+
health_check_path=health_check_path,
|
|
289
|
+
health_check_port=health_check_port,
|
|
290
|
+
disk_size_gb=disk_size_gb,
|
|
291
|
+
privileged=privileged,
|
|
292
|
+
kernel_modules=kernel_modules,
|
|
293
|
+
cloud_init_runcmd=cloud_init_runcmd,
|
|
294
|
+
template_id=template_id,
|
|
295
|
+
ssh_key_ids=ssh_key_ids,
|
|
296
|
+
)
|
|
297
|
+
response = await self._client.request("POST", "/api/v1/vms", json=body)
|
|
298
|
+
return self._client._parse_response(response, VmCreateResponse)
|
|
299
|
+
|
|
300
|
+
async def delete(self, vm_id: str) -> None:
|
|
301
|
+
"""Terminate a VM."""
|
|
302
|
+
await self._client.request("DELETE", f"/api/v1/vms/{vm_id}")
|
|
303
|
+
|
|
304
|
+
async def stop(self, vm_id: str) -> VmActionResponse:
|
|
305
|
+
"""Stop a running VM."""
|
|
306
|
+
response = await self._client.request(
|
|
307
|
+
"POST", f"/api/v1/vms/{vm_id}/stop"
|
|
308
|
+
)
|
|
309
|
+
return self._client._parse_response(response, VmActionResponse)
|
|
310
|
+
|
|
311
|
+
async def restart(self, vm_id: str) -> VmActionResponse:
|
|
312
|
+
"""Restart a stopped VM."""
|
|
313
|
+
response = await self._client.request(
|
|
314
|
+
"POST", f"/api/v1/vms/{vm_id}/restart"
|
|
315
|
+
)
|
|
316
|
+
return self._client._parse_response(response, VmActionResponse)
|
|
317
|
+
|
|
318
|
+
async def reboot(self, vm_id: str) -> VmActionResponse:
|
|
319
|
+
"""Gracefully reboot a running VM."""
|
|
320
|
+
response = await self._client.request(
|
|
321
|
+
"POST", f"/api/v1/vms/{vm_id}/reboot"
|
|
322
|
+
)
|
|
323
|
+
return self._client._parse_response(response, VmActionResponse)
|
|
324
|
+
|
|
325
|
+
async def wait_for_status(
|
|
326
|
+
self,
|
|
327
|
+
vm_id: str,
|
|
328
|
+
*,
|
|
329
|
+
target: str = "running",
|
|
330
|
+
timeout: float = 600,
|
|
331
|
+
poll_interval: float = 5.0,
|
|
332
|
+
) -> Vm:
|
|
333
|
+
"""Poll until VM reaches target status or a terminal state."""
|
|
334
|
+
import asyncio
|
|
335
|
+
|
|
336
|
+
start = time.monotonic()
|
|
337
|
+
while True:
|
|
338
|
+
vm = await self.get(vm_id)
|
|
339
|
+
if vm.status == target:
|
|
340
|
+
return vm
|
|
341
|
+
if vm.status in _TERMINAL_STATUSES and vm.status != target:
|
|
342
|
+
raise OpenRelayError(
|
|
343
|
+
f"VM reached terminal status '{vm.status}' "
|
|
344
|
+
f"instead of '{target}'"
|
|
345
|
+
)
|
|
346
|
+
elapsed = time.monotonic() - start
|
|
347
|
+
if elapsed >= timeout:
|
|
348
|
+
raise OpenRelayError(
|
|
349
|
+
f"Timed out waiting for VM to reach '{target}' "
|
|
350
|
+
f"(current: '{vm.status}')"
|
|
351
|
+
)
|
|
352
|
+
await asyncio.sleep(poll_interval)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .gpus import (
|
|
2
|
+
GpuAvailability,
|
|
3
|
+
GpuAvailabilityRegion,
|
|
4
|
+
GpuModel,
|
|
5
|
+
GpuModelAvailability,
|
|
6
|
+
GpuPricing,
|
|
7
|
+
GpuPricingTier,
|
|
8
|
+
)
|
|
9
|
+
from .vms import Vm, VmCreateResponse, VmActionResponse
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"GpuAvailability",
|
|
13
|
+
"GpuAvailabilityRegion",
|
|
14
|
+
"GpuModel",
|
|
15
|
+
"GpuModelAvailability",
|
|
16
|
+
"GpuPricing",
|
|
17
|
+
"GpuPricingTier",
|
|
18
|
+
"Vm",
|
|
19
|
+
"VmCreateResponse",
|
|
20
|
+
"VmActionResponse",
|
|
21
|
+
]
|
openrelay/types/gpus.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from .shared import APIModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GpuModelAvailability(APIModel):
|
|
9
|
+
total_gpus: int
|
|
10
|
+
node_count: int
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GpuModel(APIModel):
|
|
14
|
+
"""GPU model returned by /v1/gpus/models."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
name: str
|
|
18
|
+
manufacturer: str
|
|
19
|
+
vram_gb: int
|
|
20
|
+
architecture: Optional[str] = None
|
|
21
|
+
availability: GpuModelAvailability
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GpuAvailabilityRegion(APIModel):
|
|
25
|
+
gpu_model: str
|
|
26
|
+
vram_gb: int
|
|
27
|
+
manufacturer: str
|
|
28
|
+
region: str
|
|
29
|
+
location: str
|
|
30
|
+
country: str
|
|
31
|
+
tier: str
|
|
32
|
+
total_gpus: int
|
|
33
|
+
node_count: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GpuAvailability(APIModel):
|
|
37
|
+
timestamp: str
|
|
38
|
+
availability: List[GpuAvailabilityRegion]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GpuPricingTier(APIModel):
|
|
42
|
+
price_per_hour: float
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GpuPricing(APIModel):
|
|
46
|
+
gpu_model: str
|
|
47
|
+
vram_gb: int
|
|
48
|
+
pricing: Dict[str, GpuPricingTier]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from pydantic import BaseModel, ConfigDict
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def to_camel(snake: str) -> str:
|
|
5
|
+
components = snake.split("_")
|
|
6
|
+
return components[0] + "".join(x.title() for x in components[1:])
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class APIModel(BaseModel):
|
|
10
|
+
model_config = ConfigDict(
|
|
11
|
+
alias_generator=to_camel,
|
|
12
|
+
populate_by_name=True,
|
|
13
|
+
from_attributes=True,
|
|
14
|
+
)
|
openrelay/types/vms.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
from .shared import APIModel
|
|
7
|
+
from .._types import ResourceSize, Tier, VmStatus
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class VmGpuInfo(APIModel):
|
|
11
|
+
name: str
|
|
12
|
+
vram_gb: int
|
|
13
|
+
price_per_hour_cents: int
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VmNodeInfo(APIModel):
|
|
17
|
+
hostname: str
|
|
18
|
+
location: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Vm(APIModel):
|
|
22
|
+
"""VM returned by get endpoint."""
|
|
23
|
+
|
|
24
|
+
id: str
|
|
25
|
+
organization_id: str
|
|
26
|
+
name: str
|
|
27
|
+
status: VmStatus
|
|
28
|
+
gpu_model_id: Optional[str] = None
|
|
29
|
+
gpu_count: int
|
|
30
|
+
resource_size: ResourceSize
|
|
31
|
+
tier: Tier
|
|
32
|
+
disk_size_gb: int
|
|
33
|
+
image_url: Optional[str] = None
|
|
34
|
+
container_port: Optional[int] = None
|
|
35
|
+
container_command: Optional[List[str]] = None
|
|
36
|
+
env_vars: Optional[Dict[str, str]] = None
|
|
37
|
+
health_checks: List[str] = []
|
|
38
|
+
health_check_path: Optional[str] = None
|
|
39
|
+
health_check_port: Optional[int] = None
|
|
40
|
+
privileged: bool = False
|
|
41
|
+
kernel_modules: Optional[List[str]] = None
|
|
42
|
+
cloud_init_runcmd: Optional[List[str]] = None
|
|
43
|
+
template_id: Optional[str] = None
|
|
44
|
+
endpoint_url: Optional[str] = None
|
|
45
|
+
preferred_regions: Optional[List[str]] = None
|
|
46
|
+
allow_fallback: bool = False
|
|
47
|
+
healthy: bool = False
|
|
48
|
+
node_id: Optional[str] = None
|
|
49
|
+
internal_ip: Optional[str] = None
|
|
50
|
+
port: Optional[int] = None
|
|
51
|
+
ssh_port: Optional[int] = None
|
|
52
|
+
persistent_disk_path: Optional[str] = None
|
|
53
|
+
disk_used_bytes: Optional[int] = None
|
|
54
|
+
disk_total_bytes: Optional[int] = None
|
|
55
|
+
started_at: Optional[datetime] = None
|
|
56
|
+
stopped_at: Optional[datetime] = None
|
|
57
|
+
created_at: datetime
|
|
58
|
+
updated_at: datetime
|
|
59
|
+
gpu_info: Optional[VmGpuInfo] = None
|
|
60
|
+
node_info: Optional[VmNodeInfo] = None
|
|
61
|
+
price_per_hour_cents: Optional[int] = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class VmCreateResponse(APIModel):
|
|
65
|
+
"""Response from VM creation."""
|
|
66
|
+
|
|
67
|
+
id: str
|
|
68
|
+
name: str
|
|
69
|
+
status: VmStatus
|
|
70
|
+
endpoint_url: Optional[str] = None
|
|
71
|
+
created_at: datetime
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class VmActionResponse(APIModel):
|
|
75
|
+
"""Response from VM stop/restart/reboot/delete."""
|
|
76
|
+
|
|
77
|
+
success: bool
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openrelay-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The official Python SDK for the OpenRelay API
|
|
5
|
+
Project-URL: Homepage, https://openrelay.inc
|
|
6
|
+
Project-URL: Documentation, https://docs.openrelay.inc
|
|
7
|
+
Project-URL: Repository, https://github.com/OpenRelayInc/OpenRelay
|
|
8
|
+
Project-URL: Issues, https://github.com/OpenRelayInc/OpenRelay/issues
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Requires-Dist: httpx<1,>=0.25.0
|
|
23
|
+
Requires-Dist: pydantic<3,>=2.0.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff>=0.1; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# OpenRelay Python SDK
|
|
33
|
+
|
|
34
|
+
The official Python SDK for the [OpenRelay](https://openrelay.inc) API.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install openrelay-sdk
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The package imports as `openrelay`. The unrelated `openrelay` distribution on
|
|
43
|
+
PyPI also provides a top-level `openrelay` module, so install `openrelay-sdk`,
|
|
44
|
+
and do not install both into the same environment.
|
|
45
|
+
|
|
46
|
+
From a checkout of this repository: `pip install ./sdks/python`.
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import openrelay
|
|
52
|
+
|
|
53
|
+
client = openrelay.Client(api_key="or_your_api_key")
|
|
54
|
+
|
|
55
|
+
# List available GPUs
|
|
56
|
+
gpus = client.gpus.list()
|
|
57
|
+
|
|
58
|
+
# Create a VM
|
|
59
|
+
vm = client.vms.create(
|
|
60
|
+
name="dev-box",
|
|
61
|
+
ssh_key_ids=["your-ssh-key-id"],
|
|
62
|
+
gpu_type="rtx-4090",
|
|
63
|
+
gpu_count=1,
|
|
64
|
+
disk_size_gb=100,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Wait for it to be ready
|
|
68
|
+
vm = client.vms.wait_for_status(vm.id)
|
|
69
|
+
print(f"VM running at: {vm.endpoint_url}")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Async Usage
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
import asyncio
|
|
76
|
+
import openrelay
|
|
77
|
+
|
|
78
|
+
async def main():
|
|
79
|
+
async with openrelay.AsyncClient(api_key="or_your_api_key") as client:
|
|
80
|
+
vm = await client.vms.get("your-vm-id")
|
|
81
|
+
print(vm.status)
|
|
82
|
+
|
|
83
|
+
asyncio.run(main())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
The client accepts these parameters:
|
|
89
|
+
|
|
90
|
+
| Parameter | Env Variable | Default |
|
|
91
|
+
|-----------|-------------|---------|
|
|
92
|
+
| `api_key` | `OPENRELAY_API_KEY` | Required |
|
|
93
|
+
| `base_url` | `OPENRELAY_API_URL` | `https://api.openrelay.inc` |
|
|
94
|
+
| `timeout` | (none) | `60.0` seconds |
|
|
95
|
+
| `max_retries` | (none) | `2` |
|
|
96
|
+
|
|
97
|
+
The `orl` CLI reads the same two variables.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
openrelay/__init__.py,sha256=qTg21KjF-BbvhJkNwl3BNeNnM-NKYMrclehWyJ3YK1A,1130
|
|
2
|
+
openrelay/_base_client.py,sha256=zH--VRw08GKliaDYOSnGLizrSjTz-eNeAX6kFjjQz4I,5575
|
|
3
|
+
openrelay/_client.py,sha256=gKBNCql7SbYebd1FytvZDkqhIl2lXGcbi0P8IIowcDA,7581
|
|
4
|
+
openrelay/_constants.py,sha256=eOCheHCQssPNT-3mDqRg5F0imnAunDBok1BANpZT83Y,849
|
|
5
|
+
openrelay/_exceptions.py,sha256=fHy2QmSAFGb1AXCl2leWocXNTWo0d1Qq4aSkuQIL350,1635
|
|
6
|
+
openrelay/_types.py,sha256=8OEwdQxplL-9KhS7wV_9v5wT2sfPoiELQUk3zVTmHCk,256
|
|
7
|
+
openrelay/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
8
|
+
openrelay/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
openrelay/resources/__init__.py,sha256=AhzKNY7t9g_aV0TRNqQbMKNeW57ybTLRBv-Wu7TNmMY,152
|
|
10
|
+
openrelay/resources/gpus.py,sha256=oNhfSGmsPPK4tJeWtZp5rzvFW83e1efw9PT7-1D97jk,3271
|
|
11
|
+
openrelay/resources/vms.py,sha256=wJyOwkMvMZUXvsVcCRKmu_cJAucRKW6pbdplg0AoeL0,12664
|
|
12
|
+
openrelay/types/__init__.py,sha256=a_1kWizar876P8CjSW36kyACcizBD6hnGHQcsor31mA,411
|
|
13
|
+
openrelay/types/gpus.py,sha256=wdlZpNZLDSBRtb0WOdJL4irFAFMyAGZKhjGO88mRcps,876
|
|
14
|
+
openrelay/types/shared.py,sha256=UKYfKdg2_ZdexNfJKVQyNOQz7_C7cjTTrND1qzcN1U8,343
|
|
15
|
+
openrelay/types/vms.py,sha256=msWPxym1dpwLrclcBqF1YXwxxKVW8KnTkXqM1F2ptWo,2040
|
|
16
|
+
openrelay_sdk-0.1.0.dist-info/METADATA,sha256=25eTZDg0IgFyLgtK5k_PNEOS2vJDRb2z-hfCHxYYw_M,2744
|
|
17
|
+
openrelay_sdk-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
18
|
+
openrelay_sdk-0.1.0.dist-info/RECORD,,
|