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.
Files changed (109) hide show
  1. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/CHANGELOG.md +26 -0
  2. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/PKG-INFO +10 -1
  3. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/pyproject.toml +8 -2
  4. swm_gpu-0.2.3/src/swm/__init__.py +1 -0
  5. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/cli.py +9 -2
  6. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/_helpers.py +18 -4
  7. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/config.py +22 -8
  8. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/costs.py +4 -6
  9. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/guard.py +26 -3
  10. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/images.py +1 -1
  11. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/models.py +75 -56
  12. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/pod.py +16 -22
  13. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/pricing.py +5 -3
  14. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/setup.py +0 -29
  15. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/sync.py +49 -3
  16. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/use.py +8 -2
  17. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/swarmui.py +8 -5
  18. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/guard.py +12 -4
  19. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/manifest.py +8 -3
  20. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/pricing/providers.py +96 -0
  21. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/pull.py +6 -4
  22. swm_gpu-0.2.2/src/swm/__init__.py +0 -1
  23. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/.agents/skills/swm-gpu-workflow/SKILL.md +0 -0
  24. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/.gitignore +0 -0
  25. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/CODE_OF_CONDUCT.md +0 -0
  26. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/CONTRIBUTING.md +0 -0
  27. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/LICENSE +0 -0
  28. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/NOTICE +0 -0
  29. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/README.md +0 -0
  30. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/SECURITY.md +0 -0
  31. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/bootstrap.py +0 -0
  32. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/bootstrap_frameworks.py +0 -0
  33. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/bootstrap_ssh.py +0 -0
  34. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/__init__.py +0 -0
  35. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/remote.py +0 -0
  36. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/commands/storage.py +0 -0
  37. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/config.py +0 -0
  38. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/__init__.py +0 -0
  39. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/billing.py +0 -0
  40. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/budget.py +0 -0
  41. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/db.py +0 -0
  42. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/reconcile.py +0 -0
  43. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/costs/tracker.py +0 -0
  44. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/cuda.py +0 -0
  45. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/__init__.py +0 -0
  46. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/axolotl.py +0 -0
  47. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/comfyui.py +0 -0
  48. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/llm_studio.py +0 -0
  49. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/ollama.py +0 -0
  50. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/open_webui.py +0 -0
  51. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/frameworks/vllm_server.py +0 -0
  52. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/images.py +0 -0
  53. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/__init__.py +0 -0
  54. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/civitai.py +0 -0
  55. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/huggingface.py +0 -0
  56. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/models/resolver.py +0 -0
  57. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/pricing/__init__.py +0 -0
  58. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/pricing/calculator.py +0 -0
  59. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/__init__.py +0 -0
  60. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/aws.py +0 -0
  61. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/azure.py +0 -0
  62. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/base.py +0 -0
  63. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/coreweave.py +0 -0
  64. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/fluidstack.py +0 -0
  65. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/gcp.py +0 -0
  66. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/lambda_labs.py +0 -0
  67. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/runpod.py +0 -0
  68. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/tensordock.py +0 -0
  69. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/vastai.py +0 -0
  70. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/providers/vultr.py +0 -0
  71. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/remote/__init__.py +0 -0
  72. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/remote/ssh.py +0 -0
  73. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/__init__.py +0 -0
  74. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/b2.py +0 -0
  75. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/base.py +0 -0
  76. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/gcs.py +0 -0
  77. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/storage/s3.py +0 -0
  78. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/__init__.py +0 -0
  79. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/_autosync_daemon.sh +0 -0
  80. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/_common.py +0 -0
  81. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/autosync.py +0 -0
  82. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/paths.py +0 -0
  83. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/preflight.py +0 -0
  84. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/push.py +0 -0
  85. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/src/swm/sync/watcher.py +0 -0
  86. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/swm_main.py +0 -0
  87. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/.gitignore +0 -0
  88. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/README.md +0 -0
  89. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/download-music.sh +0 -0
  90. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/package-lock.json +0 -0
  91. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/package.json +0 -0
  92. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/remotion.config.ts +0 -0
  93. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/Root.tsx +0 -0
  94. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/ChapterCard.tsx +0 -0
  95. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/EndCard.tsx +0 -0
  96. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/GpuTable.tsx +0 -0
  97. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/OutputBlock.tsx +0 -0
  98. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/PHBadge.tsx +0 -0
  99. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/ProviderCascade.tsx +0 -0
  100. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/TerminalFrame.tsx +0 -0
  101. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/TypingLine.tsx +0 -0
  102. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/components/Wordmark.tsx +0 -0
  103. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/compositions/Hero16x9.tsx +0 -0
  104. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/compositions/Square1x1.tsx +0 -0
  105. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/compositions/Vertical9x16.tsx +0 -0
  106. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/config/music.ts +0 -0
  107. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/script/scrubbed-logs.ts +0 -0
  108. {swm_gpu-0.2.2 → swm_gpu-0.2.3}/video/src/styles/tokens.ts +0 -0
  109. {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.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.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 for ComfyUI / SwarmUI."""
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
- sources = []
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 (`pods.<provider:id>`)."""
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
- return sorted(pods.keys())
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
- return [pid for pid in _iter_configured_pod_ids() if pid.startswith(incomplete)]
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
- return value in _iter_configured_pod_ids()
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
- display = value if "key" not in key.lower() else value[:4] + "****"
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 = (now - timedelta(days=1)).isoformat()
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
- Queries provider billing APIs for:
402
- - Card / payment charges (Stripe auto-reload, crypto, etc.)
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
- def guard_disable(instance_id: str):
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
- console.print(f"[green]✓[/green] Guard disabled for {inst.qualified_id}")
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: exact repo file to download. "
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
- entry = _pull_hf(sess, resolved, hf_token, filename)
357
+ entries = _pull_hf(sess, resolved, hf_token, filename)
355
358
  elif resolved.source == "ollama":
356
- entry = _pull_ollama(sess, resolved, hf_token=hf_token)
359
+ entries = [_pull_ollama(sess, resolved, hf_token=hf_token)]
357
360
  elif resolved.source == "civitai":
358
- entry = _pull_civitai(sess, resolved, civitai_token, filename)
361
+ entries = [_pull_civitai(sess, resolved, civitai_token, filename)]
359
362
  elif resolved.source == "url":
360
- entry = _pull_url(sess, resolved, filename)
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).upsert(entry)
367
+ rm = RemoteManifest(sess)
368
+ for entry in entries:
369
+ rm.upsert(entry)
365
370
 
