jambu-gpu 0.2.0__tar.gz → 0.3.1__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 (96) hide show
  1. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/.gitignore +1 -0
  2. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/CONTRIBUTING.md +2 -2
  3. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/PKG-INFO +2 -2
  4. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/README.md +1 -1
  5. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/ACCEPTANCE.md +2 -2
  6. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/CONFIG.md +2 -1
  7. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/INTEGRATION.md +6 -5
  8. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/LIFECYCLE.md +1 -1
  9. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/USAGE.md +5 -5
  10. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/README.md +1 -1
  11. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/__init__.py +1 -1
  12. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/_common.py +1 -1
  13. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/account.py +2 -2
  14. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/main.py +1 -1
  15. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/timer.py +6 -4
  16. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/config.py +7 -2
  17. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/credentials.py +4 -2
  18. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/engine.py +13 -0
  19. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/events.py +1 -1
  20. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/hosts.py +1 -1
  21. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/models.py +8 -0
  22. jambu_gpu-0.3.1/jambu_gpu/core/paths.py +42 -0
  23. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/state.py +2 -2
  24. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/sweep.py +6 -6
  25. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/mapper.py +18 -0
  26. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/runtime/base.py +10 -0
  27. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/runtime/vllm.py +10 -0
  28. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/pyproject.toml +2 -2
  29. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/conftest.py +4 -3
  30. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_config.py +1 -1
  31. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_engine.py +12 -0
  32. jambu_gpu-0.3.1/tests/test_paths.py +50 -0
  33. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_runtime_vllm.py +13 -0
  34. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_state.py +1 -1
  35. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_sweep.py +5 -5
  36. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_vast_mapper.py +35 -3
  37. jambu_gpu-0.3.1/uv.lock +870 -0
  38. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/.env.example +0 -0
  39. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/LICENSE +0 -0
  40. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/ADDING_A_PROVIDER.md +0 -0
  41. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/CHOOSING_A_MODEL.md +0 -0
  42. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/docs/assets/jambu-logo.jpg +0 -0
  43. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/experiment.py +0 -0
  44. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/jambu.yaml +0 -0
  45. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/README.md +0 -0
  46. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/package-lock.json +0 -0
  47. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/package.json +0 -0
  48. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/src/agent.ts +0 -0
  49. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/src/index.ts +0 -0
  50. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/src/tools/weather-tool.ts +0 -0
  51. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/mastra-agent/tsconfig.json +0 -0
  52. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/package-lock.json +0 -0
  53. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/package.json +0 -0
  54. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/src/index.ts +0 -0
  55. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/src/jambuGpu.ts +0 -0
  56. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/examples/typescript-client/tsconfig.json +0 -0
  57. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu.example.yaml +0 -0
  58. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu.models.yaml +0 -0
  59. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu.yaml +0 -0
  60. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/__main__.py +0 -0
  61. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/__init__.py +0 -0
  62. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/inspect.py +0 -0
  63. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/lifecycle.py +0 -0
  64. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/providers.py +0 -0
  65. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/run.py +0 -0
  66. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/setup.py +0 -0
  67. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/status.py +0 -0
  68. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/cli/validate.py +0 -0
  69. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/__init__.py +0 -0
  70. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/beacon.py +0 -0
  71. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/durations.py +0 -0
  72. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/errors.py +0 -0
  73. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/execution.py +0 -0
  74. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/health.py +0 -0
  75. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/model_inspect.py +0 -0
  76. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/policy.py +0 -0
  77. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/core/tool_parsers.py +0 -0
  78. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/__init__.py +0 -0
  79. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/base.py +0 -0
  80. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/registry.py +0 -0
  81. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/__init__.py +0 -0
  82. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/client.py +0 -0
  83. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/config.py +0 -0
  84. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/providers/vast/provider.py +0 -0
  85. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/runtime/__init__.py +0 -0
  86. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/watchdog/__init__.py +0 -0
  87. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/watchdog/agent_body.py +0 -0
  88. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/jambu_gpu/watchdog/builder.py +0 -0
  89. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/fakes.py +0 -0
  90. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_cli.py +0 -0
  91. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_engine_sessions.py +0 -0
  92. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_integration.py +0 -0
  93. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_model_inspect.py +0 -0
  94. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_policy.py +0 -0
  95. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_vast_provider.py +0 -0
  96. {jambu_gpu-0.2.0 → jambu_gpu-0.3.1}/tests/test_watchdog.py +0 -0
@@ -5,6 +5,7 @@ venv/
5
5
  dist/
6
6
  build/
7
7
  *.egg-info/
8
+ .gpu/
8
9
  .jambu/
9
10
  .env
10
11
  .env.*
@@ -62,9 +62,9 @@ job queues, a web dashboard. See [docs/ACCEPTANCE.md](docs/ACCEPTANCE.md).
62
62
 
63
63
  ## Secrets
64
64
 
65
- Never commit credentials, `.env`, `.jambu/state.json`, or provider instance
65
+ Never commit credentials, `.env`, `.gpu/state.json`, or provider instance
66
66
  ids from a real run. Keys are read from the environment, a gitignored `.env`,
67
- or `~/.jambu/credentials`. `.env.example` ships with empty values only.
67
+ or `~/.gpu/credentials`. `.env.example` ships with empty values only.
68
68
 
69
69
  ## Pull requests
70
70
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: jambu-gpu
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: Provider-agnostic CLI for provisioning and managing temporary GPU runtimes
5
5
  Project-URL: Homepage, https://github.com/jambuai/jambu-gpu
6
6
  Project-URL: Repository, https://github.com/jambuai/jambu-gpu
@@ -49,7 +49,7 @@ From an application in any language: `gpu --json setup` returns the OpenAI-compa
49
49
 
