jambu-gpu 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.
- jambu_gpu/__init__.py +5 -0
- jambu_gpu/__main__.py +4 -0
- jambu_gpu/cli/__init__.py +0 -0
- jambu_gpu/cli/_common.py +117 -0
- jambu_gpu/cli/inspect.py +207 -0
- jambu_gpu/cli/lifecycle.py +127 -0
- jambu_gpu/cli/main.py +103 -0
- jambu_gpu/cli/providers.py +96 -0
- jambu_gpu/cli/run.py +37 -0
- jambu_gpu/cli/setup.py +165 -0
- jambu_gpu/cli/status.py +214 -0
- jambu_gpu/cli/validate.py +57 -0
- jambu_gpu/core/__init__.py +0 -0
- jambu_gpu/core/beacon.py +124 -0
- jambu_gpu/core/config.py +596 -0
- jambu_gpu/core/credentials.py +75 -0
- jambu_gpu/core/durations.py +47 -0
- jambu_gpu/core/engine.py +951 -0
- jambu_gpu/core/errors.py +68 -0
- jambu_gpu/core/events.py +115 -0
- jambu_gpu/core/execution.py +169 -0
- jambu_gpu/core/health.py +62 -0
- jambu_gpu/core/model_inspect.py +368 -0
- jambu_gpu/core/models.py +283 -0
- jambu_gpu/core/policy.py +233 -0
- jambu_gpu/core/state.py +361 -0
- jambu_gpu/core/tool_parsers.py +63 -0
- jambu_gpu/providers/__init__.py +4 -0
- jambu_gpu/providers/base.py +125 -0
- jambu_gpu/providers/registry.py +63 -0
- jambu_gpu/providers/vast/__init__.py +3 -0
- jambu_gpu/providers/vast/client.py +230 -0
- jambu_gpu/providers/vast/config.py +51 -0
- jambu_gpu/providers/vast/mapper.py +221 -0
- jambu_gpu/providers/vast/provider.py +422 -0
- jambu_gpu/runtime/__init__.py +6 -0
- jambu_gpu/runtime/base.py +99 -0
- jambu_gpu/runtime/vllm.py +224 -0
- jambu_gpu/watchdog/__init__.py +3 -0
- jambu_gpu/watchdog/agent_body.py +547 -0
- jambu_gpu/watchdog/builder.py +178 -0
- jambu_gpu-0.1.0.dist-info/METADATA +82 -0
- jambu_gpu-0.1.0.dist-info/RECORD +46 -0
- jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
- jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
- jambu_gpu-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The provider contract (spec section 6).
|
|
2
|
+
|
|
3
|
+
Every provider implements exactly this interface. Provider-specific APIs,
|
|
4
|
+
SDKs, identifiers, pricing structures and authentication MUST NOT leak
|
|
5
|
+
through it. The core branches on ``capabilities``, never on ``name``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from ..core.config import RuntimeConfig
|
|
15
|
+
from ..core.credentials import CredentialResolver
|
|
16
|
+
from ..core.errors import UnsupportedCapabilityError
|
|
17
|
+
from ..core.models import (
|
|
18
|
+
ComputeSpec,
|
|
19
|
+
Execution,
|
|
20
|
+
Instance,
|
|
21
|
+
InstanceStatus,
|
|
22
|
+
LogStream,
|
|
23
|
+
Offer,
|
|
24
|
+
ProviderCapabilities,
|
|
25
|
+
ValidationResult,
|
|
26
|
+
Workload,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class WatchdogSpec:
|
|
32
|
+
"""What the adapter contributes to the remote lifecycle watchdog.
|
|
33
|
+
|
|
34
|
+
The *policy* lives in the core; the adapter only supplies the provider
|
|
35
|
+
calls the watchdog needs to shut its own machine down (spec section 14).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Python source executed inside the watchdog. Must define
|
|
39
|
+
# def provider_stop(ctx) -> None
|
|
40
|
+
# def provider_destroy(ctx) -> None
|
|
41
|
+
# using the stdlib only.
|
|
42
|
+
actions_source: str
|
|
43
|
+
# Environment variables the instance needs for those calls (credentials).
|
|
44
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
# Static values available to the actions through ctx["provider"].
|
|
46
|
+
context: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GPUProvider(ABC):
|
|
50
|
+
"""Stable internal contract implemented by every adapter."""
|
|
51
|
+
|
|
52
|
+
name: str = "abstract"
|
|
53
|
+
display_name: str = "Abstract"
|
|
54
|
+
required_credentials: list[str] = []
|
|
55
|
+
capabilities: ProviderCapabilities = ProviderCapabilities()
|
|
56
|
+
|
|
57
|
+
def __init__(self, config: RuntimeConfig, credentials: CredentialResolver) -> None:
|
|
58
|
+
self.config = config
|
|
59
|
+
self.credentials = credentials
|
|
60
|
+
|
|
61
|
+
# -- contract -----------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
@abstractmethod
|
|
64
|
+
def validate(self, config: RuntimeConfig) -> ValidationResult:
|
|
65
|
+
"""Check credentials, capabilities and the requested spec. No side effects."""
|
|
66
|
+
|
|
67
|
+
@abstractmethod
|
|
68
|
+
def setup(self, spec: ComputeSpec) -> Instance:
|
|
69
|
+
"""Provision compute matching ``spec`` and return a normalized handle."""
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def start(self, instance: Instance) -> Instance:
|
|
73
|
+
"""Bring a stopped instance back to running."""
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
def run(self, instance: Instance, workload: Workload) -> Execution:
|
|
77
|
+
"""Execute a workload on the instance (requires ``capabilities.remote_exec``)."""
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
def status(self, instance: Instance) -> InstanceStatus:
|
|
81
|
+
"""Observed provider-side state, normalized."""
|
|
82
|
+
|
|
83
|
+
@abstractmethod
|
|
84
|
+
def stop(self, instance: Instance) -> None:
|
|
85
|
+
"""Stop compute. MUST be idempotent (spec section 23)."""
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
def destroy(self, instance: Instance) -> None:
|
|
89
|
+
"""Destroy the remote resource. MUST be idempotent."""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def logs(self, instance: Instance) -> LogStream:
|
|
93
|
+
"""Provider-side logs for the instance."""
|
|
94
|
+
|
|
95
|
+
# -- optional, capability-gated ----------------------------------------
|
|
96
|
+
|
|
97
|
+
def find_offers(self, spec: ComputeSpec, limit: int = 10) -> list[Offer]:
|
|
98
|
+
"""Discover purchasable capacity (requires ``capabilities.pricing_query``)."""
|
|
99
|
+
raise UnsupportedCapabilityError(f"{self.name} cannot query offers")
|
|
100
|
+
|
|
101
|
+
def watchdog_spec(self, spec_or_instance: Any) -> Optional[WatchdogSpec]:
|
|
102
|
+
"""Provider calls the remote watchdog uses to stop/destroy itself."""
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def refresh(self, instance: Instance) -> Instance:
|
|
106
|
+
"""Re-read provider data into the normalized handle (ports, ip, state)."""
|
|
107
|
+
status = self.status(instance)
|
|
108
|
+
return status.instance or instance
|
|
109
|
+
|
|
110
|
+
# -- helpers ------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def require_capability(self, name: str) -> None:
|
|
113
|
+
if not getattr(self.capabilities, name, False):
|
|
114
|
+
raise UnsupportedCapabilityError(
|
|
115
|
+
f"provider '{self.name}' does not support capability '{name}'"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def credential_status(self) -> dict[str, bool]:
|
|
119
|
+
return {name: bool(self.credentials.get(name)) for name in self.required_credentials}
|
|
120
|
+
|
|
121
|
+
def is_configured(self) -> bool:
|
|
122
|
+
return all(self.credential_status().values()) if self.required_credentials else True
|
|
123
|
+
|
|
124
|
+
def __repr__(self) -> str: # pragma: no cover - debug helper
|
|
125
|
+
return f"<{type(self).__name__} name={self.name!r}>"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Provider registry (spec section 20).
|
|
2
|
+
|
|
3
|
+
Adding a provider means adding ``providers/<name>/`` and registering the class.
|
|
4
|
+
No lifecycle, execution or CLI code changes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Iterator, Optional, Type
|
|
10
|
+
|
|
11
|
+
from ..core.config import RuntimeConfig
|
|
12
|
+
from ..core.credentials import CredentialResolver
|
|
13
|
+
from ..core.errors import ConfigError
|
|
14
|
+
from .base import GPUProvider
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProviderRegistry:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._providers: dict[str, Type[GPUProvider]] = {}
|
|
20
|
+
|
|
21
|
+
def register(self, provider_cls: Type[GPUProvider]) -> Type[GPUProvider]:
|
|
22
|
+
name = provider_cls.name
|
|
23
|
+
if not name or name == "abstract":
|
|
24
|
+
raise ValueError(f"{provider_cls!r} must define a provider name")
|
|
25
|
+
self._providers[name] = provider_cls
|
|
26
|
+
return provider_cls
|
|
27
|
+
|
|
28
|
+
def names(self) -> list[str]:
|
|
29
|
+
return sorted(self._providers)
|
|
30
|
+
|
|
31
|
+
def classes(self) -> Iterator[Type[GPUProvider]]:
|
|
32
|
+
for name in self.names():
|
|
33
|
+
yield self._providers[name]
|
|
34
|
+
|
|
35
|
+
def get_class(self, name: str) -> Type[GPUProvider]:
|
|
36
|
+
try:
|
|
37
|
+
return self._providers[name]
|
|
38
|
+
except KeyError:
|
|
39
|
+
known = ", ".join(self.names()) or "<none>"
|
|
40
|
+
raise ConfigError(f"unknown provider '{name}' (available: {known})") from None
|
|
41
|
+
|
|
42
|
+
def get(
|
|
43
|
+
self,
|
|
44
|
+
config: RuntimeConfig,
|
|
45
|
+
credentials: Optional[CredentialResolver] = None,
|
|
46
|
+
name: Optional[str] = None,
|
|
47
|
+
) -> GPUProvider:
|
|
48
|
+
provider_cls = self.get_class(name or config.provider.name)
|
|
49
|
+
resolver = credentials or CredentialResolver(
|
|
50
|
+
config.project_dir, profile=config.provider.profile
|
|
51
|
+
)
|
|
52
|
+
return provider_cls(config, resolver)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
registry = ProviderRegistry()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_builtin_providers() -> ProviderRegistry:
|
|
59
|
+
"""Import and register every adapter shipped with the CLI."""
|
|
60
|
+
from .vast.provider import VastProvider # noqa: WPS433 (deferred import by design)
|
|
61
|
+
|
|
62
|
+
registry.register(VastProvider)
|
|
63
|
+
return registry
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Thin HTTP client for the Vast.ai REST API.
|
|
2
|
+
|
|
3
|
+
Only this module knows Vast's wire format. It raises normalized errors so
|
|
4
|
+
nothing Vast-shaped escapes the adapter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from ...core.errors import (
|
|
15
|
+
AuthenticationError,
|
|
16
|
+
CapacityUnavailableError,
|
|
17
|
+
ProviderError,
|
|
18
|
+
ProviderTimeoutError,
|
|
19
|
+
ProvisioningError,
|
|
20
|
+
)
|
|
21
|
+
from .config import API_BASE
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class VastNotFound(ProviderError):
|
|
25
|
+
"""The requested Vast object does not exist (any more)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class VastClient:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
api_key: str,
|
|
32
|
+
api_base: str = API_BASE,
|
|
33
|
+
timeout: float = 60.0,
|
|
34
|
+
client: Optional[httpx.Client] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.api_key = api_key
|
|
37
|
+
self.api_base = api_base.rstrip("/")
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
self._client = client
|
|
40
|
+
|
|
41
|
+
# -- transport ----------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def client(self) -> httpx.Client:
|
|
45
|
+
if self._client is None:
|
|
46
|
+
self._client = httpx.Client(
|
|
47
|
+
timeout=self.timeout,
|
|
48
|
+
headers={
|
|
49
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
50
|
+
"Accept": "application/json",
|
|
51
|
+
"Content-Type": "application/json",
|
|
52
|
+
"User-Agent": "jambu-gpu/0.1",
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
return self._client
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
if self._client is not None:
|
|
59
|
+
self._client.close()
|
|
60
|
+
self._client = None
|
|
61
|
+
|
|
62
|
+
def request(
|
|
63
|
+
self,
|
|
64
|
+
method: str,
|
|
65
|
+
path: str,
|
|
66
|
+
*,
|
|
67
|
+
json_body: Optional[dict] = None,
|
|
68
|
+
params: Optional[dict] = None,
|
|
69
|
+
) -> Any:
|
|
70
|
+
url = path if path.startswith("http") else f"{self.api_base}{path}"
|
|
71
|
+
try:
|
|
72
|
+
response = self.client.request(method, url, json=json_body, params=params)
|
|
73
|
+
except httpx.TimeoutException as exc:
|
|
74
|
+
raise ProviderTimeoutError(f"vast.ai timed out on {method} {path}") from exc
|
|
75
|
+
except httpx.HTTPError as exc:
|
|
76
|
+
raise ProviderError(f"vast.ai request failed ({method} {path}): {exc}") from exc
|
|
77
|
+
|
|
78
|
+
# These two are reliable regardless of what's in the body.
|
|
79
|
+
if response.status_code == 404:
|
|
80
|
+
raise VastNotFound(f"vast.ai object not found: {method} {path}")
|
|
81
|
+
if response.status_code in (401, 403):
|
|
82
|
+
raise AuthenticationError(
|
|
83
|
+
"vast.ai rejected the API key (check VAST_API_KEY at "
|
|
84
|
+
"https://cloud.vast.ai/account/)"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Vast returns its own {"success": false, "msg": "..."} error shape
|
|
88
|
+
# on other 4xx responses just as often as on 200 - the old behavior
|
|
89
|
+
# (raise a blunt ProviderError for any >= 400 BEFORE ever looking at
|
|
90
|
+
# the body) intercepted real, specifically classifiable errors like
|
|
91
|
+
# "no_such_ask ... is not available" and turned them into a generic
|
|
92
|
+
# ProviderError that setup()'s offer-retry loop can't walk past.
|
|
93
|
+
# Parse the body first and classify by its content when there is one;
|
|
94
|
+
# only fall back to the bare status code when there isn't.
|
|
95
|
+
data: Any = None
|
|
96
|
+
if response.content:
|
|
97
|
+
try:
|
|
98
|
+
data = response.json()
|
|
99
|
+
except (ValueError, json.JSONDecodeError):
|
|
100
|
+
data = None
|
|
101
|
+
|
|
102
|
+
if isinstance(data, dict) and data.get("success") is False:
|
|
103
|
+
message = str(data.get("msg") or data.get("error") or data)
|
|
104
|
+
# Vast spells the same "this offer is gone" failure differently
|
|
105
|
+
# across endpoints/versions ("no_such_ask" underscored, "no such
|
|
106
|
+
# ask" spaced, "is not available" two words, "unavailable" one) -
|
|
107
|
+
# normalize separators before matching so none of them slip
|
|
108
|
+
# through as a generic ProviderError.
|
|
109
|
+
normalized = message.lower().replace("_", " ").replace("-", " ")
|
|
110
|
+
lowered = message.lower()
|
|
111
|
+
if (
|
|
112
|
+
"no such ask" in normalized
|
|
113
|
+
or "not available" in normalized
|
|
114
|
+
or "unavailable" in normalized
|
|
115
|
+
or "taken" in normalized
|
|
116
|
+
or "no longer" in normalized
|
|
117
|
+
):
|
|
118
|
+
raise CapacityUnavailableError(f"vast.ai: {message}")
|
|
119
|
+
if "insufficient" in lowered or "credit" in lowered or "balance" in lowered:
|
|
120
|
+
raise ProvisioningError(f"vast.ai: {message}")
|
|
121
|
+
raise ProviderError(f"vast.ai: {message}")
|
|
122
|
+
|
|
123
|
+
if response.status_code == 429:
|
|
124
|
+
raise ProviderError("vast.ai rate limit hit; retry in a moment")
|
|
125
|
+
if response.status_code >= 400:
|
|
126
|
+
raise ProviderError(
|
|
127
|
+
f"vast.ai error {response.status_code} on {method} {path}: "
|
|
128
|
+
f"{response.text[:400]}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if data is None:
|
|
132
|
+
return {"raw": response.text} if response.content else {}
|
|
133
|
+
return data
|
|
134
|
+
|
|
135
|
+
# -- account ------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def whoami(self) -> dict:
|
|
138
|
+
data = self.request("GET", "/users/current/")
|
|
139
|
+
return data if isinstance(data, dict) else {}
|
|
140
|
+
|
|
141
|
+
# -- offers -------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def search_offers(self, query: dict) -> list[dict]:
|
|
144
|
+
"""Vast has shipped three shapes of this endpoint; try them in order.
|
|
145
|
+
|
|
146
|
+
Stops at the first shape that *answers in the expected shape*
|
|
147
|
+
(a dict with an "offers" key), even when that list is legitimately
|
|
148
|
+
empty - a correct "no matches" MUST NOT fall through to the next
|
|
149
|
+
(possibly incompatible) shape, or a real empty result gets replaced
|
|
150
|
+
by a confusing error from a fallback shape that was never going to
|
|
151
|
+
work. Only a shape that outright fails (raises) is skipped.
|
|
152
|
+
"""
|
|
153
|
+
attempts = (
|
|
154
|
+
("PUT", "/search/asks/", {"q": query}, None),
|
|
155
|
+
("PUT", "/search/asks/", query, None),
|
|
156
|
+
("GET", "/bundles/", None, {"q": json.dumps(query)}),
|
|
157
|
+
)
|
|
158
|
+
last_error: Optional[Exception] = None
|
|
159
|
+
for method, path, body, params in attempts:
|
|
160
|
+
try:
|
|
161
|
+
data = self.request(method, path, json_body=body, params=params)
|
|
162
|
+
except (ProviderError, VastNotFound) as exc:
|
|
163
|
+
last_error = exc
|
|
164
|
+
continue
|
|
165
|
+
if isinstance(data, dict) and "offers" in data:
|
|
166
|
+
return list(data["offers"] or [])
|
|
167
|
+
if last_error is not None and not isinstance(last_error, VastNotFound):
|
|
168
|
+
raise last_error
|
|
169
|
+
return []
|
|
170
|
+
|
|
171
|
+
# -- instances ----------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def create_instance(self, ask_id: str, payload: dict) -> dict:
|
|
174
|
+
data = self.request("PUT", f"/asks/{ask_id}/", json_body=payload)
|
|
175
|
+
if not isinstance(data, dict) or not data.get("new_contract"):
|
|
176
|
+
raise ProvisioningError(f"vast.ai did not return an instance id: {data}")
|
|
177
|
+
return data
|
|
178
|
+
|
|
179
|
+
def list_instances(self) -> list[dict]:
|
|
180
|
+
data = self.request("GET", "/instances/", params={"owner": "me"})
|
|
181
|
+
if isinstance(data, dict):
|
|
182
|
+
return list(data.get("instances") or [])
|
|
183
|
+
return []
|
|
184
|
+
|
|
185
|
+
def get_instance(self, instance_id: str) -> Optional[dict]:
|
|
186
|
+
try:
|
|
187
|
+
data = self.request("GET", f"/instances/{instance_id}/")
|
|
188
|
+
except VastNotFound:
|
|
189
|
+
return None
|
|
190
|
+
if isinstance(data, dict):
|
|
191
|
+
found = data.get("instances")
|
|
192
|
+
if isinstance(found, list):
|
|
193
|
+
return found[0] if found else None
|
|
194
|
+
if isinstance(found, dict):
|
|
195
|
+
return found or None
|
|
196
|
+
# Some deployments answer the single-instance route with a bare object.
|
|
197
|
+
for candidate in self.list_instances():
|
|
198
|
+
if str(candidate.get("id")) == str(instance_id):
|
|
199
|
+
return candidate
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
def set_instance_state(self, instance_id: str, state: str) -> dict:
|
|
203
|
+
data = self.request("PUT", f"/instances/{instance_id}/", json_body={"state": state})
|
|
204
|
+
return data if isinstance(data, dict) else {}
|
|
205
|
+
|
|
206
|
+
def destroy_instance(self, instance_id: str) -> dict:
|
|
207
|
+
try:
|
|
208
|
+
data = self.request("DELETE", f"/instances/{instance_id}/")
|
|
209
|
+
except VastNotFound:
|
|
210
|
+
return {"success": True, "already_gone": True}
|
|
211
|
+
return data if isinstance(data, dict) else {}
|
|
212
|
+
|
|
213
|
+
def request_logs(self, instance_id: str, tail: int = 500) -> Optional[str]:
|
|
214
|
+
data = self.request(
|
|
215
|
+
"PUT",
|
|
216
|
+
f"/instances/request_logs/{instance_id}/",
|
|
217
|
+
json_body={"tail": str(tail)},
|
|
218
|
+
)
|
|
219
|
+
if not isinstance(data, dict):
|
|
220
|
+
return None
|
|
221
|
+
return data.get("result_url")
|
|
222
|
+
|
|
223
|
+
def fetch_url(self, url: str) -> str:
|
|
224
|
+
try:
|
|
225
|
+
response = httpx.get(url, timeout=self.timeout)
|
|
226
|
+
except httpx.HTTPError as exc:
|
|
227
|
+
raise ProviderError(f"could not download vast.ai logs: {exc}") from exc
|
|
228
|
+
if response.status_code >= 400:
|
|
229
|
+
raise ProviderError(f"vast.ai log download failed ({response.status_code})")
|
|
230
|
+
return response.text
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Vast.ai adapter settings.
|
|
2
|
+
|
|
3
|
+
Everything here is provider-local. Nothing in this module may be referenced
|
|
4
|
+
by the core.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
API_BASE = "https://console.vast.ai/api/v0"
|
|
13
|
+
API_KEY_ENV = "VAST_API_KEY"
|
|
14
|
+
|
|
15
|
+
# runtype that gives us sshd plus our own onstart script (the image entrypoint
|
|
16
|
+
# is replaced by Vast's supervisor, so we launch the model server ourselves).
|
|
17
|
+
DEFAULT_RUNTYPE = "ssh_direc ssh_proxy"
|
|
18
|
+
|
|
19
|
+
DEFAULT_SEARCH_LIMIT = 24
|
|
20
|
+
DEFAULT_MIN_RELIABILITY = 0.95
|
|
21
|
+
DEFAULT_MIN_INET_DOWN_MBPS = 100.0
|
|
22
|
+
DEFAULT_MIN_CUDA = 12.1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class VastOptions:
|
|
27
|
+
"""`provider.options` in jambu.yaml (gpu_runtime.provider.options), all optional."""
|
|
28
|
+
|
|
29
|
+
api_base: str = API_BASE
|
|
30
|
+
runtype: str = DEFAULT_RUNTYPE
|
|
31
|
+
verified_only: bool = True
|
|
32
|
+
min_reliability: float = DEFAULT_MIN_RELIABILITY
|
|
33
|
+
min_inet_down_mbps: float = DEFAULT_MIN_INET_DOWN_MBPS
|
|
34
|
+
min_cuda: float = DEFAULT_MIN_CUDA
|
|
35
|
+
search_limit: int = DEFAULT_SEARCH_LIMIT
|
|
36
|
+
order_by: str = "dph_total" # dph_total | score | inet_down
|
|
37
|
+
order_dir: str = "asc"
|
|
38
|
+
image_login: Optional[str] = None
|
|
39
|
+
# Extra raw filters merged into the offer query, escape hatch for power users.
|
|
40
|
+
search_filters: Optional[dict[str, Any]] = None
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def from_mapping(cls, data: Optional[dict[str, Any]]) -> "VastOptions":
|
|
44
|
+
data = data or {}
|
|
45
|
+
known = {f for f in cls.__dataclass_fields__} # type: ignore[attr-defined]
|
|
46
|
+
unknown = set(data) - known
|
|
47
|
+
if unknown:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"unknown provider.options for vast: " + ", ".join(sorted(unknown))
|
|
50
|
+
)
|
|
51
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|