366
- console.print(f"\n[green]\u2713[/green] [bold]{resolved.display_name}[/bold] staged at [dim]{entry.path}[/dim]")
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 resolved.needs_engine and not _engine_installed_cached:
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
- installed = exit_code == 0
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 to target a specific asset."
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
- raise click.ClickException(f"Failed to fetch {remote_filename} from {resolved.ref}")
445
-
446
- total_size = sum(_disk_usage(sess, p) for p in target_paths)
447
- primary_path = target_paths[0] if len(target_paths) == 1 else bucket_path
448
- return ModelEntry(
449
- key=make_key("hf", resolved.ref),
450
- source="hf",
451
- ref=resolved.ref,
452
- asset_type=resolved.asset_type,
453
- path=primary_path,
454
- size_bytes=total_size,
455
- display_name=resolved.display_name,
456
- extra={"files": [p.rsplit("/", 1)[-1] for p in target_paths]},
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
- return [(filename_override, filename_override.rsplit("/", 1)[-1])]
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
- pass
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
- pass
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
- pass
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
- pass
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
- pass
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=click.Choice(["h200", "b200"], case_sensitive=False), help="Filter by 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=click.Choice(["h200", "b200"], case_sensitive=False), help="GPU 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=click.Choice(["h200", "b200"], case_sensitive=False), help="Filter by 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)