50
50
  As a last line of defense, `gpu ls` shows every jambu instance in the account and `gpu sweep --install-timer` sets up a systemd user timer that guards every project on the machine by its own rules and cleans up the orphans no project tracks. See [Keeping the account clean](docs/USAGE.md#keeping-the-account-clean).
51
51
 
52
- The API key is read from the environment, a project `.env`, or `~/.jambu/credentials`. It never goes in the manifest.
52
+ The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
53
53
 
54
54
  ## Declare
55
55
 
@@ -26,7 +26,7 @@ From an application in any language: `gpu --json setup` returns the OpenAI-compa
26
26
 
27
27
  As a last line of defense, `gpu ls` shows every jambu instance in the account and `gpu sweep --install-timer` sets up a systemd user timer that guards every project on the machine by its own rules and cleans up the orphans no project tracks. See [Keeping the account clean](docs/USAGE.md#keeping-the-account-clean).
28
28
 
29
- The API key is read from the environment, a project `.env`, or `~/.jambu/credentials`. It never goes in the manifest.
29
+ The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
30
30
 
31
31
  ## Declare
32
32
 
@@ -4,7 +4,7 @@ Eighteen guarantees, mapped to the code and the test that holds each one.
4
4
 
5
5
  | # | Guarantee | Implementation | Test |
6
6
  | --- | --- | --- | --- |
7
- | 1 | Repeated `setup` does not duplicate infrastructure | `core/engine.py::Engine.setup` reconciles first, compares `config.fingerprint()`, and holds an exclusive `.jambu/runtime.lock` | `test_engine.py::test_setup_twice_does_not_create_two_instances`, `::test_setup_refuses_to_reuse_an_incompatible_instance` |
7
+ | 1 | Repeated `setup` does not duplicate infrastructure | `core/engine.py::Engine.setup` reconciles first, compares `config.fingerprint()`, and holds an exclusive `.gpu/runtime.lock` | `test_engine.py::test_setup_twice_does_not_create_two_instances`, `::test_setup_refuses_to_reuse_an_incompatible_instance` |
8
8
  | 2 | Provider APIs do not leak into core orchestration | `providers/base.py` contract; all Vast wire format confined to `providers/vast/{client,mapper}.py` | `test_engine.py::test_the_core_never_branches_on_a_provider_name` (greps `core/`) |
9
9
  | 3 | Missing credentials fail before provisioning | `VastProvider.validate` returns before any API call; `CredentialResolver` never reads jambu.yaml | `test_vast_provider.py::test_validate_fails_before_provisioning_without_a_key` (asserts zero HTTP calls) |
10
10
  | 4 | Failed provisioning cleans up | `Engine.setup` persists state *before* the long wait, then destroys on failure when `cleanup_on_setup_failure` | `test_engine.py::test_failed_setup_cleans_up_partially_created_resources` |
@@ -19,7 +19,7 @@ Eighteen guarantees, mapped to the code and the test that holds each one.
19
19
  | 13 | A restarted instance stays guarded | `Engine._reconcile` takes running/stopped from the provider; the watchdog's `terminated` flag only counts when the provider cannot tell. `RuntimeState.session_started_at` restarts on every start, and `max_lifetime` and the session budget are measured from it | `test_engine_sessions.py::test_reconcile_trusts_the_provider_over_a_stale_terminated_flag`, `::test_a_stale_terminated_flag_does_not_disable_the_guard`, `::test_restarting_a_stopped_instance_starts_a_new_session`, `::test_guard_budget_resets_with_a_new_session`; watchdog-side session tests in `test_policy.py` / `test_watchdog.py` |
20
20
  | 14 | Traffic on the model endpoint is activity | `VllmRuntime.activity_probe` hands the watchdog vLLM's `/metrics` gauges and counters; `lifecycle.traffic_counts_as_activity` turns it off | `test_engine_sessions.py::test_the_watchdog_is_told_how_to_read_vllm_traffic`, `::test_traffic_can_be_excluded_from_activity`, `test_runtime_vllm.py::test_activity_probe_reads_vllm_metrics_on_loopback`; the watchdog's metrics-reading tests in `test_watchdog.py` |
21
21
  | 15 | A stopped instance does not bill storage forever | `lifecycle.stopped_ttl` -> `PolicyConfig.stopped_ttl_s`; `RuntimeState.stopped_since`; `Engine.guard_tick` destroys with reason `stopped_ttl`; `validate` warns when nothing would remove it | `test_engine_sessions.py::test_guard_destroys_an_instance_stopped_longer_than_stopped_ttl`, `::test_validate_warns_when_nothing_removes_a_billing_stopped_instance`; the stopped-TTL policy tests in `test_policy.py` |
22
- | 16 | Orphans are found account-wide, each project keeps its own rules, and nothing else is touched | `GPUProvider.list_instances` (capability `list_instances`) returns only `jambu-` labels; `gpu setup` registers the project in `~/.jambu/projects.json`; `core/sweep.py::guard_projects` runs each registered project's own `Engine.guard_tick`; only untracked instances are orphans: running past `--orphan-max-age` is stopped (never destroyed), stopped past `--stopped-ttl` (from the sweep's own first sighting in `~/.jambu/sweep.json`) is destroyed; `gpu sweep --install-timer` schedules one working-directory-independent unit and refuses a key that is only in the shell | `test_sweep.py::test_vast_lists_only_jambu_labelled_instances`, `::test_non_jambu_instances_are_never_touched`, `::test_another_projects_restarted_and_locked_instance_is_not_destroyed`, `::test_each_project_is_judged_by_its_own_max_lifetime`, `::test_a_running_orphan_past_orphan_max_age_is_stopped_never_destroyed`, `::test_a_stopped_orphan_is_destroyed_once_seen_stopped_past_stopped_ttl`, `::test_install_timer_writes_one_unit_independent_of_the_project`, `::test_install_timer_refuses_a_key_only_in_the_shell_environment` |
22
+ | 16 | Orphans are found account-wide, each project keeps its own rules, and nothing else is touched | `GPUProvider.list_instances` (capability `list_instances`) returns only `jambu-` labels; `gpu setup` registers the project in `~/.gpu/projects.json`; `core/sweep.py::guard_projects` runs each registered project's own `Engine.guard_tick`; only untracked instances are orphans: running past `--orphan-max-age` is stopped (never destroyed), stopped past `--stopped-ttl` (from the sweep's own first sighting in `~/.gpu/sweep.json`) is destroyed; `gpu sweep --install-timer` schedules one working-directory-independent unit and refuses a key that is only in the shell | `test_sweep.py::test_vast_lists_only_jambu_labelled_instances`, `::test_non_jambu_instances_are_never_touched`, `::test_another_projects_restarted_and_locked_instance_is_not_destroyed`, `::test_each_project_is_judged_by_its_own_max_lifetime`, `::test_a_running_orphan_past_orphan_max_age_is_stopped_never_destroyed`, `::test_a_stopped_orphan_is_destroyed_once_seen_stopped_past_stopped_ttl`, `::test_install_timer_writes_one_unit_independent_of_the_project`, `::test_install_timer_refuses_a_key_only_in_the_shell_environment` |
23
23
  | 17 | The CLI guard never stops a serving endpoint it cannot see | `Engine._idle_blind`: with the watchdog enabled and installed but unreachable, `guard_tick` evaluates without `idle_timeout` and records `idle_skipped`; `max_lifetime`, budget and `stopped_ttl` still apply | `test_engine_sessions.py::test_guard_leaves_idle_to_an_unreachable_watchdog`, `::test_max_lifetime_still_applies_when_the_watchdog_is_unreachable` |
