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 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
@@ -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
+ )
@@ -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,9 @@
1
+ from .gpus import AsyncGpus, SyncGpus
2
+ from .vms import AsyncVms, SyncVms
3
+
4
+ __all__ = [
5
+ "SyncGpus",
6
+ "AsyncGpus",
7
+ "SyncVms",
8
+ "AsyncVms",
9
+ ]
@@ -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
+ ]
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any