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.
Files changed (46) hide show
  1. jambu_gpu/__init__.py +5 -0
  2. jambu_gpu/__main__.py +4 -0
  3. jambu_gpu/cli/__init__.py +0 -0
  4. jambu_gpu/cli/_common.py +117 -0
  5. jambu_gpu/cli/inspect.py +207 -0
  6. jambu_gpu/cli/lifecycle.py +127 -0
  7. jambu_gpu/cli/main.py +103 -0
  8. jambu_gpu/cli/providers.py +96 -0
  9. jambu_gpu/cli/run.py +37 -0
  10. jambu_gpu/cli/setup.py +165 -0
  11. jambu_gpu/cli/status.py +214 -0
  12. jambu_gpu/cli/validate.py +57 -0
  13. jambu_gpu/core/__init__.py +0 -0
  14. jambu_gpu/core/beacon.py +124 -0
  15. jambu_gpu/core/config.py +596 -0
  16. jambu_gpu/core/credentials.py +75 -0
  17. jambu_gpu/core/durations.py +47 -0
  18. jambu_gpu/core/engine.py +951 -0
  19. jambu_gpu/core/errors.py +68 -0
  20. jambu_gpu/core/events.py +115 -0
  21. jambu_gpu/core/execution.py +169 -0
  22. jambu_gpu/core/health.py +62 -0
  23. jambu_gpu/core/model_inspect.py +368 -0
  24. jambu_gpu/core/models.py +283 -0
  25. jambu_gpu/core/policy.py +233 -0
  26. jambu_gpu/core/state.py +361 -0
  27. jambu_gpu/core/tool_parsers.py +63 -0
  28. jambu_gpu/providers/__init__.py +4 -0
  29. jambu_gpu/providers/base.py +125 -0
  30. jambu_gpu/providers/registry.py +63 -0
  31. jambu_gpu/providers/vast/__init__.py +3 -0
  32. jambu_gpu/providers/vast/client.py +230 -0
  33. jambu_gpu/providers/vast/config.py +51 -0
  34. jambu_gpu/providers/vast/mapper.py +221 -0
  35. jambu_gpu/providers/vast/provider.py +422 -0
  36. jambu_gpu/runtime/__init__.py +6 -0
  37. jambu_gpu/runtime/base.py +99 -0
  38. jambu_gpu/runtime/vllm.py +224 -0
  39. jambu_gpu/watchdog/__init__.py +3 -0
  40. jambu_gpu/watchdog/agent_body.py +547 -0
  41. jambu_gpu/watchdog/builder.py +178 -0
  42. jambu_gpu-0.1.0.dist-info/METADATA +82 -0
  43. jambu_gpu-0.1.0.dist-info/RECORD +46 -0
  44. jambu_gpu-0.1.0.dist-info/WHEEL +4 -0
  45. jambu_gpu-0.1.0.dist-info/entry_points.txt +2 -0
  46. 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,3 @@
1
+ from .provider import VastProvider
2
+
3
+ __all__ = ["VastProvider"]
@@ -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})