swm-gpu 0.2.2__tar.gz → 0.2.3__tar.gz
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.
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/CHANGELOG.md +26 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/PKG-INFO +10 -1
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/pyproject.toml +8 -2
- swm_gpu-0.2.3/src/swm/__init__.py +1 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/cli.py +9 -2
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/_helpers.py +18 -4
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/config.py +22 -8
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/costs.py +4 -6
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/guard.py +26 -3
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/images.py +1 -1
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/models.py +75 -56
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/pod.py +16 -22
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/pricing.py +5 -3
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/setup.py +0 -29
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/sync.py +49 -3
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/use.py +8 -2
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/swarmui.py +8 -5
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/guard.py +12 -4
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/manifest.py +8 -3
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/pricing/providers.py +96 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/pull.py +6 -4
- swm_gpu-0.2.2/src/swm/__init__.py +0 -1
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/.agents/skills/swm-gpu-workflow/SKILL.md +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/.gitignore +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/CODE_OF_CONDUCT.md +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/CONTRIBUTING.md +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/LICENSE +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/NOTICE +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/README.md +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/SECURITY.md +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/bootstrap.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/bootstrap_frameworks.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/bootstrap_ssh.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/remote.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/storage.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/config.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/billing.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/budget.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/db.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/reconcile.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/tracker.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/cuda.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/axolotl.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/comfyui.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/llm_studio.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/ollama.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/open_webui.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/vllm_server.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/images.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/civitai.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/huggingface.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/resolver.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/pricing/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/pricing/calculator.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/aws.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/azure.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/base.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/coreweave.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/fluidstack.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/gcp.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/lambda_labs.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/runpod.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/tensordock.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/vastai.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/vultr.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/remote/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/remote/ssh.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/b2.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/base.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/gcs.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/s3.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/__init__.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/_autosync_daemon.sh +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/_common.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/autosync.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/paths.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/preflight.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/push.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/watcher.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/swm_main.py +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/.gitignore +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/README.md +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/download-music.sh +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/package-lock.json +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/package.json +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/remotion.config.ts +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/Root.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/ChapterCard.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/EndCard.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/GpuTable.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/OutputBlock.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/PHBadge.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/ProviderCascade.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/TerminalFrame.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/TypingLine.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/Wordmark.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/compositions/Hero16x9.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/compositions/Square1x1.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/compositions/Vertical9x16.tsx +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/config/music.ts +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/script/scrubbed-logs.ts +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/styles/tokens.ts +0 -0
- {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/tsconfig.json +0 -0
|
@@ -6,6 +6,32 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.3] - 2026-05-24
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `swm sync pull --tar` no longer reports success when download/extract fails.
|
|
13
|
+
- `swm models pull` tracks each HF file under a unique manifest key; multi-file
|
|
14
|
+
repos (e.g. high/low LoRA pairs) no longer overwrite each other.
|
|
15
|
+
- HF pull 404s suggest nested repo paths; `--filename` resolves basenames when unique.
|
|
16
|
+
- `swm guard set` honors `--idle-timeout 0` / `--poll-interval 0` and starts the
|
|
17
|
+
local guard daemon for auto-stop/auto-down policies.
|
|
18
|
+
- `swm sync status` reports push stamp, watcher, pending changes, and auto-sync.
|
|
19
|
+
- `swm use` validates the pod exists before setting the active pod.
|
|
20
|
+
- SwarmUI install creates `dlbackend` before cloning ComfyUI (fixes #9).
|
|
21
|
+
- `swm gpus -p badslug` errors instead of returning empty results.
|
|
22
|
+
- `swm costs summary --period today` uses UTC calendar day, not rolling 24h.
|
|
23
|
+
- Cost tracking failures on pod lifecycle commands emit dim warnings.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Removed deprecated shims: `swm models set`, `swm pod gpus`, hidden `setup comfyui` /
|
|
27
|
+
`setup swarmui`.
|
|
28
|
+
- `swm guard disable --force-manual` pins a pod to manual mode.
|
|
29
|
+
- `swm pricing` supports H100, A100, RTX 4090, and L40S reference data.
|
|
30
|
+
- Shell completion suggests `provider:id` pod references.
|
|
31
|
+
- Config masking uses suffix-based sensitive key detection.
|
|
32
|
+
- Azure optional dependencies in `pyproject.toml` (`pip install swm-gpu[azure]`).
|
|
33
|
+
- Regenerated repo docs (`docs/cli-reference.md`, `docs/configuration.md`).
|
|
34
|
+
|
|
9
35
|
## [0.2.2] - 2026-05-24
|
|
10
36
|
|
|
11
37
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swm-gpu
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: One CLI to search, provision, and manage cloud GPUs across 10 providers
|
|
5
5
|
Project-URL: Homepage, https://swmgpu.com
|
|
6
6
|
Project-URL: Repository, https://github.com/swm-gpu/swm
|
|
@@ -29,7 +29,16 @@ Requires-Dist: httpx>=0.27
|
|
|
29
29
|
Requires-Dist: rich>=13.0
|
|
30
30
|
Requires-Dist: tomli-w>=1.0
|
|
31
31
|
Provides-Extra: all
|
|
32
|
+
Requires-Dist: azure-identity>=1.15; extra == 'all'
|
|
33
|
+
Requires-Dist: azure-mgmt-compute>=30.0; extra == 'all'
|
|
34
|
+
Requires-Dist: azure-mgmt-network>=25.0; extra == 'all'
|
|
35
|
+
Requires-Dist: azure-mgmt-resource>=23.0; extra == 'all'
|
|
32
36
|
Requires-Dist: kubernetes>=29.0; extra == 'all'
|
|
37
|
+
Provides-Extra: azure
|
|
38
|
+
Requires-Dist: azure-identity>=1.15; extra == 'azure'
|
|
39
|
+
Requires-Dist: azure-mgmt-compute>=30.0; extra == 'azure'
|
|
40
|
+
Requires-Dist: azure-mgmt-network>=25.0; extra == 'azure'
|
|
41
|
+
Requires-Dist: azure-mgmt-resource>=23.0; extra == 'azure'
|
|
33
42
|
Provides-Extra: coreweave
|
|
34
43
|
Requires-Dist: kubernetes>=29.0; extra == 'coreweave'
|
|
35
44
|
Provides-Extra: dev
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "swm-gpu"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.3"
|
|
4
4
|
description = "One CLI to search, provision, and manage cloud GPUs across 10 providers"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -30,8 +30,14 @@ dependencies = [
|
|
|
30
30
|
|
|
31
31
|
[project.optional-dependencies]
|
|
32
32
|
coreweave = ["kubernetes>=29.0"]
|
|
33
|
+
azure = [
|
|
34
|
+
"azure-identity>=1.15",
|
|
35
|
+
"azure-mgmt-compute>=30.0",
|
|
36
|
+
"azure-mgmt-network>=25.0",
|
|
37
|
+
"azure-mgmt-resource>=23.0",
|
|
38
|
+
]
|
|
33
39
|
dev = ["nuitka", "ordered-set", "zstandard"]
|
|
34
|
-
all = ["kubernetes>=29.0"]
|
|
40
|
+
all = ["kubernetes>=29.0", "azure-identity>=1.15", "azure-mgmt-compute>=30.0", "azure-mgmt-network>=25.0", "azure-mgmt-resource>=23.0"]
|
|
35
41
|
|
|
36
42
|
[project.urls]
|
|
37
43
|
Homepage = "https://swmgpu.com"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.3"
|
|
@@ -32,7 +32,11 @@ Workflow:
|
|
|
32
32
|
@click.group(epilog=_WORKFLOW_EPILOG)
|
|
33
33
|
@click.version_option(__version__, prog_name="swm")
|
|
34
34
|
def main():
|
|
35
|
-
"""swm — Cloud GPU workflow manager
|
|
35
|
+
"""swm — Cloud GPU workflow manager across 10 providers.
|
|
36
|
+
|
|
37
|
+
Search GPUs, provision pods, install frameworks (ComfyUI, vLLM, Ollama, …),
|
|
38
|
+
sync workspaces, manage models, and automate lifecycle with guard policies.
|
|
39
|
+
"""
|
|
36
40
|
|
|
37
41
|
|
|
38
42
|
# ── GPU search (top-level, kept here for brevity) ──────────────────
|
|
@@ -103,7 +107,10 @@ def gpus(gpu: str | None, gpu_count: int | None, max_price: float | None,
|
|
|
103
107
|
try:
|
|
104
108
|
sources = [get_provider(provider)]
|
|
105
109
|
except ValueError:
|
|
106
|
-
|
|
110
|
+
known = ", ".join(sorted(_SLUG_TO_NAME))
|
|
111
|
+
raise click.UsageError(
|
|
112
|
+
f"Unknown provider {provider!r}. Known slugs: {known}"
|
|
113
|
+
) from None
|
|
107
114
|
else:
|
|
108
115
|
configured = get_configured_providers()
|
|
109
116
|
unconfigured_slugs = (
|
|
@@ -63,16 +63,29 @@ def resolve_active_pod(instance_id: str | None) -> str:
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def _iter_configured_pod_ids() -> list[str]:
|
|
66
|
-
"""Return pod ids known to swm's config (`
|
|
66
|
+
"""Return qualified pod ids known to swm's config (`provider:id`)."""
|
|
67
67
|
pods = cfg.get("pods") or {}
|
|
68
68
|
if not isinstance(pods, dict):
|
|
69
69
|
return []
|
|
70
|
-
|
|
70
|
+
out: list[str] = []
|
|
71
|
+
for pid, meta in pods.items():
|
|
72
|
+
prov = (meta or {}).get("provider", "")
|
|
73
|
+
out.append(f"{prov}:{pid}" if prov else pid)
|
|
74
|
+
return sorted(out)
|
|
71
75
|
|
|
72
76
|
|
|
73
77
|
def complete_pod_id(ctx, param, incomplete: str):
|
|
74
78
|
"""Click shell completion callback for pod id arguments."""
|
|
75
|
-
|
|
79
|
+
pods = cfg.get("pods") or {}
|
|
80
|
+
if not isinstance(pods, dict):
|
|
81
|
+
return []
|
|
82
|
+
candidates: set[str] = set()
|
|
83
|
+
for pid, meta in pods.items():
|
|
84
|
+
prov = (meta or {}).get("provider", "")
|
|
85
|
+
qid = f"{prov}:{pid}" if prov else pid
|
|
86
|
+
candidates.add(qid)
|
|
87
|
+
candidates.add(pid)
|
|
88
|
+
return sorted(c for c in candidates if c.startswith(incomplete))
|
|
76
89
|
|
|
77
90
|
|
|
78
91
|
def pod_arg_callback(ctx, param, value):
|
|
@@ -94,7 +107,8 @@ def looks_like_pod_id(value: str) -> bool:
|
|
|
94
107
|
return False
|
|
95
108
|
if any(value.startswith(p) for p in _PROVIDER_PREFIXES):
|
|
96
109
|
return True
|
|
97
|
-
|
|
110
|
+
pods = cfg.get("pods") or {}
|
|
111
|
+
return isinstance(pods, dict) and value in pods
|
|
98
112
|
|
|
99
113
|
|
|
100
114
|
def split_pod_and_command(
|
|
@@ -7,6 +7,25 @@ from swm import config as cfg
|
|
|
7
7
|
from swm.commands._helpers import console
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
_SENSITIVE_SUFFIXES = (
|
|
11
|
+
".api_key", ".app_key", ".secret_key", ".access_key", ".token", ".password",
|
|
12
|
+
)
|
|
13
|
+
_SENSITIVE_EXACT = frozenset({"hf_token"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
17
|
+
lowered = key.lower()
|
|
18
|
+
if lowered in _SENSITIVE_EXACT:
|
|
19
|
+
return True
|
|
20
|
+
return any(lowered.endswith(suffix) for suffix in _SENSITIVE_SUFFIXES)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _mask_value(key: str, value: str) -> str:
|
|
24
|
+
if not _is_sensitive_key(key):
|
|
25
|
+
return value
|
|
26
|
+
return value[:4] + "****" if len(value) > 4 else "****"
|
|
27
|
+
|
|
28
|
+
|
|
10
29
|
@click.group(name="config")
|
|
11
30
|
def config_group():
|
|
12
31
|
"""Manage configuration (API keys, defaults, preferences)."""
|
|
@@ -18,8 +37,7 @@ def config_group():
|
|
|
18
37
|
def config_set(key: str, value: str):
|
|
19
38
|
"""Set a config value. Example: swm config set runpod.api_key sk-xxx"""
|
|
20
39
|
cfg.set_value(key, value)
|
|
21
|
-
|
|
22
|
-
console.print(f"[green]✓[/green] {key} = {display}")
|
|
40
|
+
console.print(f"[green]✓[/green] {key} = {_mask_value(key, value)}")
|
|
23
41
|
|
|
24
42
|
|
|
25
43
|
@config_group.command(name="get")
|
|
@@ -30,9 +48,7 @@ def config_get(key: str):
|
|
|
30
48
|
if val is None:
|
|
31
49
|
console.print(f"[yellow]⚠[/yellow] {key} is not set")
|
|
32
50
|
else:
|
|
33
|
-
display = str(val)
|
|
34
|
-
if "key" in key.lower() or "secret" in key.lower():
|
|
35
|
-
display = display[:4] + "****" if len(display) > 4 else "****"
|
|
51
|
+
display = _mask_value(key, str(val))
|
|
36
52
|
console.print(f"{key} = {display}")
|
|
37
53
|
|
|
38
54
|
|
|
@@ -71,7 +87,5 @@ def _print_nested(d: dict, prefix: str = "") -> None:
|
|
|
71
87
|
if isinstance(v, dict):
|
|
72
88
|
_print_nested(v, f"{full}.")
|
|
73
89
|
else:
|
|
74
|
-
display = str(v)
|
|
75
|
-
if "key" in full.lower() or "secret" in full.lower():
|
|
76
|
-
display = display[:4] + "****" if len(display) > 4 else "****"
|
|
90
|
+
display = _mask_value(full, str(v))
|
|
77
91
|
console.print(f" {full} = {display}")
|
|
@@ -43,7 +43,8 @@ def summary(period: str, provider: str | None):
|
|
|
43
43
|
since: str | None = None
|
|
44
44
|
label = period
|
|
45
45
|
if period == "today":
|
|
46
|
-
since = (
|
|
46
|
+
since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
|
|
47
|
+
label = "today (UTC)"
|
|
47
48
|
elif period == "week":
|
|
48
49
|
since = (now - timedelta(weeks=1)).isoformat()
|
|
49
50
|
elif period == "month":
|
|
@@ -398,11 +399,8 @@ def reconcile(provider: str | None):
|
|
|
398
399
|
"""Show card charges, usage costs, and compare with local tracking.
|
|
399
400
|
|
|
400
401
|
\b
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
- GPU compute and storage usage costs
|
|
404
|
-
- Account balance and spend rate
|
|
405
|
-
Then compares provider-reported usage against swm's local records.
|
|
402
|
+
Currently supported billing APIs: RunPod and Vast.ai only.
|
|
403
|
+
Other providers show local cost tracking via ``swm costs summary``.
|
|
406
404
|
"""
|
|
407
405
|
from swm.costs.reconcile import reconcile_runpod, reconcile_vastai
|
|
408
406
|
|
|
@@ -83,6 +83,14 @@ def guard_set(instance_id: str, mode: str, idle_timeout: int | None, poll_interv
|
|
|
83
83
|
)
|
|
84
84
|
if policy.enabled and inst.status.value == "running":
|
|
85
85
|
ensure_remote_guard(inst, policy)
|
|
86
|
+
if policy.mode in ("auto-stop", "auto-down"):
|
|
87
|
+
from swm.guard import ensure_local_daemon
|
|
88
|
+
|
|
89
|
+
if not ensure_local_daemon():
|
|
90
|
+
console.print(
|
|
91
|
+
"[yellow]⚠ Could not start local guard daemon. "
|
|
92
|
+
"Run `swm guard run` in the background for auto-stop/auto-down.[/yellow]"
|
|
93
|
+
)
|
|
86
94
|
console.print(
|
|
87
95
|
f"[green]✓[/green] Guard for {inst.qualified_id}: {policy.mode} "
|
|
88
96
|
f"after {policy.idle_timeout_minutes}m idle"
|
|
@@ -91,7 +99,12 @@ def guard_set(instance_id: str, mode: str, idle_timeout: int | None, poll_interv
|
|
|
91
99
|
|
|
92
100
|
@guard.command(name="disable")
|
|
93
101
|
@click.argument("instance_id", required=False, shell_complete=complete_pod_id, callback=pod_arg_callback)
|
|
94
|
-
|
|
102
|
+
@click.option(
|
|
103
|
+
"--force-manual",
|
|
104
|
+
is_flag=True,
|
|
105
|
+
help="Write an explicit manual override so guard.defaults no longer applies.",
|
|
106
|
+
)
|
|
107
|
+
def guard_disable(instance_id: str, force_manual: bool):
|
|
95
108
|
"""Disable lifecycle automation for a pod."""
|
|
96
109
|
from swm.commands._helpers import _instance_for
|
|
97
110
|
from swm.remote.ssh import session_from_instance
|
|
@@ -100,8 +113,18 @@ def guard_disable(instance_id: str):
|
|
|
100
113
|
if inst.status.value == "running":
|
|
101
114
|
with session_from_instance(inst) as sess:
|
|
102
115
|
stop_remote_guard(sess)
|
|
103
|
-
disable_policy(inst.id)
|
|
104
|
-
|
|
116
|
+
disable_policy(inst.id, force_manual=force_manual)
|
|
117
|
+
if force_manual:
|
|
118
|
+
console.print(
|
|
119
|
+
f"[green]✓[/green] Guard disabled for {inst.qualified_id} "
|
|
120
|
+
f"(explicit manual override)"
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
console.print(f"[green]✓[/green] Guard disabled for {inst.qualified_id}")
|
|
124
|
+
console.print(
|
|
125
|
+
"[dim]Per-pod override removed — guard.defaults may still apply. "
|
|
126
|
+
"Use --force-manual to pin this pod to manual.[/dim]"
|
|
127
|
+
)
|
|
105
128
|
|
|
106
129
|
|
|
107
130
|
@guard.command(name="list")
|
|
@@ -19,7 +19,7 @@ def images():
|
|
|
19
19
|
"--provider", "-p",
|
|
20
20
|
type=click.Choice(list(PROVIDER_SLUGS), case_sensitive=False),
|
|
21
21
|
default="runpod",
|
|
22
|
-
help="Cloud provider (default: runpod).",
|
|
22
|
+
help="Cloud provider (default: runpod). Image catalog is RunPod-only today.",
|
|
23
23
|
)
|
|
24
24
|
@click.option(
|
|
25
25
|
"--cuda", "cuda_filter", default=None,
|
|
@@ -286,7 +286,8 @@ _SOURCE_CHOICES = click.Choice(
|
|
|
286
286
|
"--filename",
|
|
287
287
|
default=None,
|
|
288
288
|
help=(
|
|
289
|
-
"HF:
|
|
289
|
+
"HF: repo file path to download (supports nested paths like "
|
|
290
|
+
"split_files/loras/foo.safetensors). "
|
|
290
291
|
"URL/Civitai: override the saved filename."
|
|
291
292
|
),
|
|
292
293
|
)
|
|
@@ -340,10 +341,12 @@ def models_pull(
|
|
|
340
341
|
f"[dim](source={resolved.source}, type={resolved.asset_type})[/dim]"
|
|
341
342
|
)
|
|
342
343
|
|
|
344
|
+
engine_missing = False
|
|
343
345
|
with session_from_instance(inst) as sess:
|
|
344
346
|
sess.exec(_ensure_models_root_cmd(), stream=False)
|
|
345
347
|
|
|
346
348
|
if resolved.needs_engine and not _engine_installed(sess, resolved.needs_engine):
|
|
349
|
+
engine_missing = True
|
|
347
350
|
console.print(
|
|
348
351
|
f"\n[yellow]\u26a0 {resolved.needs_engine} is not installed on this pod.[/yellow]\n"
|
|
349
352
|
f"[yellow] The model will be staged but cannot be used until you run:[/yellow]\n"
|
|
@@ -351,34 +354,39 @@ def models_pull(
|
|
|
351
354
|
)
|
|
352
355
|
|
|
353
356
|
if resolved.source == "hf":
|
|
354
|
-
|
|
357
|
+
entries = _pull_hf(sess, resolved, hf_token, filename)
|
|
355
358
|
elif resolved.source == "ollama":
|
|
356
|
-
|
|
359
|
+
entries = [_pull_ollama(sess, resolved, hf_token=hf_token)]
|
|
357
360
|
elif resolved.source == "civitai":
|
|
358
|
-
|
|
361
|
+
entries = [_pull_civitai(sess, resolved, civitai_token, filename)]
|
|
359
362
|
elif resolved.source == "url":
|
|
360
|
-
|
|
363
|
+
entries = [_pull_url(sess, resolved, filename)]
|
|
361
364
|
else:
|
|
362
365
|
raise click.ClickException(f"Unhandled source {resolved.source!r}")
|
|
363
366
|
|
|
364
|
-
RemoteManifest(sess)
|
|
367
|
+
rm = RemoteManifest(sess)
|
|
368
|
+
for entry in entries:
|
|
369
|
+
rm.upsert(entry)
|
|
365
370
|
|
|
366
|
-
|
|
371
|
+
if len(entries) == 1:
|
|
372
|
+
console.print(
|
|
373
|
+
f"\n[green]\u2713[/green] [bold]{resolved.display_name}[/bold] staged at "
|
|
374
|
+
f"[dim]{entries[0].path}[/dim]"
|
|
375
|
+
)
|
|
376
|
+
else:
|
|
377
|
+
console.print(f"\n[green]\u2713[/green] [bold]{resolved.display_name}[/bold] staged:")
|
|
378
|
+
for entry in entries:
|
|
379
|
+
console.print(f" [dim]{entry.path}[/dim]")
|
|
367
380
|
|
|
368
|
-
if
|
|
369
|
-
# Reminder at end, same as start.
|
|
381
|
+
if engine_missing:
|
|
370
382
|
console.print(
|
|
371
383
|
f"\n[yellow]\u26a0 Remember to install the engine before using it:[/yellow]\n"
|
|
372
384
|
f"[bold] swm setup install {resolved.needs_engine} {inst.qualified_id}[/bold]\n"
|
|
373
385
|
)
|
|
374
386
|
|
|
375
387
|
|
|
376
|
-
_engine_installed_cached = False
|
|
377
|
-
|
|
378
|
-
|
|
379
388
|
def _engine_installed(sess, framework_name: str) -> bool:
|
|
380
389
|
"""Quick check: is the binary or install dir for *framework_name* present?"""
|
|
381
|
-
global _engine_installed_cached
|
|
382
390
|
probes = {
|
|
383
391
|
"vllm": "[ -x /workspace/vllm/venv/bin/vllm ]",
|
|
384
392
|
"ollama": "[ -x /usr/local/bin/ollama ]",
|
|
@@ -391,12 +399,10 @@ def _engine_installed(sess, framework_name: str) -> bool:
|
|
|
391
399
|
if not probe:
|
|
392
400
|
return True
|
|
393
401
|
exit_code, _, _ = sess.exec(f"{probe} && echo yes || echo no", stream=False)
|
|
394
|
-
|
|
395
|
-
_engine_installed_cached = installed
|
|
396
|
-
return installed
|
|
402
|
+
return exit_code == 0
|
|
397
403
|
|
|
398
404
|
|
|
399
|
-
def _pull_hf(sess, resolved, hf_token: str | None, filename_override: str | None):
|
|
405
|
+
def _pull_hf(sess, resolved, hf_token: str | None, filename_override: str | None) -> list:
|
|
400
406
|
"""Download an HF repo to /workspace/models/hf, or single-file GGUFs to /files."""
|
|
401
407
|
from swm.models import resolver as r
|
|
402
408
|
from swm.models.manifest import ModelEntry, make_key
|
|
@@ -413,7 +419,7 @@ def _pull_hf(sess, resolved, hf_token: str | None, filename_override: str | None
|
|
|
413
419
|
raise click.ClickException(f"Failed to download {resolved.ref}")
|
|
414
420
|
cache_dir = f"{bucket_path}/hub/models--{resolved.ref.replace('/', '--')}"
|
|
415
421
|
size = _disk_usage(sess, cache_dir)
|
|
416
|
-
return ModelEntry(
|
|
422
|
+
return [ModelEntry(
|
|
417
423
|
key=make_key("hf", resolved.ref),
|
|
418
424
|
source="hf",
|
|
419
425
|
ref=resolved.ref,
|
|
@@ -422,39 +428,40 @@ def _pull_hf(sess, resolved, hf_token: str | None, filename_override: str | None
|
|
|
422
428
|
size_bytes=size,
|
|
423
429
|
display_name=resolved.display_name,
|
|
424
430
|
extra={"hf_info": _slim_hf_info(resolved.extra)},
|
|
425
|
-
)
|
|
431
|
+
)]
|
|
426
432
|
|
|
427
433
|
# Single-file modes: gguf, checkpoint, lora, vae, etc. on HF.
|
|
428
434
|
files = _pick_hf_files(resolved, filename_override)
|
|
429
435
|
if not files:
|
|
430
436
|
raise click.ClickException(
|
|
431
437
|
f"Could not find a downloadable file in {resolved.ref}. "
|
|
432
|
-
"Pass --filename
|
|
438
|
+
"Pass --filename with the repo path (e.g. split_files/loras/foo.safetensors)."
|
|
433
439
|
)
|
|
434
440
|
|
|
435
|
-
target_paths: list[str] = []
|
|
436
441
|
sess.exec(f"mkdir -p {bucket_path}", stream=False)
|
|
442
|
+
entries: list = []
|
|
437
443
|
for remote_filename, save_as in files:
|
|
438
444
|
url = _hf_file_url(resolved.ref, remote_filename)
|
|
439
445
|
dest = f"{bucket_path}/{save_as}"
|
|
440
|
-
target_paths.append(dest)
|
|
441
446
|
cmd = _curl_to_file(url, dest, hf_token)
|
|
442
447
|
exit_code, _, _ = sess.exec(cmd)
|
|
443
448
|
if exit_code != 0:
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
449
|
+
hint = _hf_fetch_hint(resolved, remote_filename, filename_override)
|
|
450
|
+
raise click.ClickException(
|
|
451
|
+
f"Failed to fetch {remote_filename} from {resolved.ref}.{hint}"
|
|
452
|
+
)
|
|
453
|
+
size = _disk_usage(sess, dest)
|
|
454
|
+
entries.append(ModelEntry(
|
|
455
|
+
key=make_key("hf", resolved.ref, file_id=remote_filename),
|
|
456
|
+
source="hf",
|
|
457
|
+
ref=resolved.ref,
|
|
458
|
+
asset_type=resolved.asset_type,
|
|
459
|
+
path=dest,
|
|
460
|
+
size_bytes=size,
|
|
461
|
+
display_name=resolved.display_name,
|
|
462
|
+
extra={"remote_path": remote_filename, "save_as": save_as},
|
|
463
|
+
))
|
|
464
|
+
return entries
|
|
458
465
|
|
|
459
466
|
|
|
460
467
|
def _hf_repo_download_cmd(ref: str, cache_dir: str, token: str | None) -> str:
|
|
@@ -485,7 +492,16 @@ def _pick_hf_files(resolved, filename_override: str | None) -> list[tuple[str, s
|
|
|
485
492
|
candidates = [c for c in candidates if c]
|
|
486
493
|
|
|
487
494
|
if filename_override:
|
|
488
|
-
|
|
495
|
+
if filename_override in candidates:
|
|
496
|
+
remote = filename_override
|
|
497
|
+
else:
|
|
498
|
+
base = filename_override.rsplit("/", 1)[-1]
|
|
499
|
+
basename_matches = [c for c in candidates if c.rsplit("/", 1)[-1] == base]
|
|
500
|
+
if len(basename_matches) == 1:
|
|
501
|
+
remote = basename_matches[0]
|
|
502
|
+
else:
|
|
503
|
+
remote = filename_override
|
|
504
|
+
return [(remote, remote.rsplit("/", 1)[-1])]
|
|
489
505
|
|
|
490
506
|
ext_priority = {
|
|
491
507
|
"llm-gguf": (".gguf",),
|
|
@@ -510,6 +526,27 @@ def _hf_file_url(repo: str, path: str) -> str:
|
|
|
510
526
|
return f"https://huggingface.co/{repo}/resolve/main/{path}"
|
|
511
527
|
|
|
512
528
|
|
|
529
|
+
def _hf_fetch_hint(resolved, remote_filename: str, filename_override: str | None) -> str:
|
|
530
|
+
"""Suggest nested repo paths when a bare basename 404s."""
|
|
531
|
+
info = (resolved.extra or {}).get("hf_info", {})
|
|
532
|
+
siblings = info.get("siblings", []) or []
|
|
533
|
+
candidates = [
|
|
534
|
+
s.get("rfilename") for s in siblings if isinstance(s, dict) and s.get("rfilename")
|
|
535
|
+
]
|
|
536
|
+
if not candidates:
|
|
537
|
+
return " Run `swm models info` on the repo to inspect available files."
|
|
538
|
+
|
|
539
|
+
needle = (filename_override or remote_filename).rsplit("/", 1)[-1]
|
|
540
|
+
matches = [c for c in candidates if c.endswith(needle) or c.rsplit("/", 1)[-1] == needle]
|
|
541
|
+
if not matches:
|
|
542
|
+
return " Run `swm models info` on the repo to inspect available files."
|
|
543
|
+
if len(matches) == 1:
|
|
544
|
+
return f" Did you mean --filename {matches[0]!r}?"
|
|
545
|
+
preview = ", ".join(matches[:3])
|
|
546
|
+
suffix = f" (+{len(matches) - 3} more)" if len(matches) > 3 else ""
|
|
547
|
+
return f" Matching repo paths: {preview}{suffix}."
|
|
548
|
+
|
|
549
|
+
|
|
513
550
|
def _curl_to_file(url: str, dest: str, token: str | None) -> str:
|
|
514
551
|
auth = f"-H 'Authorization: Bearer {token}' " if token else ""
|
|
515
552
|
qd = shlex.quote(dest)
|
|
@@ -570,7 +607,7 @@ def _pull_ollama(sess, resolved, hf_token: str | None):
|
|
|
570
607
|
display_name=mirror,
|
|
571
608
|
extra={"hf_info": info},
|
|
572
609
|
)
|
|
573
|
-
return _pull_hf(sess, fallback, hf_token, None)
|
|
610
|
+
return _pull_hf(sess, fallback, hf_token, None)[0]
|
|
574
611
|
|
|
575
612
|
|
|
576
613
|
def _pull_civitai(sess, resolved, civitai_token: str | None, filename_override: str | None):
|
|
@@ -926,21 +963,3 @@ def models_remove(instance_id: str, ref: str, yes: bool):
|
|
|
926
963
|
rm.remove(target.key)
|
|
927
964
|
|
|
928
965
|
console.print(f"\n[green]\u2713[/green] Removed [bold]{target.display_name}[/bold]\n")
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
# ── set (removed; deprecation shim) ──────────────────────────────────
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
@models_group.command(name="set", hidden=True)
|
|
935
|
-
@click.argument("instance_id", required=False)
|
|
936
|
-
@click.argument("model", required=False)
|
|
937
|
-
def models_set(instance_id: str | None, model: str | None):
|
|
938
|
-
"""[removed] Use `swm setup start vllm <pod> --model <ref>` instead."""
|
|
939
|
-
pod = instance_id or "<pod>"
|
|
940
|
-
target = model or "<model>"
|
|
941
|
-
raise click.UsageError(
|
|
942
|
-
"`swm models set` was removed in v0.2.\n"
|
|
943
|
-
f" Start vLLM with an explicit model instead:\n"
|
|
944
|
-
f" swm setup stop vllm {pod}\n"
|
|
945
|
-
f" swm setup start vllm {pod} --model {target}"
|
|
946
|
-
)
|
|
@@ -313,6 +313,11 @@ def create(
|
|
|
313
313
|
console.print("[dim]Cancelled.[/dim]")
|
|
314
314
|
return
|
|
315
315
|
|
|
316
|
+
if volume <= 0:
|
|
317
|
+
raise click.UsageError("--volume must be at least 1 GB")
|
|
318
|
+
if provider == "runpod" and volume < 1:
|
|
319
|
+
raise click.UsageError("RunPod requires --volume >= 1 GB for /workspace")
|
|
320
|
+
|
|
316
321
|
try:
|
|
317
322
|
with console.status(
|
|
318
323
|
f"Creating {p.name} instance…", spinner="dots"
|
|
@@ -335,8 +340,8 @@ def create(
|
|
|
335
340
|
if warning:
|
|
336
341
|
for line in warning.splitlines():
|
|
337
342
|
console.print(f" [yellow]⚠ {line}[/yellow]")
|
|
338
|
-
except Exception:
|
|
339
|
-
|
|
343
|
+
except Exception as exc:
|
|
344
|
+
console.print(f" [dim]⚠ Cost tracking (start) failed: {exc}[/dim]")
|
|
340
345
|
|
|
341
346
|
from swm.bootstrap import wait_for_ssh
|
|
342
347
|
ssh_ok = False
|
|
@@ -351,7 +356,7 @@ def create(
|
|
|
351
356
|
cfg.set_value(f"pods.{inst.id}.name", name)
|
|
352
357
|
set_active_pod(inst.id)
|
|
353
358
|
policy = None
|
|
354
|
-
if lifecycle_mode:
|
|
359
|
+
if lifecycle_mode and lifecycle_mode != "manual":
|
|
355
360
|
from swm.guard import set_policy
|
|
356
361
|
|
|
357
362
|
policy = set_policy(
|
|
@@ -519,8 +524,8 @@ def start(instance_id: str):
|
|
|
519
524
|
if warning:
|
|
520
525
|
for line in warning.splitlines():
|
|
521
526
|
console.print(f" [yellow]⚠ {line}[/yellow]")
|
|
522
|
-
except Exception:
|
|
523
|
-
|
|
527
|
+
except Exception as exc:
|
|
528
|
+
console.print(f" [dim]⚠ Cost tracking (start) failed: {exc}[/dim]")
|
|
524
529
|
|
|
525
530
|
|
|
526
531
|
@pod.command()
|
|
@@ -541,8 +546,8 @@ def stop(instance_id: str):
|
|
|
541
546
|
try:
|
|
542
547
|
from swm.costs.tracker import record_stop
|
|
543
548
|
record_stop(raw_id, provider.slug)
|
|
544
|
-
except Exception:
|
|
545
|
-
|
|
549
|
+
except Exception as exc:
|
|
550
|
+
console.print(f" [dim]⚠ Cost tracking (stop) failed: {exc}[/dim]")
|
|
546
551
|
|
|
547
552
|
|
|
548
553
|
@pod.command()
|
|
@@ -577,8 +582,8 @@ def terminate(instance_id: str, yes: bool):
|
|
|
577
582
|
try:
|
|
578
583
|
from swm.costs.tracker import record_stop
|
|
579
584
|
record_stop(raw_id, provider.slug)
|
|
580
|
-
except Exception:
|
|
581
|
-
|
|
585
|
+
except Exception as exc:
|
|
586
|
+
console.print(f" [dim]⚠ Cost tracking (stop) failed: {exc}[/dim]")
|
|
582
587
|
|
|
583
588
|
cfg.delete(f"pods.{raw_id}")
|
|
584
589
|
clear_active_pod(if_matches=raw_id)
|
|
@@ -716,8 +721,8 @@ def pod_down(instance_id: str, yes: bool, no_sync: bool, exclude: tuple[str, ...
|
|
|
716
721
|
try:
|
|
717
722
|
from swm.costs.tracker import record_stop
|
|
718
723
|
record_stop(raw_id, provider.slug)
|
|
719
|
-
except Exception:
|
|
720
|
-
|
|
724
|
+
except Exception as exc:
|
|
725
|
+
console.print(f" [dim]⚠ Cost tracking (stop) failed: {exc}[/dim]")
|
|
721
726
|
|
|
722
727
|
cfg.delete(f"pods.{raw_id}")
|
|
723
728
|
clear_active_pod(if_matches=raw_id)
|
|
@@ -732,14 +737,3 @@ def pod_down(instance_id: str, yes: bool, no_sync: bool, exclude: tuple[str, ...
|
|
|
732
737
|
f" Restore later: [bold]swm pod create -p {provider.slug} "
|
|
733
738
|
f"-g <gpu> -n <name> -w {meta['workspace']}[/bold]"
|
|
734
739
|
)
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
@pod.command(name="gpus", hidden=True, deprecated=True)
|
|
738
|
-
@click.option("--provider", "-p", default=None, help="Filter to one provider")
|
|
739
|
-
@click.option("--gpu", "-g", default=None, help="Filter by GPU type")
|
|
740
|
-
@click.pass_context
|
|
741
|
-
def pod_gpus_alias(ctx: click.Context, provider: str | None, gpu: str | None):
|
|
742
|
-
"""Alias for 'swm gpus'. Use 'swm gpus' instead."""
|
|
743
|
-
console.print("[dim]Hint: use 'swm gpus' directly for more filters.[/dim]\n")
|
|
744
|
-
from swm.cli import gpus
|
|
745
|
-
ctx.invoke(gpus, gpu=gpu, provider=provider)
|
|
@@ -8,6 +8,8 @@ from swm.pricing.providers import OFFERINGS, GPU_SPECS
|
|
|
8
8
|
from swm.pricing.calculator import estimate_workload
|
|
9
9
|
from swm.commands._helpers import console
|
|
10
10
|
|
|
11
|
+
_GPU_CHOICES = click.Choice(sorted(GPU_SPECS.keys()), case_sensitive=False)
|
|
12
|
+
|
|
11
13
|
|
|
12
14
|
@click.group()
|
|
13
15
|
def pricing():
|
|
@@ -15,7 +17,7 @@ def pricing():
|
|
|
15
17
|
|
|
16
18
|
|
|
17
19
|
@pricing.command()
|
|
18
|
-
@click.option("--gpu", type=
|
|
20
|
+
@click.option("--gpu", type=_GPU_CHOICES, help="Filter by GPU type")
|
|
19
21
|
@click.option("--single-gpu", is_flag=True, help="Only show single-GPU providers")
|
|
20
22
|
def compare(gpu: str | None, single_gpu: bool):
|
|
21
23
|
"""Compare per-GPU/hr pricing across all providers."""
|
|
@@ -66,7 +68,7 @@ def compare(gpu: str | None, single_gpu: bool):
|
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
@pricing.command()
|
|
69
|
-
@click.option("--gpu", default="h200", type=
|
|
71
|
+
@click.option("--gpu", default="h200", type=_GPU_CHOICES, help="GPU type")
|
|
70
72
|
@click.option("--hours", default=3.0, type=float, help="Hours per week")
|
|
71
73
|
@click.option("--storage", default=100.0, type=float, help="Model storage in GB")
|
|
72
74
|
@click.option("--provider", default=None, help="Filter to one provider")
|
|
@@ -138,7 +140,7 @@ def estimate(gpu: str, hours: float, storage: float, provider: str | None, singl
|
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
@pricing.command()
|
|
141
|
-
@click.option("--gpu", type=
|
|
143
|
+
@click.option("--gpu", type=_GPU_CHOICES, help="Filter by GPU type")
|
|
142
144
|
def specs(gpu: str | None):
|
|
143
145
|
"""Show GPU hardware specs side-by-side."""
|
|
144
146
|
table = Table(title="GPU Specifications", title_style="bold", show_lines=True)
|