tetra-rp 0.5.5__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.
- tetra_rp/__init__.py +37 -0
- tetra_rp/client.py +59 -0
- tetra_rp/core/__init__.py +0 -0
- tetra_rp/core/api/__init__.py +5 -0
- tetra_rp/core/api/runpod.py +212 -0
- tetra_rp/core/pool/__init__.py +0 -0
- tetra_rp/core/pool/cluster_manager.py +177 -0
- tetra_rp/core/pool/dataclass.py +18 -0
- tetra_rp/core/pool/ex.py +38 -0
- tetra_rp/core/pool/job.py +22 -0
- tetra_rp/core/pool/worker.py +19 -0
- tetra_rp/core/resources/__init__.py +33 -0
- tetra_rp/core/resources/base.py +47 -0
- tetra_rp/core/resources/cloud.py +4 -0
- tetra_rp/core/resources/cpu.py +34 -0
- tetra_rp/core/resources/environment.py +41 -0
- tetra_rp/core/resources/gpu.py +53 -0
- tetra_rp/core/resources/live_serverless.py +32 -0
- tetra_rp/core/resources/resource_manager.py +80 -0
- tetra_rp/core/resources/serverless.py +476 -0
- tetra_rp/core/resources/template.py +94 -0
- tetra_rp/core/resources/utils.py +50 -0
- tetra_rp/core/utils/__init__.py +0 -0
- tetra_rp/core/utils/backoff.py +43 -0
- tetra_rp/core/utils/json.py +33 -0
- tetra_rp/core/utils/singleton.py +7 -0
- tetra_rp/logger.py +34 -0
- tetra_rp/protos/__init__.py +0 -0
- tetra_rp/protos/remote_execution.py +57 -0
- tetra_rp/stubs/__init__.py +5 -0
- tetra_rp/stubs/live_serverless.py +133 -0
- tetra_rp/stubs/registry.py +85 -0
- tetra_rp/stubs/serverless.py +30 -0
- tetra_rp-0.5.5.dist-info/METADATA +806 -0
- tetra_rp-0.5.5.dist-info/RECORD +37 -0
- tetra_rp-0.5.5.dist-info/WHEEL +5 -0
- tetra_rp-0.5.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any, Dict, List, Optional
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from pydantic import (
|
|
7
|
+
field_serializer,
|
|
8
|
+
field_validator,
|
|
9
|
+
model_validator,
|
|
10
|
+
BaseModel,
|
|
11
|
+
Field,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from runpod.endpoint.runner import Job
|
|
15
|
+
|
|
16
|
+
from ..api.runpod import RunpodGraphQLClient
|
|
17
|
+
from ..utils.backoff import get_backoff_delay
|
|
18
|
+
|
|
19
|
+
from .cloud import runpod
|
|
20
|
+
from .base import DeployableResource
|
|
21
|
+
from .template import PodTemplate, KeyValuePair
|
|
22
|
+
from .gpu import GpuGroup
|
|
23
|
+
from .cpu import CpuInstanceType
|
|
24
|
+
from .environment import EnvironmentVars
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Environment variables are loaded from the .env file
|
|
28
|
+
def get_env_vars() -> Dict[str, str]:
|
|
29
|
+
"""
|
|
30
|
+
Returns the environment variables from the .env file.
|
|
31
|
+
{
|
|
32
|
+
"KEY": "VALUE",
|
|
33
|
+
}
|
|
34
|
+
"""
|
|
35
|
+
env_vars = EnvironmentVars()
|
|
36
|
+
return env_vars.get_env()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
log = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
CONSOLE_BASE_URL = os.environ.get("CONSOLE_BASE_URL", "https://console.runpod.io")
|
|
43
|
+
CONSOLE_URL = f"{CONSOLE_BASE_URL}/serverless/user/endpoint/%s"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ServerlessScalerType(Enum):
|
|
47
|
+
QUEUE_DELAY = "QUEUE_DELAY"
|
|
48
|
+
REQUEST_COUNT = "REQUEST_COUNT"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CudaVersion(Enum):
|
|
52
|
+
V11_8 = "11.8"
|
|
53
|
+
V12_0 = "12.0"
|
|
54
|
+
V12_1 = "12.1"
|
|
55
|
+
V12_2 = "12.2"
|
|
56
|
+
V12_3 = "12.3"
|
|
57
|
+
V12_4 = "12.4"
|
|
58
|
+
V12_5 = "12.5"
|
|
59
|
+
V12_6 = "12.6"
|
|
60
|
+
V12_7 = "12.7"
|
|
61
|
+
V12_8 = "12.8"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ServerlessResource(DeployableResource):
|
|
65
|
+
"""
|
|
66
|
+
Base class for GPU serverless resource
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
_input_only = {"id", "cudaVersions", "env", "gpus", "flashboot", "imageName"}
|
|
70
|
+
|
|
71
|
+
# === Input-only Fields ===
|
|
72
|
+
cudaVersions: Optional[List[CudaVersion]] = [] # for allowedCudaVersions
|
|
73
|
+
env: Optional[Dict[str, str]] = Field(default_factory=get_env_vars)
|
|
74
|
+
flashboot: Optional[bool] = True
|
|
75
|
+
gpus: Optional[List[GpuGroup]] = [GpuGroup.ANY] # for gpuIds
|
|
76
|
+
imageName: Optional[str] = "" # for template.imageName
|
|
77
|
+
|
|
78
|
+
# === Input Fields ===
|
|
79
|
+
executionTimeoutMs: Optional[int] = None
|
|
80
|
+
gpuCount: Optional[int] = 1
|
|
81
|
+
idleTimeout: Optional[int] = 5
|
|
82
|
+
instanceIds: Optional[List[CpuInstanceType]] = None
|
|
83
|
+
locations: Optional[str] = None
|
|
84
|
+
name: str
|
|
85
|
+
networkVolumeId: Optional[str] = None
|
|
86
|
+
scalerType: Optional[ServerlessScalerType] = ServerlessScalerType.QUEUE_DELAY
|
|
87
|
+
scalerValue: Optional[int] = 4
|
|
88
|
+
templateId: Optional[str] = None
|
|
89
|
+
workersMax: Optional[int] = 3
|
|
90
|
+
workersMin: Optional[int] = 0
|
|
91
|
+
workersPFBTarget: Optional[int] = None
|
|
92
|
+
|
|
93
|
+
# === Runtime Fields ===
|
|
94
|
+
activeBuildid: Optional[str] = None
|
|
95
|
+
aiKey: Optional[str] = None
|
|
96
|
+
allowedCudaVersions: Optional[str] = None
|
|
97
|
+
computeType: Optional[str] = None
|
|
98
|
+
createdAt: Optional[str] = None # TODO: use datetime
|
|
99
|
+
gpuIds: Optional[str] = ""
|
|
100
|
+
hubRelease: Optional[str] = None
|
|
101
|
+
repo: Optional[str] = None
|
|
102
|
+
template: Optional[PodTemplate] = None
|
|
103
|
+
userId: Optional[str] = None
|
|
104
|
+
|
|
105
|
+
def __str__(self) -> str:
|
|
106
|
+
return f"{self.__class__.__name__}:{self.id}"
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def url(self) -> str:
|
|
110
|
+
if not self.id:
|
|
111
|
+
raise ValueError("Missing self.id")
|
|
112
|
+
return CONSOLE_URL % self.id
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def endpoint(self) -> runpod.Endpoint:
|
|
116
|
+
"""
|
|
117
|
+
Returns the Runpod endpoint object for this serverless resource.
|
|
118
|
+
"""
|
|
119
|
+
if not self.id:
|
|
120
|
+
raise ValueError("Missing self.id")
|
|
121
|
+
return runpod.Endpoint(self.id)
|
|
122
|
+
|
|
123
|
+
@field_serializer("scalerType")
|
|
124
|
+
def serialize_scaler_type(
|
|
125
|
+
self, value: Optional[ServerlessScalerType]
|
|
126
|
+
) -> Optional[str]:
|
|
127
|
+
"""Convert ServerlessScalerType enum to string."""
|
|
128
|
+
return value.value if value is not None else None
|
|
129
|
+
|
|
130
|
+
@field_serializer("instanceIds")
|
|
131
|
+
def serialize_instance_ids(self, value: List[CpuInstanceType]) -> List[str]:
|
|
132
|
+
"""Convert CpuInstanceType enums to strings."""
|
|
133
|
+
return [item.value if hasattr(item, "value") else str(item) for item in value]
|
|
134
|
+
|
|
135
|
+
@field_validator("gpus")
|
|
136
|
+
@classmethod
|
|
137
|
+
def validate_gpus(cls, value: List[GpuGroup]) -> List[GpuGroup]:
|
|
138
|
+
"""Expand ANY to all GPU groups"""
|
|
139
|
+
if value == [GpuGroup.ANY]:
|
|
140
|
+
return GpuGroup.all()
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
@model_validator(mode="after")
|
|
144
|
+
def sync_input_fields(self):
|
|
145
|
+
"""Sync between temporary inputs and exported fields"""
|
|
146
|
+
if self.flashboot:
|
|
147
|
+
self.name += "-fb"
|
|
148
|
+
|
|
149
|
+
if self.instanceIds:
|
|
150
|
+
return self._sync_input_fields_cpu()
|
|
151
|
+
else:
|
|
152
|
+
return self._sync_input_fields_gpu()
|
|
153
|
+
|
|
154
|
+
def _sync_input_fields_gpu(self):
|
|
155
|
+
# GPU-specific fields
|
|
156
|
+
if self.gpus:
|
|
157
|
+
# Convert gpus list to gpuIds string
|
|
158
|
+
self.gpuIds = ",".join(gpu.value for gpu in self.gpus)
|
|
159
|
+
elif self.gpuIds:
|
|
160
|
+
# Convert gpuIds string to gpus list (from backend responses)
|
|
161
|
+
gpu_values = [v.strip() for v in self.gpuIds.split(",") if v.strip()]
|
|
162
|
+
self.gpus = [GpuGroup(value) for value in gpu_values]
|
|
163
|
+
|
|
164
|
+
if self.cudaVersions:
|
|
165
|
+
# Convert cudaVersions list to allowedCudaVersions string
|
|
166
|
+
self.allowedCudaVersions = ",".join(v.value for v in self.cudaVersions)
|
|
167
|
+
elif self.allowedCudaVersions:
|
|
168
|
+
# Convert allowedCudaVersions string to cudaVersions list (from backend responses)
|
|
169
|
+
version_values = [
|
|
170
|
+
v.strip() for v in self.allowedCudaVersions.split(",") if v.strip()
|
|
171
|
+
]
|
|
172
|
+
self.cudaVersions = [CudaVersion(value) for value in version_values]
|
|
173
|
+
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def _sync_input_fields_cpu(self):
|
|
177
|
+
# Override GPU-specific fields for CPU
|
|
178
|
+
self.gpuCount = 0
|
|
179
|
+
self.allowedCudaVersions = ""
|
|
180
|
+
self.gpuIds = ""
|
|
181
|
+
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
def is_deployed(self) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Checks if the serverless resource is deployed and available.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
if not self.id:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
response = self.endpoint.health()
|
|
193
|
+
return response is not None
|
|
194
|
+
except Exception as e:
|
|
195
|
+
log.error(f"Error checking {self}: {e}")
|
|
196
|
+
return False
|
|
197
|
+
|
|
198
|
+
async def deploy(self) -> "DeployableResource":
|
|
199
|
+
"""
|
|
200
|
+
Deploys the serverless resource using the provided configuration.
|
|
201
|
+
Returns a DeployableResource object.
|
|
202
|
+
"""
|
|
203
|
+
try:
|
|
204
|
+
# If the resource is already deployed, return it
|
|
205
|
+
if self.is_deployed():
|
|
206
|
+
log.debug(f"{self} exists")
|
|
207
|
+
return self
|
|
208
|
+
|
|
209
|
+
async with RunpodGraphQLClient() as client:
|
|
210
|
+
payload = self.model_dump(exclude=self._input_only, exclude_none=True)
|
|
211
|
+
result = await client.create_endpoint(payload)
|
|
212
|
+
|
|
213
|
+
if endpoint := self.__class__(**result):
|
|
214
|
+
return endpoint
|
|
215
|
+
|
|
216
|
+
raise ValueError("Deployment failed, no endpoint was returned.")
|
|
217
|
+
|
|
218
|
+
except Exception as e:
|
|
219
|
+
log.error(f"{self} failed to deploy: {e}")
|
|
220
|
+
raise
|
|
221
|
+
|
|
222
|
+
async def is_ready_for_requests(self, give_up_threshold=10) -> bool:
|
|
223
|
+
"""
|
|
224
|
+
Asynchronously checks if the serverless resource is ready to handle
|
|
225
|
+
requests by polling its health endpoint.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
give_up_threshold (int, optional): The maximum number of polling
|
|
229
|
+
attempts before giving up and raising an error. Defaults to 10.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
bool: True if the serverless resource is ready for requests.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
ValueError: If the serverless resource is not deployed.
|
|
236
|
+
RuntimeError: If the health status is THROTTLED, UNHEALTHY, or UNKNOWN
|
|
237
|
+
after exceeding the give_up_threshold.
|
|
238
|
+
"""
|
|
239
|
+
if not self.is_deployed():
|
|
240
|
+
raise ValueError("Serverless is not deployed")
|
|
241
|
+
|
|
242
|
+
log.debug(f"{self} | API /health")
|
|
243
|
+
|
|
244
|
+
current_pace = 0
|
|
245
|
+
attempt = 0
|
|
246
|
+
|
|
247
|
+
# Poll for health status
|
|
248
|
+
while True:
|
|
249
|
+
await asyncio.sleep(current_pace)
|
|
250
|
+
|
|
251
|
+
health = await asyncio.to_thread(self.endpoint.health)
|
|
252
|
+
health = ServerlessHealth(**health)
|
|
253
|
+
|
|
254
|
+
if health.is_ready:
|
|
255
|
+
return True
|
|
256
|
+
else:
|
|
257
|
+
# nothing changed, increase the gap
|
|
258
|
+
attempt += 1
|
|
259
|
+
indicator = "." * (attempt // 2) if attempt % 2 == 0 else ""
|
|
260
|
+
if indicator:
|
|
261
|
+
log.info(f"{self} | {indicator}")
|
|
262
|
+
|
|
263
|
+
status = health.workers.status
|
|
264
|
+
if status in [
|
|
265
|
+
Status.THROTTLED,
|
|
266
|
+
Status.UNHEALTHY,
|
|
267
|
+
Status.UNKNOWN,
|
|
268
|
+
]:
|
|
269
|
+
log.debug(f"{self} | Health {status.value}")
|
|
270
|
+
|
|
271
|
+
if attempt >= give_up_threshold:
|
|
272
|
+
# Give up
|
|
273
|
+
raise RuntimeError(f"Health {status.value}")
|
|
274
|
+
|
|
275
|
+
# Adjust polling pace appropriately
|
|
276
|
+
current_pace = get_backoff_delay(attempt)
|
|
277
|
+
|
|
278
|
+
async def run_sync(self, payload: Dict[str, Any]) -> "JobOutput":
|
|
279
|
+
"""
|
|
280
|
+
Executes a serverless endpoint request with the payload.
|
|
281
|
+
Returns a JobOutput object.
|
|
282
|
+
"""
|
|
283
|
+
if not self.id:
|
|
284
|
+
raise ValueError("Serverless is not deployed")
|
|
285
|
+
|
|
286
|
+
def _fetch_job():
|
|
287
|
+
return self.endpoint.rp_client.post(
|
|
288
|
+
f"{self.id}/runsync", payload, timeout=60
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
# log.debug(f"[{log_group}] Payload: {payload}")
|
|
293
|
+
|
|
294
|
+
# Poll until requests can be sent
|
|
295
|
+
await self.is_ready_for_requests()
|
|
296
|
+
|
|
297
|
+
log.info(f"{self} | API /run_sync")
|
|
298
|
+
response = await asyncio.to_thread(_fetch_job)
|
|
299
|
+
return JobOutput(**response)
|
|
300
|
+
|
|
301
|
+
except Exception as e:
|
|
302
|
+
health = await asyncio.to_thread(self.endpoint.health)
|
|
303
|
+
health = ServerlessHealth(**health)
|
|
304
|
+
log.info(f"{self} | Health {health.workers.status}")
|
|
305
|
+
log.error(f"{self} | Exception: {e}")
|
|
306
|
+
raise
|
|
307
|
+
|
|
308
|
+
async def run(self, payload: Dict[str, Any]) -> "JobOutput":
|
|
309
|
+
"""
|
|
310
|
+
Executes a serverless endpoint async request with the payload.
|
|
311
|
+
Returns a JobOutput object.
|
|
312
|
+
"""
|
|
313
|
+
if not self.id:
|
|
314
|
+
raise ValueError("Serverless is not deployed")
|
|
315
|
+
|
|
316
|
+
job: Optional[Job] = None
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
# log.debug(f"[{self}] Payload: {payload}")
|
|
320
|
+
|
|
321
|
+
# Poll until requests can be sent
|
|
322
|
+
await self.is_ready_for_requests()
|
|
323
|
+
|
|
324
|
+
# Create a job using the endpoint
|
|
325
|
+
log.info(f"{self} | API /run")
|
|
326
|
+
job = await asyncio.to_thread(self.endpoint.run, request_input=payload)
|
|
327
|
+
|
|
328
|
+
log_subgroup = f"Job:{job.job_id}"
|
|
329
|
+
|
|
330
|
+
log.info(f"{self} | Started {log_subgroup}")
|
|
331
|
+
|
|
332
|
+
current_pace = 0
|
|
333
|
+
attempt = 0
|
|
334
|
+
job_status = Status.UNKNOWN
|
|
335
|
+
last_status = job_status
|
|
336
|
+
|
|
337
|
+
# Poll for job status
|
|
338
|
+
while True:
|
|
339
|
+
await asyncio.sleep(current_pace)
|
|
340
|
+
|
|
341
|
+
if await self.is_ready_for_requests():
|
|
342
|
+
# Check job status
|
|
343
|
+
job_status = await asyncio.to_thread(job.status)
|
|
344
|
+
|
|
345
|
+
if last_status == job_status:
|
|
346
|
+
# nothing changed, increase the gap
|
|
347
|
+
attempt += 1
|
|
348
|
+
indicator = "." * (attempt // 2) if attempt % 2 == 0 else ""
|
|
349
|
+
if indicator:
|
|
350
|
+
log.info(f"{log_subgroup} | {indicator}")
|
|
351
|
+
else:
|
|
352
|
+
# status changed, reset the gap
|
|
353
|
+
log.info(f"{log_subgroup} | Status: {job_status}")
|
|
354
|
+
attempt = 0
|
|
355
|
+
|
|
356
|
+
last_status = job_status
|
|
357
|
+
|
|
358
|
+
# Adjust polling pace appropriately
|
|
359
|
+
current_pace = get_backoff_delay(attempt)
|
|
360
|
+
|
|
361
|
+
if job_status in ("COMPLETED", "FAILED", "CANCELLED"):
|
|
362
|
+
response = await asyncio.to_thread(job._fetch_job)
|
|
363
|
+
return JobOutput(**response)
|
|
364
|
+
|
|
365
|
+
except Exception as e:
|
|
366
|
+
if job and job.job_id:
|
|
367
|
+
log.info(f"{self} | Cancelling job {job.job_id}")
|
|
368
|
+
await asyncio.to_thread(job.cancel)
|
|
369
|
+
|
|
370
|
+
log.error(f"{self} | Exception: {e}")
|
|
371
|
+
raise
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class ServerlessEndpoint(ServerlessResource):
|
|
375
|
+
"""
|
|
376
|
+
Represents a serverless endpoint distinct from a live serverless.
|
|
377
|
+
Inherits from ServerlessResource.
|
|
378
|
+
"""
|
|
379
|
+
|
|
380
|
+
@model_validator(mode="after")
|
|
381
|
+
def set_serverless_template(self):
|
|
382
|
+
if not any([self.imageName, self.template, self.templateId]):
|
|
383
|
+
raise ValueError(
|
|
384
|
+
"Either imageName, template, or templateId must be provided"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if not self.templateId and not self.template:
|
|
388
|
+
self.template = PodTemplate(
|
|
389
|
+
name=self.resource_id,
|
|
390
|
+
imageName=self.imageName,
|
|
391
|
+
env=KeyValuePair.from_dict(self.env or get_env_vars()),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
elif self.template:
|
|
395
|
+
self.template.name = f"{self.resource_id}__{self.template.resource_id}"
|
|
396
|
+
if self.imageName:
|
|
397
|
+
self.template.imageName = self.imageName
|
|
398
|
+
if self.env:
|
|
399
|
+
self.template.env = KeyValuePair.from_dict(self.env)
|
|
400
|
+
|
|
401
|
+
return self
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class CpuServerlessEndpoint(ServerlessEndpoint):
|
|
405
|
+
"""
|
|
406
|
+
Convenience class for CPU serverless endpoint.
|
|
407
|
+
Represents a CPU-only serverless endpoint distinct from a live serverless.
|
|
408
|
+
Inherits from ServerlessEndpoint.
|
|
409
|
+
"""
|
|
410
|
+
|
|
411
|
+
instanceIds: Optional[List[CpuInstanceType]] = [CpuInstanceType.CPU3G_2_8]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class JobOutput(BaseModel):
|
|
415
|
+
id: str
|
|
416
|
+
workerId: str
|
|
417
|
+
status: str
|
|
418
|
+
delayTime: int
|
|
419
|
+
executionTime: int
|
|
420
|
+
output: Optional[Any] = None
|
|
421
|
+
error: Optional[str] = ""
|
|
422
|
+
|
|
423
|
+
def model_post_init(self, __context):
|
|
424
|
+
log_group = f"Worker:{self.workerId}"
|
|
425
|
+
log.info(f"{log_group} | Delay Time: {self.delayTime} ms")
|
|
426
|
+
log.info(f"{log_group} | Execution Time: {self.executionTime} ms")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class Status(str, Enum):
|
|
430
|
+
READY = "READY"
|
|
431
|
+
INITIALIZING = "INITIALIZING"
|
|
432
|
+
THROTTLED = "THROTTLED"
|
|
433
|
+
UNHEALTHY = "UNHEALTHY"
|
|
434
|
+
UNKNOWN = "UNKNOWN"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class WorkersHealth(BaseModel):
|
|
438
|
+
idle: int
|
|
439
|
+
initializing: int
|
|
440
|
+
ready: int
|
|
441
|
+
running: int
|
|
442
|
+
throttled: int
|
|
443
|
+
unhealthy: int
|
|
444
|
+
|
|
445
|
+
@property
|
|
446
|
+
def status(self) -> Status:
|
|
447
|
+
if self.ready or self.idle or self.running:
|
|
448
|
+
return Status.READY
|
|
449
|
+
|
|
450
|
+
if self.initializing:
|
|
451
|
+
return Status.INITIALIZING
|
|
452
|
+
|
|
453
|
+
if self.throttled:
|
|
454
|
+
return Status.THROTTLED
|
|
455
|
+
|
|
456
|
+
if self.unhealthy:
|
|
457
|
+
return Status.UNHEALTHY
|
|
458
|
+
|
|
459
|
+
return Status.UNKNOWN
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class JobsHealth(BaseModel):
|
|
463
|
+
completed: int
|
|
464
|
+
failed: int
|
|
465
|
+
inProgress: int
|
|
466
|
+
inQueue: int
|
|
467
|
+
retried: int
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class ServerlessHealth(BaseModel):
|
|
471
|
+
workers: WorkersHealth
|
|
472
|
+
jobs: JobsHealth
|
|
473
|
+
|
|
474
|
+
@property
|
|
475
|
+
def is_ready(self) -> bool:
|
|
476
|
+
return self.workers.status == Status.READY
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from typing import Dict, List, Optional, Any
|
|
3
|
+
from pydantic import BaseModel, model_validator
|
|
4
|
+
from .base import BaseResource
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class KeyValuePair(BaseModel):
|
|
8
|
+
key: str
|
|
9
|
+
value: str
|
|
10
|
+
|
|
11
|
+
@classmethod
|
|
12
|
+
def from_dict(cls, data: Dict[str, str]) -> "List[KeyValuePair]":
|
|
13
|
+
"""
|
|
14
|
+
Create a list of KeyValuePair instances from a dictionary.
|
|
15
|
+
"""
|
|
16
|
+
if not isinstance(data, dict):
|
|
17
|
+
raise ValueError("Input must be a dictionary.")
|
|
18
|
+
|
|
19
|
+
return [cls(key=key, value=value) for key, value in data.items()]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PodTemplate(BaseResource):
|
|
23
|
+
advancedStart: Optional[bool] = False
|
|
24
|
+
config: Optional[Dict[str, Any]] = {}
|
|
25
|
+
containerDiskInGb: Optional[int] = 10
|
|
26
|
+
containerRegistryAuthId: Optional[str] = ""
|
|
27
|
+
dockerArgs: Optional[str] = ""
|
|
28
|
+
env: Optional[List[KeyValuePair]] = []
|
|
29
|
+
imageName: Optional[str] = ""
|
|
30
|
+
name: Optional[str] = ""
|
|
31
|
+
ports: Optional[str] = ""
|
|
32
|
+
startScript: Optional[str] = ""
|
|
33
|
+
|
|
34
|
+
@model_validator(mode="after")
|
|
35
|
+
def sync_input_fields(self):
|
|
36
|
+
self.name = f"{self.name}__{self.resource_id}"
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def update_system_dependencies(
|
|
41
|
+
template_id, token, system_dependencies, base_entry_cmd=None
|
|
42
|
+
):
|
|
43
|
+
"""
|
|
44
|
+
Updates Runpod template with system dependencies installed via apt-get,
|
|
45
|
+
and appends the app start command.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
template_id (str): Runpod template ID.
|
|
49
|
+
token (str): Runpod API token.
|
|
50
|
+
system_dependencies (List[str]): List of apt packages to install.
|
|
51
|
+
base_entry_cmd (List[str]): The default command to run the app, e.g. ["uv", "run", "handler.py"]
|
|
52
|
+
Returns:
|
|
53
|
+
dict: API response JSON or error info.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
# Compose apt-get install command if any packages specified
|
|
57
|
+
apt_cmd = ""
|
|
58
|
+
if system_dependencies:
|
|
59
|
+
joined_pkgs = " ".join(system_dependencies)
|
|
60
|
+
apt_cmd = f"apt-get update && apt-get install -y {joined_pkgs} && "
|
|
61
|
+
|
|
62
|
+
# Default start command if not provided
|
|
63
|
+
app_cmd = base_entry_cmd or ["uv", "run", "handler.py"]
|
|
64
|
+
app_cmd_str = " ".join(app_cmd)
|
|
65
|
+
|
|
66
|
+
# Full command to run in entrypoint shell
|
|
67
|
+
full_cmd = f"{apt_cmd}exec {app_cmd_str}"
|
|
68
|
+
|
|
69
|
+
payload = {
|
|
70
|
+
# other required fields like disk, env, image, etc, should be fetched or passed in real usage
|
|
71
|
+
"dockerEntrypoint": ["/bin/bash", "-c", full_cmd],
|
|
72
|
+
"dockerStartCmd": [],
|
|
73
|
+
# placeholder values, replace as needed or fetch from current template state
|
|
74
|
+
"containerDiskInGb": 50,
|
|
75
|
+
"containerRegistryAuthId": "",
|
|
76
|
+
"env": {},
|
|
77
|
+
"imageName": "your-image-name",
|
|
78
|
+
"isPublic": False,
|
|
79
|
+
"name": "your-template-name",
|
|
80
|
+
"ports": ["8888/http", "22/tcp"],
|
|
81
|
+
"readme": "",
|
|
82
|
+
"volumeInGb": 20,
|
|
83
|
+
"volumeMountPath": "/workspace",
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
87
|
+
|
|
88
|
+
url = f"https://rest.runpod.io/v1/templates/{template_id}/update"
|
|
89
|
+
response = requests.post(url, json=payload, headers=headers)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
return response.json()
|
|
93
|
+
except Exception:
|
|
94
|
+
return {"error": "Invalid JSON response", "text": response.text}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from typing import Callable, Any, List, Union
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
from .gpu import GpuType, GpuTypeDetail
|
|
4
|
+
from .serverless import ServerlessEndpoint
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
Define the mapping for the methods and their return types
|
|
9
|
+
Only include methods from runpod.*
|
|
10
|
+
"""
|
|
11
|
+
RUNPOD_TYPED_OPERATIONS = {
|
|
12
|
+
"get_gpus": List[GpuType],
|
|
13
|
+
"get_gpu": GpuTypeDetail,
|
|
14
|
+
"get_endpoints": List[ServerlessEndpoint],
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def inquire(method: Callable, *args, **kwargs) -> Union[List[Any], Any]:
|
|
19
|
+
"""
|
|
20
|
+
This function dynamically determines the return type of the provided method
|
|
21
|
+
based on a predefined mapping (`definitions`) and validates the result using
|
|
22
|
+
Pydantic models if applicable.
|
|
23
|
+
|
|
24
|
+
Refer to `RUNPOD_TYPED_OPERATIONS` for the mapping.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
----------
|
|
28
|
+
>>> import runpod
|
|
29
|
+
>>> inquire(runpod.get_gpus)
|
|
30
|
+
[
|
|
31
|
+
GpuType(id='NVIDIA A100 80GB', displayName='A100 80GB', memoryInGb=80),
|
|
32
|
+
GpuType(id='NVIDIA A100 40GB', displayName='A100 40GB', memoryInGb=40),
|
|
33
|
+
GpuType(id='NVIDIA A10', displayName='A10', memoryInGb=24)
|
|
34
|
+
]
|
|
35
|
+
"""
|
|
36
|
+
method_name = method.__name__
|
|
37
|
+
return_type = RUNPOD_TYPED_OPERATIONS.get(method_name)
|
|
38
|
+
|
|
39
|
+
raw_result = method(*args, **kwargs)
|
|
40
|
+
|
|
41
|
+
if hasattr(return_type, "__origin__") and return_type.__origin__ is list:
|
|
42
|
+
# List case
|
|
43
|
+
model_type = return_type.__args__[0]
|
|
44
|
+
if issubclass(model_type, BaseModel):
|
|
45
|
+
return [model_type.model_validate(item) for item in raw_result]
|
|
46
|
+
elif isinstance(return_type, type) and issubclass(return_type, BaseModel):
|
|
47
|
+
# Single object case
|
|
48
|
+
return return_type.model_validate(raw_result)
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"Unsupported return type for method '{method_name}'")
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BackoffStrategy(str, Enum):
|
|
7
|
+
EXPONENTIAL = "exponential"
|
|
8
|
+
LINEAR = "linear"
|
|
9
|
+
LOGARITHMIC = "logarithmic"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_backoff_delay(
|
|
13
|
+
attempt: int,
|
|
14
|
+
base: float = 0.1,
|
|
15
|
+
max_seconds: float = 10.0,
|
|
16
|
+
jitter: float = 0.2,
|
|
17
|
+
strategy: BackoffStrategy = BackoffStrategy.EXPONENTIAL,
|
|
18
|
+
) -> float:
|
|
19
|
+
"""
|
|
20
|
+
Returns a backoff delay in seconds based on the number of attempts and strategy.
|
|
21
|
+
|
|
22
|
+
Parameters:
|
|
23
|
+
- attempt (int): The number of failed attempts or polls.
|
|
24
|
+
- base (float): The base delay time in seconds.
|
|
25
|
+
- max_seconds (float): The maximum delay.
|
|
26
|
+
- jitter (float): Random jitter as a fraction (e.g., 0.2 = ±20%). Prevent thundering herd
|
|
27
|
+
- strategy (BackoffStrategy): The backoff curve to apply.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
- float: The delay in seconds.
|
|
31
|
+
"""
|
|
32
|
+
if strategy == BackoffStrategy.EXPONENTIAL:
|
|
33
|
+
delay = base * (2**attempt)
|
|
34
|
+
elif strategy == BackoffStrategy.LINEAR:
|
|
35
|
+
delay = base + (attempt * base)
|
|
36
|
+
elif strategy == BackoffStrategy.LOGARITHMIC:
|
|
37
|
+
delay = base * math.log2(attempt + 2)
|
|
38
|
+
else:
|
|
39
|
+
raise ValueError(f"Unsupported backoff strategy: {strategy}")
|
|
40
|
+
|
|
41
|
+
# Clamp to max and apply jitter
|
|
42
|
+
delay = min(delay, max_seconds)
|
|
43
|
+
return delay * random.uniform(1 - jitter, 1 + jitter)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from typing import Any
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_for_json(obj: Any) -> Any:
|
|
7
|
+
"""
|
|
8
|
+
Recursively normalizes an object for JSON serialization.
|
|
9
|
+
|
|
10
|
+
This function handles various data types and ensures that objects
|
|
11
|
+
are converted into JSON-serializable formats. It supports the following:
|
|
12
|
+
- `BaseModel` instances: Converts them to dictionaries using `model_dump()`.
|
|
13
|
+
- Dictionaries: Recursively normalizes their values.
|
|
14
|
+
- Lists: Recursively normalizes their elements.
|
|
15
|
+
- Tuples: Recursively normalizes their elements and returns a tuple.
|
|
16
|
+
- Other types: Returns the object as is.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
obj (Any): The object to normalize.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Any: A JSON-serializable representation of the input object.
|
|
23
|
+
"""
|
|
24
|
+
if isinstance(obj, BaseModel):
|
|
25
|
+
return normalize_for_json(obj.model_dump())
|
|
26
|
+
elif isinstance(obj, Enum):
|
|
27
|
+
return obj.value
|
|
28
|
+
elif isinstance(obj, dict):
|
|
29
|
+
return {k: normalize_for_json(v) for k, v in obj.items()}
|
|
30
|
+
elif isinstance(obj, (list, tuple)):
|
|
31
|
+
return type(obj)(normalize_for_json(i) for i in obj)
|
|
32
|
+
else:
|
|
33
|
+
return obj
|