24
24
  | 18 | A fresh stop or a short API outage never costs the disk | `RuntimeState.mark_stopped` restarts `stopped_since` on evidence the instance ran since; the watchdog escalates a failing stop to destroy only after `escalate_after_s` (30m) of continuous failure and resets on renewed use | `test_engine_sessions.py::test_a_stale_stopped_since_does_not_destroy_a_fresh_stop`, `test_watchdog.py::test_an_api_blip_does_not_destroy_the_disk`, `::test_failures_reset_when_the_instance_is_back_in_use` |
25
25
 
@@ -86,10 +86,11 @@ each number comes from.
86
86
  | `gpu.count` | `1` | Number of GPUs on one machine. |
87
87
  | `gpu.name_filter` | – | Substring match, e.g. `A100`, `RTX 4090`. |
88
88
  | `gpu.cuda_min` | – | Minimum host CUDA version. `gpu` raises it to what the runtime image needs, read from its tag (`-cu129` means 12.9, vLLM's bare `v0.32+` and `latest` mean 13.0), so an image never lands on a driver too old to run it. |
89
+ | `gpu.compute_cap_min` | – | Minimum GPU **compute capability** (architecture), e.g. `8.0` for Ampere. Distinct from `cuda_min`: a Pascal (GTX 10xx, 6.1) or Volta (V100, 7.0) card can sit behind a modern CUDA-13 driver — clearing `cuda_min` — yet still be too old for a runtime image whose kernels are built for newer architectures, which then crashes at engine startup. `gpu` raises this to what the runtime needs: a CUDA-13 vLLM image floors it at `8.0` (those wheels drop Volta), a CUDA-12 vLLM image at `7.0`. The cheapest Vast offers are often exactly these old cards, so without the floor the search keeps picking a GPU the image cannot run. |
89
90
  | `disk_gb` | `80` | Disk. Must fit the model weights plus the container. |
90
91
  | `region` | – | Comma-separated geolocations. |
91
92
  | `spot` | `false` | Use interruptible/bid capacity where supported. |
92
- | `host_attempts` | `3` | How many machines `setup` tries. A host that fails to boot (the provider reports it failed, it vanishes, or it is not running within `health.boot_timeout`) is destroyed, replaced from fresh offers (each held to the budget), and remembered in `$JAMBU_HOME/bad_hosts.json` so no setup rents it again for 24h. A model server that dies once its container runs fails at once, since the same config would fail on any machine, unless its log shows the host's network failed it (a dropped connection or timeout while downloading weights): that counts as a host failure too. Needs `lifecycle.cleanup_on_setup_failure`; without it the first failed host is kept for inspection. |
93
+ | `host_attempts` | `3` | How many machines `setup` tries. A host that fails to boot (the provider reports it failed, it vanishes, or it is not running within `health.boot_timeout`) is destroyed, replaced from fresh offers (each held to the budget), and remembered in `$GPU_HOME/bad_hosts.json` so no setup rents it again for 24h. A model server that dies once its container runs fails at once, since the same config would fail on any machine, unless its log shows the host's network failed it (a dropped connection or timeout while downloading weights): that counts as a host failure too. Needs `lifecycle.cleanup_on_setup_failure`; without it the first failed host is kept for inspection. |
93
94
  | `image` | – | Override the runtime's container image. vLLM's default is `vllm/vllm-openai:v0.32.0`. Pin a tag: `validate` warns on `latest` or an untagged image, because the CUDA it needs can change under you. On Vast, `vastai/vllm:v0.32.0-cuda-13.0` (the same vLLM, packaged by Vast) is often cached on the host and boots in minutes where a 12 GB Docker Hub pull can stall. |
94
95
 
95
96
  ---
@@ -15,16 +15,17 @@ The app never has to stop the GPU. The watchdog on the machine stops it once not
15
15
  pipx install jambu-gpu # or: uv tool install jambu-gpu / pip install jambu-gpu
16
16
  cd /srv/my-app # the directory the app runs gpu from
17
17
  gpu init --model Qwen/Qwen2.5-7B-Instruct
18
- echo "VAST_API_KEY=..." >> .env # or export it, or ~/.jambu/credentials
18
+ echo "VAST_API_KEY=..." >> .env # or export it, or ~/.gpu/credentials
19
19
  gpu validate # fails here, not in production, on a bad config
20
20
  gpu sweep --install-timer # optional safety net, see USAGE.md
21
21
  ```
22
22
 
23
23
  Requirements:
24
24
 
25
- - `jambu-gpu` 0.2.0 or later, on Python 3.10+. The app can be in any language.
26
- - The directory holding `jambu.yaml` must be **persistent**. `gpu` keeps what it rented in `.jambu/state.json` next to it. In a container, mount it as a volume: a container that loses `.jambu/` forgets its instance, rents a second one, and leaves the first to the watchdog and the sweep.
27
- - `.jambu/state.json` holds the endpoint key and the watchdog token. It is written `0600`. Add `.jambu/` and `.env` to `.gitignore` and keep them out of images.
25
+ - `jambu-gpu` 0.3.0 or later, on Python 3.10+. The app can be in any language.
26
+ - The directory holding `jambu.yaml` must be **persistent**. `gpu` keeps what it rented in `.gpu/state.json` next to it. In a container, mount it as a volume: a container that loses `.gpu/` forgets its instance, rents a second one, and leaves the first to the watchdog and the sweep.
27
+ - Before 0.3.0 these directories were `.jambu/` and `~/.jambu`. The first command after an upgrade renames them, so an instance already rented is kept, not rented again.
28
+ - `.gpu/state.json` holds the endpoint key and the watchdog token. It is written `0600`. Add `.gpu/` and `.env` to `.gitignore` and keep them out of images.
28
29
 
29
30
  Tune the lifecycle in `jambu.yaml` for the app. For a service with bursty traffic, a longer idle timeout avoids paying a restart on every burst:
30
31
 
@@ -218,7 +219,7 @@ Run `gpu --json setup` as a subprocess, parse the JSON, and point the language's
218
219
  - **The endpoint requires a key.** Unless `runtime.api_key` is set, `setup` generates one per machine (43 random URL-safe characters) and starts vLLM with it. Requests without it get `401`. `/health` and `/metrics` stay open: they reveal no data and the watchdog reads `/metrics`. Instances provisioned before this existed run without a key until they are recreated.
219
220
  - **The endpoint is plain HTTP** on a public IP and port. The key and the prompts travel unencrypted. For sensitive data, put the app and the GPU on a private network or a tunnel, or front the endpoint with a TLS proxy.
220
221
  - **The watchdog holds the provider key** on the instance so it can stop its own machine. A restricted key is the safer choice; see [LIFECYCLE.md](LIFECYCLE.md).
221
- - `jambu.yaml` never holds credentials. `.env` and `.jambu/` do not belong in version control.
222
+ - `jambu.yaml` never holds credentials. `.env` and `.gpu/` do not belong in version control.
222
223
 
223
224
  ## 6. Troubleshooting
224
225
 
@@ -105,7 +105,7 @@ gpu
105
105
  │
106
106
  ├── Lifecycle core/policy.py the only shutdown rule
107
107
  ├── Runtime runtime/vllm.py how the model runs
108
- └── State core/state.py .jambu/state.json
108
+ └── State core/state.py .gpu/state.json
109
109
  │
110
110
  Provider contract providers/base.py
111
111
  │
@@ -111,9 +111,9 @@ Vast.ai instances
111
111
 
112
112
  ### Every project by its own rules
113
113
 
114
- `gpu setup` (and `gpu run`, which can provision) registers the project's absolute config path in `~/.jambu/projects.json` (`$JAMBU_HOME` overrides `~/.jambu`). `gpu sweep` then works in two steps:
114
+ `gpu setup` (and `gpu run`, which can provision) registers the project's absolute config path in `~/.gpu/projects.json` (`$GPU_HOME` overrides `~/.gpu`; a `~/.jambu` from before 0.3.0 is renamed to `~/.gpu` on first use, and `$JAMBU_HOME` is still read). `gpu sweep` then works in two steps:
115
115
 
116
- 1. **Tracked instances.** For every registered project whose config still loads, it runs that project's own `gpu guard`: its own `jambu.yaml` and `.jambu/state.json`, so its own `max_lifetime`, lock, session start, `stopped_ttl` and budget. The project's events land in its own log. A registered project whose config file is gone is dropped from the registry. The project in the current directory, or the one given with `--config`, is guarded too for that run.
116
+ 1. **Tracked instances.** For every registered project whose config still loads, it runs that project's own `gpu guard`: its own `jambu.yaml` and `.gpu/state.json`, so its own `max_lifetime`, lock, session start, `stopped_ttl` and budget. The project's events land in its own log. A registered project whose config file is gone is dropped from the registry. The project in the current directory, or the one given with `--config`, is guarded too for that run.
117
117
  2. **Orphans.** An instance no project tracks has no known policy and nobody knows whether it is in use, so the rules are blunt but never destroy a running machine:
118
118
 
119
119
  | Orphan | Rule | Action |
@@ -134,7 +134,7 @@ $ gpu sweep
134
134
  ! 1 action(s) pending; re-run with --apply
135
135
  ```
136
136
 
137
- Providers do not report when an instance stopped, so the sweep keeps the time it first saw each orphan stopped in `~/.jambu/sweep.json`. An entry goes away when the instance runs again, disappears, or is tracked by a project. `stopped_ttl` for an orphan therefore counts from the first sweep that saw it stopped, never from a project's state. Every orphan action is recorded as a `sweep_action` event with its reason in `~/.jambu/logs/events.jsonl`. Credentials for the orphan step resolve the usual way (the environment, `.env` in the working directory, `~/.jambu/credentials`), then from the `.env` of registered projects.
137
+ Providers do not report when an instance stopped, so the sweep keeps the time it first saw each orphan stopped in `~/.gpu/sweep.json`. An entry goes away when the instance runs again, disappears, or is tracked by a project. `stopped_ttl` for an orphan therefore counts from the first sweep that saw it stopped, never from a project's state. Every orphan action is recorded as a `sweep_action` event with its reason in `~/.gpu/logs/events.jsonl`. Credentials for the orphan step resolve the usual way (the environment, `.env` in the working directory, `~/.gpu/credentials`), then from the `.env` of registered projects.
138
138
 
139
139
  ### Run it on a timer
140
140
 
@@ -143,9 +143,9 @@ gpu sweep --install-timer # every 5m; --every 15m to change
143
143
  systemctl --user daemon-reload && systemctl --user enable --now jambu-gpu-sweep.timer
144
144
  ```
145
145
 
146
- `--install-timer` writes `~/.config/systemd/user/jambu-gpu-sweep.service` and `.timer`, and registers the current project if there is one. The service is a single `ExecStart=<python> -m jambu_gpu sweep --apply` with the interpreter that installed it, run from `~`: which projects get guarded comes from the registry, not from the working directory, so installing from a second project changes nothing in the unit. The oneshot has a 10 minute start timeout, a failing sweep shows as a failed unit, and `JAMBU_HOME` is passed through when it is set. A unit written by an older version, with one `guard` line per project, is rewritten and those projects are registered.
146
+ `--install-timer` writes `~/.config/systemd/user/jambu-gpu-sweep.service` and `.timer`, and registers the current project if there is one. The service is a single `ExecStart=<python> -m jambu_gpu sweep --apply` with the interpreter that installed it, run from `~`: which projects get guarded comes from the registry, not from the working directory, so installing from a second project changes nothing in the unit. The oneshot has a 10 minute start timeout, a failing sweep shows as a failed unit, and `GPU_HOME` is passed through when it is set. A unit written by an older version, with one `guard` line per project, is rewritten and those projects are registered.
147
147
 
148
- A user unit does not inherit your shell's environment. `--install-timer` therefore refuses unless the provider key is in a file: the project `.env` or `~/.jambu/credentials`. A key that is only exported in your shell is not enough. A user timer needs no root, but it stops at logout unless lingering is on (`loginctl enable-linger $USER`).
148
+ A user unit does not inherit your shell's environment. `--install-timer` therefore refuses unless the provider key is in a file: the project `.env` or `~/.gpu/credentials`. A key that is only exported in your shell is not enough. A user timer needs no root, but it stops at logout unless lingering is on (`loginctl enable-linger $USER`).
149
149
 
150
150
  ```bash
151
151
  gpu sweep --uninstall-timer
@@ -6,7 +6,7 @@ Any Node/TypeScript application using a GPU that `gpu` rents and guards, through
6
6
 
7
7
  ## Run it
8
8
 
9
- From the directory that holds `jambu.yaml` and `VAST_API_KEY` (in `.env` or the environment), with `jambu-gpu` 0.2.0 or later on `PATH`:
9
+ From the directory that holds `jambu.yaml` and `VAST_API_KEY` (in `.env` or the environment), with `jambu-gpu` 0.3.0 or later on `PATH`:
10
10
 
11
11
  ```bash
12
12
  npm --prefix examples/typescript-client install
@@ -1,5 +1,5 @@
1
1
  """gpu: provider-agnostic GPU runtime provisioning and lifecycle CLI."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.3.1"
4
4
 
5
5
  __all__ = ["__version__"]
@@ -118,7 +118,7 @@ def fmt_money(value: Optional[float], suffix: str = "") -> Optional[str]:
118
118
 
119
119
 
120
120
  def register_project(context: Context) -> None:
121
- """Put this project in $JAMBU_HOME/projects.json so `gpu sweep` guards it.
121
+ """Put this project in $GPU_HOME/projects.json so `gpu sweep` guards it.
122
122
 
123
123
  Done before provisioning: a setup that dies halfway must still leave an
124
124
  instance the sweep judges by this project's policy, not as an orphan.
@@ -57,7 +57,7 @@ class Account:
57
57
 
58
58
 
59
59
  def account_credentials(project_dir: Path, profile: str = "default") -> CredentialResolver:
60
- """The environment, ./.env, ~/.jambu/credentials, then registered projects' .env.
60
+ """The environment, ./.env, ~/.gpu/credentials, then registered projects' .env.
61
61
 
62
62
  The timer runs from ~ without the shell's environment; a key kept only in
63
63
  a project's .env must still reach the account-wide orphan sweep.
@@ -196,7 +196,7 @@ def _require_file_credentials(context: Context) -> None:
196
196
  raise ConfigError(
197
197
  f"{name} is {where}, so the timer could not use it for {who}: a systemd "
198
198
  f"user unit does not inherit your shell. Put `{name}=...` in "
199
- "~/.jambu/credentials (chmod 600), then re-run --install-timer."
199
+ "~/.gpu/credentials (chmod 600), then re-run --install-timer."
200
200
  )
201
201
 
202
202
 
@@ -19,7 +19,7 @@ app = typer.Typer(
19
19
  name="gpu",
20
20
  help=(
21
21
  "Provider-agnostic CLI for provisioning and managing temporary GPU runtimes.\n\n"
22
- "jambu.yaml is the desired-state source of truth; .jambu/state.json is what "
22
+ "jambu.yaml is the desired-state source of truth; .gpu/state.json is what "
23
23
  "was last observed."
24
24
  ),
25
25
  no_args_is_help=True,
@@ -1,9 +1,9 @@
1
1
  """systemd *user* units that run `gpu sweep --apply` on a timer.
2
2
 
3
3
  One ``ExecStart`` covers every project: the sweep guards each project in
4
- ``$JAMBU_HOME/projects.json`` with its own config, so the unit does not depend
4
+ ``$GPU_HOME/projects.json`` with its own config, so the unit does not depend
5
5
  on its working directory. A user unit needs no root, but it does not inherit
6
- the shell's environment: credentials must live in ``~/.jambu/credentials`` or
6
+ the shell's environment: credentials must live in ``~/.gpu/credentials`` or
7
7
  the project ``.env``. Writing the files is all this module does; enabling them
8
8
  is left to the printed ``systemctl`` command.
9
9
  """
@@ -64,7 +64,7 @@ def render_units(
64
64
  f"TimeoutStartSec={START_TIMEOUT}",
65
65
  ]
66
66
  if jambu_home:
67
- lines.append(_environment("JAMBU_HOME", jambu_home))
67
+ lines.append(_environment("GPU_HOME", jambu_home))
68
68
  # No "-" prefix: a failing sweep must show up as a failed unit.
69
69
  lines.append("ExecStart=" + " ".join(_quote(a) for a in [python, "-m", "jambu_gpu", "sweep", "--apply"]))
70
70
  lines.append("")
@@ -115,7 +115,9 @@ def install_timer(every_s: float, python: Optional[str] = None) -> dict:
115
115
  service_path, timer_path = unit_paths()
116
116
  legacy = legacy_guard_configs(service_path)
117
117
  service, timer = render_units(
118
- every_s=every_s, python=python, jambu_home=os.environ.get("JAMBU_HOME") or None
118
+ every_s=every_s,
119
+ python=python,
120
+ jambu_home=os.environ.get("GPU_HOME") or os.environ.get("JAMBU_HOME") or None,
119
121
  )
120
122
  service_path.parent.mkdir(parents=True, exist_ok=True)
121
123
  service_path.write_text(service)
@@ -33,12 +33,12 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
33
33
 
34
34
  from .durations import parse_duration
35
35
  from .errors import ConfigError
36
+ from .paths import project_state_dir
36
37
 
37
38
  # jambu.yaml (namespaced, shared with other Jambu Lab tools) is searched
38
39
  # first; config.yml (flat, gpu-runtime-only) is the legacy fallback.
39
40
  CONFIG_FILENAMES = ("jambu.yaml", "jambu.yml", "config.yml", "config.yaml")
40
41
  GPU_RUNTIME_KEY = "gpu_runtime"
41
- STATE_DIRNAME = ".jambu"
42
42
 
43
43
  # The model catalog (spec: "Choosing a model" doc) - a project's jambu.yaml
44
44
  # references one entry by name instead of redeclaring the whole model/compute/
@@ -79,6 +79,11 @@ class GpuConfig(_Base):
79
79
  count: int = 1
80
80
  name_filter: Optional[str] = None
81
81
  cuda_min: Optional[str] = None
82
+ # Minimum GPU compute capability (architecture), e.g. 8.0 to require
83
+ # Ampere or newer. Raised to what the runtime needs; see
84
+ # ModelRuntime.required_compute_cap. Leave unset to accept the runtime's
85
+ # own floor.
86
+ compute_cap_min: Optional[float] = None
82
87
 
83
88
  @field_validator("count")
84
89
  @classmethod
@@ -319,7 +324,7 @@ class RuntimeConfig(_Base):
319
324
 
320
325
  @property
321
326
  def state_dir(self) -> Path:
322
- return self.project_dir / STATE_DIRNAME
327
+ return project_state_dir(self.project_dir)
323
328
 
324
329
  def fingerprint(self) -> str:
325
330
  """Identity of the *desired* runtime.
@@ -1,7 +1,7 @@
1
1
  """Credential resolution (spec section 5).
2
2
 
3
3
  Credentials never live in jambu.yaml. They are resolved from, in order:
4
- environment variables, a project-local ``.env``, then ``~/.jambu/credentials``.
4
+ environment variables, a project-local ``.env``, then ``~/.gpu/credentials``.
5
5
  The resolution strategy can grow (keychain, secret managers, CI secrets)
6
6
  without touching jambu.yaml or any adapter.
7
7
  """
@@ -12,6 +12,8 @@ import os
12
12
  from pathlib import Path
13
13
  from typing import Optional
14
14
 
15
+ from .paths import gpu_home
16
+
15
17
  _DOTENV_CACHE: dict[Path, dict[str, str]] = {}
16
18
 
17
19
 
@@ -46,7 +48,7 @@ class CredentialResolver:
46
48
  self.profile = profile
47
49
  self.sources: list[tuple[str, Path]] = [
48
50
  ("project .env", self.project_dir / ".env"),
49
- ("user credentials", Path.home() / ".jambu" / "credentials"),
51
+ ("user credentials", gpu_home() / "credentials"),
50
52
  *(extra_sources or []),
51
53
  ]
52
54
 
@@ -366,6 +366,7 @@ class Engine:
366
366
  count=cfg.compute.gpu.count,
367
367
  name_filter=cfg.compute.gpu.name_filter,
368
368
  cuda_min=self.cuda_min(),
369
+ compute_cap_min=self.compute_cap_min(),
369
370
  ),
370
371
  disk_gb=cfg.compute.disk_gb,
371
372
  image=self.runtime.image(),
@@ -389,6 +390,18 @@ class Engine:
389
390
  return configured
390
391
  return f"{needed:.1f}"
391
392
 
393
+ def compute_cap_min(self) -> Optional[float]:
394
+ """The stricter of compute.gpu.compute_cap_min and the runtime's floor."""
395
+ candidates = [
396
+ c
397
+ for c in (
398
+ self.config.compute.gpu.compute_cap_min,
399
+ self.runtime.required_compute_cap(),
400
+ )
401
+ if c is not None
402
+ ]
403
+ return max(candidates) if candidates else None
404
+
392
405
  def _ports(self) -> list[int]:
393
406
  ports = list(self.runtime.ports())
394
407
  if self.config.lifecycle.watchdog.enabled:
@@ -1,6 +1,6 @@
1
1
  """Structured lifecycle events (spec section 24).
2
2
 
3
- Every lifecycle action appends one JSON object to ``.jambu/logs/events.jsonl``
3
+ Every lifecycle action appends one JSON object to ``.gpu/logs/events.jsonl``
4
4
  so unexpected GPU cost can always be traced back to a decision and a reason.
5
5
  """
6
6
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  A host that could not pull the image or reach the network fails the next
4
4
  setup the same way, and the cheapest-offer search keeps picking it. Each
5
- failure is kept for ``BAD_HOST_TTL_S`` in ``$JAMBU_HOME/bad_hosts.json``.
5
+ failure is kept for ``BAD_HOST_TTL_S`` in ``$GPU_HOME/bad_hosts.json``.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
@@ -91,6 +91,11 @@ class GpuSpec:
91
91
  count: int = 1
92
92
  name_filter: Optional[str] = None
93
93
  cuda_min: Optional[str] = None
94
+ # Minimum GPU compute capability (architecture), e.g. 8.0 for Ampere. The
95
+ # host driver's CUDA (``cuda_min``) is not the same thing: a Pascal/Volta
96
+ # card can sit behind a CUDA-13 driver yet still be too old for a runtime
97
+ # image built for newer architectures, which then dies at engine startup.
98
+ compute_cap_min: Optional[float] = None
94
99
 
95
100
 
96
101
  @dataclass
@@ -127,6 +132,9 @@ class Offer:
127
132
  score: Optional[float] = None
128
133
  # Highest CUDA version the host's driver supports, when the provider says.
129
134
  cuda_max: Optional[float] = None
135
+ # The GPU's compute capability (architecture), e.g. 8.6 for an RTX 3090,
136
+ # when the provider says. Distinct from cuda_max (the driver's CUDA).
137
+ compute_cap: Optional[float] = None
130
138
  # The physical machine behind the offer, when the provider says. Offers
131
139
  # on one machine share its network, disk and Docker daemon.
132
140
  host_id: Optional[str] = None
@@ -0,0 +1,42 @@
1
+ """Where gpu keeps its files: ``.gpu/`` in a project, ``~/.gpu`` per user.
2
+
3
+ Both were named ``.jambu`` before 0.3.0. A legacy directory is renamed in
4
+ place the first time it is needed, so an instance a project already tracks
5
+ is never forgotten (and never rented twice) after an upgrade.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ STATE_DIRNAME = ".gpu"
14
+ LEGACY_STATE_DIRNAME = ".jambu"
15
+
16
+
17
+ def adopt_legacy(path: Path, legacy: Path) -> Path:
18
+ """``path``, after moving ``legacy`` there when only the legacy one exists."""
19
+ if not path.exists() and legacy.is_dir():
20
+ try:
21
+ legacy.rename(path)
22
+ except OSError:
23
+ # Read-only or racing another gpu process: keep using the old one.
24
+ return path if path.exists() else legacy
25
+ return path
26
+
27
+
28
+ def project_state_dir(project_dir: Path) -> Path:
29
+ project_dir = Path(project_dir)
30
+ return adopt_legacy(project_dir / STATE_DIRNAME, project_dir / LEGACY_STATE_DIRNAME)
31
+
32
+
33
+ def gpu_home() -> Path:
34
+ """Per-user files: the project registry, sweep sightings, bad hosts, credentials.
35
+
36
+ ``$GPU_HOME`` overrides it (``$JAMBU_HOME`` is still honored).
37
+ """
38
+ override = os.environ.get("GPU_HOME") or os.environ.get("JAMBU_HOME")
39
+ if override:
40
+ return Path(override).expanduser()
41
+ home = Path.home()
42
+ return adopt_legacy(home / STATE_DIRNAME, home / LEGACY_STATE_DIRNAME)
@@ -1,6 +1,6 @@
1
1
  """Observed runtime state (spec section 9).
2
2
 
3
- jambu.yaml is the desired-state SoT; ``.jambu/state.json`` is what we last
3
+ jambu.yaml is the desired-state SoT; ``.gpu/state.json`` is what we last
4
4
  observed. This module owns reading/writing it atomically, plus the on-disk
5
5
  process lock that keeps two CLI invocations from provisioning twice.
6
6
  """
@@ -312,7 +312,7 @@ class RuntimeState:
312
312
 
313
313
 
314
314
  class StateStore:
315
- """Atomic reader/writer for ``.jambu/state.json``."""
315
+ """Atomic reader/writer for ``.gpu/state.json``."""
316
316
 
317
317
  def __init__(self, state_dir: Path) -> None:
318
318
  self._depth = 0 # flock is per-open-file-description: keep it reentrant
@@ -4,7 +4,7 @@ The watchdog guards one instance from the inside and `gpu guard` guards the
4
4
  one instance a project tracks. The sweep covers every project on this machine
5
5
  and whatever is left over:
6
6
 
7
- - every project registered in ``$JAMBU_HOME/projects.json`` (by `gpu setup`)
7
+ - every project registered in ``$GPU_HOME/projects.json`` (by `gpu setup`)
8
8
  gets its own ``Engine.guard_tick``, with its own config and state: its own
9
9
  max_lifetime, lock, session and stopped_ttl. Those instances are *tracked*;
10
10
  - an instance no project tracks is an *orphan*. Nobody knows its policy or
@@ -14,7 +14,7 @@ and whatever is left over:
14
14
  ago is destroyed.
15
15
 
16
16
  Providers do not report when an instance stopped, so the first time the sweep
17
- saw each orphan stopped is kept in ``$JAMBU_HOME/sweep.json``.
17
+ saw each orphan stopped is kept in ``$GPU_HOME/sweep.json``.
18
18
  """
19
19
 
20
20
  from __future__ import annotations
@@ -30,7 +30,8 @@ from typing import Any, Callable, Iterator, Optional
30
30
 
31
31
  from . import events as ev
32
32
  from . import policy
33
- from .config import STATE_DIRNAME, load_config
33
+ from .config import load_config
34
+ from .paths import gpu_home, project_state_dir
34
35
  from .errors import JambuError, ProviderError
35
36
  from .events import EventLog
36
37
  from .models import Instance, InstanceState, is_jambu_label
@@ -46,8 +47,7 @@ SWEEP_ACTION = "sweep_action"
46
47
 
47
48
 
48
49
  def jambu_home() -> Path:
49
- override = os.environ.get("JAMBU_HOME")
50
- return Path(override).expanduser() if override else Path.home() / ".jambu"
50
+ return gpu_home()
51
51
 
52
52
 
53
53
  class SweepRegistry:
@@ -198,7 +198,7 @@ class ProjectGuard:
198
198
 
199
199
  def _state_beside(config_path: Path) -> Optional[RuntimeState]:
200
200
  """The project's state read straight from disk (no API call)."""
201
- store = StateStore(Path(config_path).parent / STATE_DIRNAME)
201
+ store = StateStore(project_state_dir(Path(config_path).parent))
202
202
  try:
203
203
  return store.load() if store.exists() else None
204
204
  except JambuError:
@@ -82,6 +82,12 @@ def _float_or_none(value: Any) -> Optional[float]:
82
82
  return None
83
83
 
84
84
 
85
+ def _compute_cap(value: Any) -> Optional[float]:
86
+ """Vast's ``compute_cap`` is capability ×100 (860 → 8.6); normalize it."""
87
+ raw = _float_or_none(value)
88
+ return raw / 100.0 if raw else None
89
+
90
+
85
91
  def effective_min_cuda(spec: ComputeSpec, options: VastOptions) -> Optional[float]:
86
92
  """The stricter of the adapter's floor and what the spec (image) needs."""
87
93
  known = [
@@ -110,6 +116,8 @@ def map_offer(raw: dict[str, Any]) -> Offer:
110
116
  score=float(raw.get("score") or 0.0) if raw.get("score") is not None else None,
111
117
  # Highest CUDA the host driver runs; an image needing more dies at boot.
112
118
  cuda_max=_float_or_none(raw.get("cuda_max_good")),
119
+ # Vast reports compute capability ×100 (860 = 8.6); normalize to 8.6.
120
+ compute_cap=_compute_cap(raw.get("compute_cap")),
113
121
  host_id=host_id(raw),
114
122
  raw=raw,
115
123
  )
@@ -193,6 +201,12 @@ def build_search_query(
193
201
  min_cuda = effective_min_cuda(spec, options)
194
202
  if min_cuda:
195
203
  query["cuda_max_good"] = {"gte": min_cuda}
204
+ # Floor the GPU architecture, not just the driver's CUDA: Vast reports
205
+ # compute capability ×100, so 8.0 -> 800. Without this the cheapest offers
206
+ # (old Volta/Pascal cards behind modern drivers) pass cuda_max_good but
207
+ # crash a newer runtime image at engine startup.
208
+ if spec.gpu.compute_cap_min:
209
+ query["compute_cap"] = {"gte": int(round(float(spec.gpu.compute_cap_min) * 100))}
196
210
  # gpu_name is deliberately NOT sent server-side: Vast's field only
197
211
  # accepts an exact match ({"eq": ...}), but name_filter is documented and
198
212
  # used everywhere else (offer_matches below) as a case-insensitive
@@ -258,6 +272,10 @@ def offer_matches(offer: Offer, spec: ComputeSpec, min_cuda: Optional[float] = N
258
272
  # 1e-6: 12.9 must match a host reporting exactly 12.9.
259
273
  if min_cuda and offer.cuda_max is not None and offer.cuda_max + 1e-6 < min_cuda:
260
274
  return False
275
+ # Vast's compute_cap filter is advisory; verify the architecture floor too.
276
+ min_cc = spec.gpu.compute_cap_min
277
+ if min_cc and offer.compute_cap is not None and offer.compute_cap + 1e-6 < float(min_cc):
278
+ return False
261
279
  if offer.gpu_count < spec.gpu.count:
262
280
  return False
263
281
  if spec.gpu.min_vram_gb and offer.gpu_vram_gb + 0.5 < float(spec.gpu.min_vram_gb):
@@ -116,6 +116,16 @@ class ModelRuntime(ABC):
116
116
  """
117
117
  return cuda_from_tag(image_tag(self.image()))
118
118
 
119
+ def required_compute_cap(self) -> Optional[float]:
120
+ """Minimum GPU compute capability (architecture) the runtime needs.
121
+
122
+ None if unknown. The offer filter is raised to this, so an image whose
123
+ kernels are built for newer architectures is never placed on a GPU too
124
+ old to run them (which the host driver's CUDA alone does not catch: a
125
+ Volta card behind a CUDA-13 driver still fails at engine startup).
126
+ """
127
+ return None
128
+
119
129
  # -- on-instance liveness -------------------------------------------------
120
130
 
121
131
  def runtime_pid_path(self) -> Optional[str]:
@@ -123,6 +123,16 @@ class VllmRuntime(ModelRuntime):
123
123
  def required_cuda(self) -> Optional[float]:
124
124
  return vllm_image_cuda(self.image())
125
125
 
126
+ def required_compute_cap(self) -> Optional[float]:
127
+ # vLLM wheels built for CUDA 13+ drop Volta (sm_70) and need Ampere
128
+ # (8.0) or newer; those crash at engine-core init on a V100 or older.
129
+ # CUDA 12.x wheels still run Volta. Pascal (GTX 10xx, 6.1) is below
130
+ # vLLM's floor on any build.
131
+ cuda = self.required_cuda()
132
+ if cuda is None:
133
+ return None
134
+ return 8.0 if cuda >= 13.0 else 7.0
135
+
126
136
  def runtime_pid_path(self) -> str:
127
137
  return PID_PATH
128
138
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "jambu-gpu"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "Provider-agnostic CLI for provisioning and managing temporary GPU runtimes"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -36,7 +36,7 @@ gpu = "jambu_gpu.cli.main:main"
36
36
  packages = ["jambu_gpu"]
37
37
 
38
38
  [tool.hatch.build.targets.sdist]
39
- exclude = ["/.env", "/.venv", "/.jambu"]
39
+ exclude = ["/.env", "/.venv", "/.gpu", "/.jambu"]
40
40
 
41
41
  [tool.pytest.ini_options]
42
42
  testpaths = ["tests"]
@@ -29,9 +29,10 @@ BASE_CONFIG = {
29
29
 
30
30
  @pytest.fixture(autouse=True)
31
31
  def jambu_home(tmp_path, monkeypatch):
32
- """Keep the project registry and sweep state out of the real ~/.jambu."""
33
- home = tmp_path / "jambu-home"
34
- monkeypatch.setenv("JAMBU_HOME", str(home))
32
+ """Keep the project registry and sweep state out of the real ~/.gpu."""
33
+ home = tmp_path / "gpu-home"
34
+ monkeypatch.setenv("GPU_HOME", str(home))
35
+ monkeypatch.delenv("JAMBU_HOME", raising=False)
35
36
  return home
36
37
 
37
38
 
@@ -100,7 +100,7 @@ def test_fingerprint_tracks_runtime_identity(write_config):
100
100
 
101
101
 
102
102
  def test_state_dir_sits_next_to_the_config(config):
103
- assert config.state_dir == config.source_path.parent / ".jambu"
103
+ assert config.state_dir == config.source_path.parent / ".gpu"
104
104
 
105
105
 
106
106
  # -- jambu.yaml shared-manifest namespacing ----------------------------------