jambu-gpu 0.1.0__tar.gz → 0.3.0__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 (102) hide show
  1. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/.gitignore +2 -0
  2. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/CONTRIBUTING.md +2 -2
  3. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/PKG-INFO +7 -2
  4. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/README.md +6 -1
  5. jambu_gpu-0.3.0/docs/ACCEPTANCE.md +69 -0
  6. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/docs/ADDING_A_PROVIDER.md +12 -1
  7. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/docs/CONFIG.md +24 -7
  8. jambu_gpu-0.3.0/docs/INTEGRATION.md +232 -0
  9. jambu_gpu-0.3.0/docs/LIFECYCLE.md +128 -0
  10. jambu_gpu-0.3.0/docs/USAGE.md +153 -0
  11. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/mastra-agent/README.md +1 -1
  12. jambu_gpu-0.3.0/examples/typescript-client/README.md +44 -0
  13. jambu_gpu-0.3.0/examples/typescript-client/package-lock.json +71 -0
  14. jambu_gpu-0.3.0/examples/typescript-client/package.json +18 -0
  15. jambu_gpu-0.3.0/examples/typescript-client/src/index.ts +31 -0
  16. jambu_gpu-0.3.0/examples/typescript-client/src/jambuGpu.ts +178 -0
  17. jambu_gpu-0.3.0/examples/typescript-client/tsconfig.json +16 -0
  18. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu.example.yaml +12 -1
  19. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/__init__.py +1 -1
  20. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/_common.py +14 -0
  21. jambu_gpu-0.3.0/jambu_gpu/cli/account.py +330 -0
  22. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/main.py +47 -2
  23. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/run.py +2 -1
  24. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/setup.py +10 -11
  25. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/status.py +13 -3
  26. jambu_gpu-0.3.0/jambu_gpu/cli/timer.py +143 -0
  27. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/beacon.py +9 -0
  28. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/config.py +37 -2
  29. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/credentials.py +23 -3
  30. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/engine.py +415 -95
  31. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/errors.py +20 -0
  32. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/events.py +3 -1
  33. jambu_gpu-0.3.0/jambu_gpu/core/hosts.py +58 -0
  34. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/models.py +22 -0
  35. jambu_gpu-0.3.0/jambu_gpu/core/paths.py +42 -0
  36. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/policy.py +37 -6
  37. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/state.py +48 -3
  38. jambu_gpu-0.3.0/jambu_gpu/core/sweep.py +478 -0
  39. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/providers/base.py +8 -0
  40. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/providers/vast/client.py +32 -7
  41. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/providers/vast/config.py +6 -1
  42. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/providers/vast/mapper.py +57 -4
  43. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/providers/vast/provider.py +20 -8
  44. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/runtime/base.py +74 -1
  45. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/runtime/vllm.py +67 -6
  46. jambu_gpu-0.3.0/jambu_gpu/watchdog/agent_body.py +959 -0
  47. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/watchdog/builder.py +89 -19
  48. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/pyproject.toml +2 -2
  49. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/conftest.py +9 -0
  50. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/fakes.py +53 -6
  51. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/test_cli.py +65 -1
  52. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/test_config.py +69 -1
  53. jambu_gpu-0.3.0/tests/test_engine.py +812 -0
  54. jambu_gpu-0.3.0/tests/test_engine_sessions.py +445 -0
  55. jambu_gpu-0.3.0/tests/test_paths.py +50 -0
  56. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/test_policy.py +75 -0
  57. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/test_runtime_vllm.py +82 -0
  58. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/test_state.py +33 -1
  59. jambu_gpu-0.3.0/tests/test_sweep.py +680 -0
  60. jambu_gpu-0.3.0/tests/test_vast_mapper.py +244 -0
  61. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/test_vast_provider.py +125 -0
  62. jambu_gpu-0.3.0/tests/test_watchdog.py +1130 -0
  63. jambu_gpu-0.1.0/docs/ACCEPTANCE.md +0 -28
  64. jambu_gpu-0.1.0/docs/LIFECYCLE.md +0 -75
  65. jambu_gpu-0.1.0/docs/USAGE.md +0 -91
  66. jambu_gpu-0.1.0/jambu_gpu/watchdog/agent_body.py +0 -547
  67. jambu_gpu-0.1.0/tests/test_engine.py +0 -366
  68. jambu_gpu-0.1.0/tests/test_vast_mapper.py +0 -119
  69. jambu_gpu-0.1.0/tests/test_watchdog.py +0 -327
  70. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/.env.example +0 -0
  71. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/LICENSE +0 -0
  72. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/docs/CHOOSING_A_MODEL.md +0 -0
  73. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/docs/assets/jambu-logo.jpg +0 -0
  74. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/experiment.py +0 -0
  75. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/jambu.yaml +0 -0
  76. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/mastra-agent/package-lock.json +0 -0
  77. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/mastra-agent/package.json +0 -0
  78. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/mastra-agent/src/agent.ts +0 -0
  79. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/mastra-agent/src/index.ts +0 -0
  80. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/mastra-agent/src/tools/weather-tool.ts +0 -0
  81. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/examples/mastra-agent/tsconfig.json +0 -0
  82. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu.models.yaml +0 -0
  83. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu.yaml +0 -0
  84. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/__main__.py +0 -0
  85. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/__init__.py +0 -0
  86. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/inspect.py +0 -0
  87. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/lifecycle.py +0 -0
  88. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/providers.py +0 -0
  89. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/cli/validate.py +0 -0
  90. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/__init__.py +0 -0
  91. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/durations.py +0 -0
  92. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/execution.py +0 -0
  93. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/health.py +0 -0
  94. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/model_inspect.py +0 -0
  95. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/core/tool_parsers.py +0 -0
  96. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/providers/__init__.py +0 -0
  97. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/providers/registry.py +0 -0
  98. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/providers/vast/__init__.py +0 -0
  99. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/runtime/__init__.py +0 -0
  100. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/jambu_gpu/watchdog/__init__.py +0 -0
  101. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/test_integration.py +0 -0
  102. {jambu_gpu-0.1.0 → jambu_gpu-0.3.0}/tests/test_model_inspect.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.*
@@ -13,3 +14,4 @@ build/
13
14
  .coverage
14
15
  node_modules/
15
16
  *.tsbuildinfo
17
+ .claude/
@@ -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.1.0
3
+ Version: 0.3.0
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
@@ -45,7 +45,11 @@ gpu run python experiment.py
45
45
 
46
46
  When you are finished, `gpu destroy`. If you walk away, the idle timeout does it.
47
47
 
48
- The API key is read from the environment, a project `.env`, or `~/.jambu/credentials`. It never goes in the manifest.
48
+ From an application in any language: `gpu --json setup` returns the OpenAI-compatible `base_url`, `api_key` and `model`, and the app uses any OpenAI SDK. See [Integrating gpu into an application](docs/INTEGRATION.md), with a ready TypeScript client.
49
+
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
+
52
+ The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
49
53
 
50
54
  ## Declare
51
55
 
@@ -64,6 +68,7 @@ The file is `jambu.yaml`. This tool reads only the `gpu_runtime:` key, so other
64
68
 
65
69
  ## Docs
66
70
 
71
+ - [Integrating into an application (any language)](docs/INTEGRATION.md)
67
72
  - [Commands, workloads, and agents](docs/USAGE.md)
68
73
  - [Every config field](docs/CONFIG.md)
69
74
  - [Choosing a model](docs/CHOOSING_A_MODEL.md)
@@ -22,7 +22,11 @@ gpu run python experiment.py
22
22
 
23
23
  When you are finished, `gpu destroy`. If you walk away, the idle timeout does it.
24
24
 
25
- The API key is read from the environment, a project `.env`, or `~/.jambu/credentials`. It never goes in the manifest.
25
+ From an application in any language: `gpu --json setup` returns the OpenAI-compatible `base_url`, `api_key` and `model`, and the app uses any OpenAI SDK. See [Integrating gpu into an application](docs/INTEGRATION.md), with a ready TypeScript client.
26
+
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
+
29
+ The API key is read from the environment, a project `.env`, or `~/.gpu/credentials`. It never goes in the manifest.
26
30
 
27
31
  ## Declare
28
32
 
@@ -41,6 +45,7 @@ The file is `jambu.yaml`. This tool reads only the `gpu_runtime:` key, so other
41
45
 
42
46
  ## Docs
43
47
 
48
+ - [Integrating into an application (any language)](docs/INTEGRATION.md)
44
49
  - [Commands, workloads, and agents](docs/USAGE.md)
45
50
  - [Every config field](docs/CONFIG.md)
46
51
  - [Choosing a model](docs/CHOOSING_A_MODEL.md)
@@ -0,0 +1,69 @@
1
+ # Acceptance criteria — where each one is implemented and tested
2
+
3
+ Eighteen guarantees, mapped to the code and the test that holds each one.
4
+
5
+ | # | Guarantee | Implementation | Test |
6
+ | --- | --- | --- | --- |
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
+ | 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
+ | 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
+ | 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` |
11
+ | 5 | Active workloads are never stopped by the idle guard | `core/policy.py::evaluate` returns `workload_active` before any idle check | `test_policy.py::test_active_workload_is_never_stopped_by_the_idle_guard`, `test_watchdog.py::test_a_heartbeating_workload_is_never_stopped` |
12
+ | 6 | An idle unlocked instance stops within `idle_timeout + watchdog_interval` | remote watchdog loop, `watchdog/agent_body.py::guard_loop` | `test_watchdog.py::test_idle_instance_is_stopped_with_a_recorded_reason` (real subprocess) |
13
+ | 7 | `max_lifetime` bounds forgotten instances | `policy.evaluate` checks age before activity | `test_policy.py::test_max_lifetime_stops_even_when_busy`, `test_watchdog.py::test_max_lifetime_stops_a_busy_instance` |
14
+ | 8 | Locks prevent shutdown until expiry | `policy.Snapshot.lock_active` / `lock_expired`; TTL required unless `allow_indefinite_lock` | `test_policy.py::test_expired_lock_stops_protecting`, `test_watchdog.py::test_a_lock_prevents_shutdown_until_it_expires` |
15
+ | 9 | A crashed CLI does not disable protection | the guard runs *on the instance*; heartbeats go stale after `heartbeat_grace` | `test_watchdog.py::test_a_vanished_heartbeat_falls_back_to_idle`, `::test_startup_grace_expires_when_the_cli_never_confirms` |
16
+ | 10 | Remote state is reconciled | `Engine._reconcile` merges provider truth + watchdog truth and repairs local state | `test_engine.py::test_status_repairs_state_when_the_instance_vanished` |
17
+ | 11 | A new provider needs only the contract | `providers/registry.py`; the engine is driven end to end by `tests/fakes.py::FakeProvider` | `test_engine.py::test_a_new_provider_needs_only_the_contract` |
18
+ | 12 | Every automatic shutdown records its reason | `lifecycle_guard_triggered` + `instance_stopped` events carry `reason`, locally and on the instance | `test_engine.py::test_guard_stops_an_idle_unlocked_instance_and_records_the_reason`, `test_watchdog.py::test_idle_instance_is_stopped_with_a_recorded_reason` |
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
+ | 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
+ | 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 `~/.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
+ | 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
+ | 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
+
26
+ ## Validated on real hardware
27
+
28
+ On 2026-10-24 the lifecycle ran end to end on Vast.ai (RTX 3090, Qwen2.5-0.5B, `idle_timeout: 4m`, `stopped_ttl: 5m`), for $0.35 of Vast credit in total across every attempt:
29
+
30
+ | What was checked | What happened |
31
+ | --- | --- |
32
+ | Traffic is activity | 10 requests sent straight to the endpoint over 7.5 minutes, none through `gpu run`. The machine stayed up past the 4-minute idle timeout. |
33
+ | Idle stops | 269 s after the last request the watchdog stopped its own machine (`idle_timeout`). Vast showed it `exited`. |
34
+ | A restart is guarded | `gpu run` restarted it. The watchdog logged `session_started` with `terminated: false` and measured `max_lifetime` from the restart. After the workload it stopped the machine again on its own. |
35
+ | Stopped storage is reclaimed | 334 s after the stop, `gpu guard` destroyed it (`stopped_ttl`). |
36
+ | Failed setups leave nothing | An image built for a newer CUDA, a model server that died at import, a host whose network dropped the weights download, a 30-minute image pull, and a Ctrl-C each ended with the instance destroyed. |
37
+ | Bad hosts are skipped | Hosts that could not pull the image were destroyed, excluded and replaced, with never two instances alive at once. |
38
+
39
+ The run also found what unit tests could not, and each of these is fixed and covered by a test:
40
+ - the boot script was over Vast's 16 KB onstart limit;
41
+ - `latest` had moved to CUDA 13;
42
+ - the `-cu129` image variant could not load torchcodec;
43
+ - a 15-minute boot timeout was too short for a 12 GB pull;
44
+ - Ctrl-C did not clean up;
45
+ - a fresh contract was reported `READY`.
46
+
47
+ ### Application integration (0.2.0)
48
+
49
+ Driven end to end by [`examples/typescript-client`](../examples/typescript-client/) through the OpenAI SDK, on an RTX 3090 with Qwen2.5-0.5B:
50
+
51
+ | Check | Result |
52
+ | --- | --- |
53
+ | `ensureGpu()` on an empty account | Two bad hosts (weights download cut by the host's network; image pull stalled 30 minutes) were destroyed and replaced; the third served. 37 minutes in all, one instance alive at a time. |
54
+ | The endpoint requires its key | No key: `401`. Wrong key: `401`. The key from `gpu --json setup`: `200`. `/health` without a key: `200`. |
55
+ | Chat through the SDK | Answered. |
56
+ | Healing after a stop | `stopGpu()`, then a call on the stale connection: it failed to connect, `gpu setup` restarted the same instance, and the retry answered 41 s later with the same key. |
57
+
58
+ It found two more bugs, both fixed and tested: Vast reports a stopping instance as `running` for a few seconds, which made a setup right after a stop skip the restart; and a Vast rate limit (`429`) failed setup instead of being waited out.
59
+
60
+ ## The MVP command surface
61
+
62
+ `jambu.yaml` schema, CLI, provider contract, provider registry, Vast.ai adapter,
63
+ vLLM runtime, `setup`, `run`, `status`, `stop`, `destroy`, `lock`/`unlock`, idle
64
+ timeout, max lifetime, remote watchdog, structured logs — all present. On top of
65
+ it, `ls` and `sweep` for account-wide orphan control.
66
+
67
+ Deliberately not implemented: multi-instance scheduling, multi-GPU
68
+ orchestration, Kubernetes, distributed inference, provider optimization, automatic
69
+ provider selection, job queues, web dashboard.
@@ -52,7 +52,12 @@ providers/runpod/
52
52
 
53
53
  The core never branches on `provider.name` — only on `provider.capabilities`. Set
54
54
  `stop_eliminates_billing=False` if a stopped instance still costs money; the policy
55
- then knows it may need to escalate to destroy.
55
+ then knows it may need to escalate to destroy, and `lifecycle.stopped_ttl` applies.
56
+
57
+ Set `list_instances=True` and implement `list_instances()` if the API can enumerate
58
+ the account. Return only instances whose label starts with `JAMBU_LABEL_PREFIX`
59
+ (`core/models.py`); `gpu ls` and `gpu sweep` are built on it and stay unavailable
60
+ otherwise.
56
61
 
57
62
  ## 3. Normalize state and errors
58
63
 
@@ -63,6 +68,12 @@ and raise only the normalized errors from `core/errors.py`:
63
68
  `ProviderTimeoutError`, `UnsupportedCapabilityError`. CLI behaviour must be
64
69
  identical across providers.
65
70
 
71
+ Report a machine that will not boot as `failed` rather than leaving it in
72
+ `starting`: `setup` then destroys it at once and rents another machine
73
+ (`compute.host_attempts`). Set `Offer.host_id` and `Instance.host_id` to the
74
+ physical machine when the API exposes it, and leave out offers whose `host_id` is
75
+ in `ComputeSpec.exclude_hosts`, or `setup` may rent the same bad machine again.
76
+
66
77
  ## 4. Supply the watchdog actions
67
78
 
68
79
  The lifecycle *policy* is the core's; the adapter contributes only the two calls
@@ -64,8 +64,8 @@ for why this exists and `gpu profiles` / `gpu inspect-model
64
64
  | `runtype` | `ssh_direc ssh_proxy` | Vast container run mode. |
65
65
  | `verified_only` | `true` | Only rent verified machines. |
66
66
  | `min_reliability` | `0.95` | Minimum machine reliability score. |
67
- | `min_inet_down_mbps` | `100` | Minimum download bandwidth (model pulls are big). |
68
- | `min_cuda` | `12.1` | Minimum `cuda_max_good`. |
67
+ | `min_inet_down_mbps` | `500` | Minimum download bandwidth. The vLLM image alone is ~10 GB and a slow pull is the commonest boot failure; most offers clear 500 at the same price. |
68
+ | `min_cuda` | `12.1` | Floor for the host's `cuda_max_good` (the newest CUDA its driver runs). The search uses the higher of this and `compute.gpu.cuda_min`, and offers are re-checked client-side. |
69
69
  | `search_limit` | `24` | How many offers to consider. |
70
70
  | `order_by` / `order_dir` | `dph_total` / `asc` | Offer ranking — cheapest first. |
71
71
  | `image_login` | – | `-u user -p pass docker.io` for a private image. |
@@ -85,11 +85,12 @@ each number comes from.
85
85
  | `gpu.min_vram_gb` | `24` | Minimum VRAM **per GPU**. |
86
86
  | `gpu.count` | `1` | Number of GPUs on one machine. |
87
87
  | `gpu.name_filter` | – | Substring match, e.g. `A100`, `RTX 4090`. |
88
- | `gpu.cuda_min` | – | Minimum CUDA version. |
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
89
  | `disk_gb` | `80` | Disk. Must fit the model weights plus the container. |
90
90
  | `region` | – | Comma-separated geolocations. |
91
91
  | `spot` | `false` | Use interruptible/bid capacity where supported. |
92
- | `image` | – | Override the runtime's container image. |
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 `$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
+ | `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. |
93
94
 
94
95
  ---
95
96
 
@@ -117,7 +118,7 @@ How the model runs. Independent from where it runs.
117
118
  | `dtype` | `auto` | vLLM dtype. |
118
119
  | `gpu_memory_utilization` | `0.90` | Fraction of VRAM vLLM may claim. |
119
120
  | `tensor_parallel_size` | `compute.gpu.count` | Shards across GPUs. |
120
- | `api_key` | – | Require this key on the model endpoint. |
121
+ | `api_key` | generated | The key the model endpoint requires. Unset, `setup` generates a random one per instance; `gpu env` prints it. |
121
122
  | `vllm_args` | `{}` | Structured escape hatch for any vLLM flag this schema doesn't model: `{flag: value}` → `--flag value` (`true`/`false` → a bare/negated flag, a dict or list → JSON-encoded). Applied before `extra_args`. See [`docs/CHOOSING_A_MODEL.md`](CHOOSING_A_MODEL.md#dynamic--un-modeled-vllm-flags). |
122
123
  | `extra_args` | `[]` | Raw argv appended verbatim, last — for anything `vllm_args` can't express. |
123
124
  | `tool_calling` | `auto` | `auto` guesses `--tool-call-parser` from `model.id` and turns tool calling on if recognized; `on` requires a resolvable parser (fails `validate` otherwise); `off` never passes tool-calling flags. |
@@ -162,6 +163,7 @@ The cost protection. See [the safety invariant](#safety-invariant).
162
163
  | --- | --- | --- |
163
164
  | `max_lifetime` | `6h` | Hard ceiling on instance age. `null` = unbounded. |
164
165
  | `idle_timeout` | `15m` | Shut down after this long with no activity. `null` = unbounded. |
166
+ | `stopped_ttl` | `24h` | Destroy an instance that has been `STOPPED` this long. A stopped Vast instance no longer bills GPU time but still bills storage. `null` keeps stopped instances forever. |
165
167
  | `auto_stop` | `true` | The guard may stop the instance. |
166
168
  | `auto_destroy` | `false` | The guard destroys instead of stopping. |
167
169
  | `cleanup_on_setup_failure` | `true` | Destroy partially created resources when setup fails. |
@@ -169,6 +171,7 @@ The cost protection. See [the safety invariant](#safety-invariant).
169
171
  | `heartbeat_interval` | `30s` | How often a running workload reports in. |
170
172
  | `heartbeat_grace` | `5m` | Missing heartbeats tolerated before a workload is stale. |
171
173
  | `ssh_counts_as_activity` | `false` | An SSH session alone is not useful work. |
174
+ | `traffic_counts_as_activity` | `true` | Requests served by the model runtime count as activity. The watchdog reads vLLM's `/metrics` on the instance (requests running or waiting, completed requests, prompt tokens), so an endpoint in use by something other than `gpu run` is not idled out. `false` = only workloads, startup and locks count. |
172
175
  | `lock.enabled` | `false` | Whether locks are offered in this project. |
173
176
  | `lock.default_ttl` | `2h` | TTL used by `gpu lock` with no `--for`. |
174
177
  | `watchdog.enabled` | `true` | Install the independent remote guard. |
@@ -180,6 +183,19 @@ otherwise.
180
183
 
181
184
  `idle_timeout` may not exceed `max_lifetime`.
182
185
 
186
+ `max_lifetime` and `budget.max_session_cost_usd` are measured per **session**: from
187
+ creation, and again from every restart of a stopped instance. A restart gets a fresh
188
+ bound instead of being killed for the hours it spent stopped.
189
+
190
+ `stopped_ttl` counts from when the instance was first observed stopped. On a
191
+ provider where a stopped instance still bills, `validate` warns when `stopped_ttl`
192
+ is `null` and `auto_destroy` is `false`: nothing would ever remove the instance.
193
+
194
+ `gpu sweep` guards every project registered by `gpu setup` with that project's own
195
+ `max_lifetime`, lock and `stopped_ttl`. Instances no project tracks are orphans,
196
+ judged by the sweep's own `--orphan-max-age` and `--stopped-ttl` flags. See
197
+ [Keeping the account clean](USAGE.md#keeping-the-account-clean).
198
+
183
199
  ---
184
200
 
185
201
  ## `health`
@@ -187,7 +203,8 @@ otherwise.
187
203
  | Field | Default | Meaning |
188
204
  | --- | --- | --- |
189
205
  | `interval` | `60s` | Probe interval while waiting for the runtime. |
190
- | `startup_timeout` | `10m` | Give up if the model server never answers. Raise it for very large models. |
206
+ | `boot_timeout` | `30m` | How long the machine may take to reach running, mostly the image pull. Past it the host counts as failed and `setup` tries another (`compute.host_attempts`). |
207
+ | `startup_timeout` | `10m` | Once the machine runs, how long the model server may take to answer. Raise it for very large models. |
191
208
  | `path` | `/health` | Health path on the model endpoint. |
192
209
 
193
210
  A passing health check is **not** activity. A healthy but unused server still goes idle.
@@ -199,7 +216,7 @@ A passing health check is **not** activity. A healthy but unused server still go
199
216
  | Field | Default | Meaning |
200
217
  | --- | --- | --- |
201
218
  | `max_hourly_cost_usd` | – | Refuse to provision above this hourly price. |
202
- | `max_session_cost_usd` | – | The remote guard shuts down once accrued cost passes this. |
219
+ | `max_session_cost_usd` | – | The remote guard shuts down once the current session's cost (session age x hourly price) passes this. |
203
220
 
204
221
  Override once with `gpu setup --allow-cost-override`.
205
222
 
@@ -0,0 +1,232 @@
1
+ # Integrating gpu into an application
2
+
3
+ How any application, in any language, uses a GPU that `gpu` rents, serves and shuts down. No SDK is needed: the app talks to two things it already knows how to use.
4
+
5
+ | Plane | What | How the app uses it |
6
+ | --- | --- | --- |
7
+ | Control | the `gpu` command | runs it as a subprocess with `--json` and reads stdout and the exit code |
8
+ | Data | vLLM's OpenAI-compatible HTTP API | any OpenAI SDK, or plain HTTP, with the base URL, key and model `gpu` printed |
9
+
10
+ The app never has to stop the GPU. The watchdog on the machine stops it once nothing has used it for `lifecycle.idle_timeout`, and every inference request counts as use. The app's only duty is to call `gpu setup` again when the machine it was using is gone.
11
+
12
+ ## 1. Prepare the host the app runs on
13
+
14
+ ```bash
15
+ pipx install jambu-gpu # or: uv tool install jambu-gpu / pip install jambu-gpu
16
+ cd /srv/my-app # the directory the app runs gpu from
17
+ gpu init --model Qwen/Qwen2.5-7B-Instruct
18
+ echo "VAST_API_KEY=..." >> .env # or export it, or ~/.gpu/credentials
19
+ gpu validate # fails here, not in production, on a bad config
20
+ gpu sweep --install-timer # optional safety net, see USAGE.md
21
+ ```
22
+
23
+ Requirements:
24
+
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.
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:
31
+
32
+ ```yaml
33
+ gpu_runtime:
34
+ lifecycle:
35
+ idle_timeout: 20m # stop after 20 minutes without a request
36
+ max_lifetime: 12h # hard cap per session, even under traffic
37
+ stopped_ttl: 24h # destroy a machine that stayed stopped this long
38
+ budget:
39
+ max_hourly_cost_usd: 0.60
40
+ ```
41
+
42
+ All fields are in [CONFIG.md](CONFIG.md).
43
+
44
+ ## 2. The contract
45
+
46
+ ### Commands an app calls
47
+
48
+ Always pass `--json` (before the command name). Run them from the project directory, or pass `--config /path/to/jambu.yaml`.
49
+
50
+ | Command | Use it to | Blocks | Starts billing |
51
+ | --- | --- | --- | --- |
52
+ | `gpu --json setup` | get a working endpoint: rents a machine if there is none, restarts it if it was stopped, waits until the model answers | until ready | yes |
53
+ | `gpu --json env` | read the endpoint without starting anything | no | no |
54
+ | `gpu --json status` | full state, cost so far, and what the guard would do now | no | no |
55
+ | `gpu --json stop` | stop it now instead of waiting for the idle timeout; disk and model cache are kept | until stopped | stops it |
56
+ | `gpu --json destroy --yes` | delete it; nothing is billed afterwards | until gone | stops it |
57
+ | `gpu --json lock --for 2h` | keep it up through a quiet period (a long job, a demo) | no | |
58
+ | `gpu --json unlock` | return to the normal rules | no | |
59
+
60
+ `setup` is idempotent and serialized by a file lock: ten processes calling it at once rent one machine, and the other nine wait and get the same answer.
61
+
62
+ ### What `setup` and `env` print
63
+
64
+ ```json
65
+ {
66
+ "instance_id": "27581234",
67
+ "state": "ready",
68
+ "endpoint": "http://203.0.113.7:41022",
69
+ "base_url": "http://203.0.113.7:41022/v1",
70
+ "api_key": "k6V0...43 characters",
71
+ "model": "Qwen/Qwen2.5-7B-Instruct",
72
+ "hourly_cost_usd": 0.196
73
+ }
74
+ ```
75
+
76
+ | Field | Meaning |
77
+ | --- | --- |
78
+ | `base_url` | Pass as the OpenAI SDK's `baseURL` / `base_url` |
79
+ | `api_key` | Pass as the SDK's `apiKey`. Requests without it get `401` |
80
+ | `model` | Pass as `model` in every request (`model.served_name` when set, otherwise `model.id`) |
81
+ | `state` | `ready` after `setup`. `env` reports whatever the last command saw, without asking the provider |
82
+ | `instance_id` | Changes when a new machine is rented |
83
+
84
+ `setup` also prints a `watchdog` object (URL, port, last contact; never its token). `env` exits `3` with `"state": "none"` when there is no endpoint.
85
+
86
+ `gpu env` without `--json` prints shell exports instead, for scripts:
87
+
88
+ ```bash
89
+ eval "$(gpu env)" # OPENAI_BASE_URL, OPENAI_API_KEY, JAMBU_GPU_ENDPOINT, JAMBU_MODEL_ID
90
+ gpu env --format dotenv > .env.gpu
91
+ ```
92
+
93
+ ### Output rules
94
+
95
+ - With `--json`, stdout carries exactly one JSON document and nothing else. Progress lines are suppressed.
96
+ - Errors go to stderr as one line, `error: <message>`, with a non-zero exit code.
97
+
98
+ ### Exit codes
99
+
100
+ | Code | Meaning | What the app should do |
101
+ | --- | --- | --- |
102
+ | 0 | success | |
103
+ | 1 | unexpected error | log stderr, retry later |
104
+ | 2 | config error (`jambu.yaml` invalid, or changed since setup) | fix the config; `setup --recreate` replaces a machine built from the old one |
105
+ | 3 | no instance / no endpoint (from `env`, `endpoint`) | call `setup` |
106
+ | 4 | provider API error | retry with backoff |
107
+ | 5 | provider authentication failed | fix `VAST_API_KEY` |
108
+ | 6 | no capacity matches the config | retry later, or relax `compute.gpu` / the budget |
109
+ | 7 | provisioning failed | retry; the failed machine was already destroyed |
110
+ | 8 | provider timed out | retry with backoff |
111
+ | 9 | the provider cannot do this | change the config |
112
+ | 10 | over `budget.max_hourly_cost_usd` | raise the budget or wait for cheaper offers |
113
+ | 11 | the model server did not start (setup failed, or every host tried failed to boot) | read stderr: it carries the server's log tail; the machine was destroyed |
114
+
115
+ ### Lifecycle the app has to expect
116
+
117
+ | Event | What the app sees | What it does |
118
+ | --- | --- | --- |
119
+ | first `setup` | blocks while a machine is rented, the image pulled and the model downloaded: usually 5–15 minutes. A bad host (stalled image pull, broken network) is destroyed and replaced automatically, each costing up to `health.boot_timeout` (30 min), at most `compute.host_attempts` (3) times | wait; do not put a short timeout on `setup`, it bounds itself |
120
+ | idle for `idle_timeout` | the watchdog stops the machine; requests fail with connection refused or a timeout | call `setup` again and retry the request |
121
+ | `setup` on a stopped machine | it is restarted: no new rent, no model download, under a minute for a small model (41 s measured) | wait, then use the **new** `base_url`: the provider may assign a new IP and port on restart |
122
+ | stopped for `stopped_ttl` | the machine is destroyed to stop the storage bill | the next `setup` rents a new one (and a new key) |
123
+ | `max_lifetime` reached | stopped even under traffic | same as idle |
124
+
125
+ The key is per machine: it survives stops and restarts, and changes only when a new machine is rented. Always take `base_url`, `api_key` and `model` from the latest `setup` output instead of caching them across restarts.
126
+
127
+ ## 3. Patterns
128
+
129
+ ### A short job: wrap it
130
+
131
+ The job gets the connection as environment variables and its heartbeats keep the machine up while it runs:
132
+
133
+ ```bash
134
+ gpu run node dist/batch.js
135
+ gpu run python score.py --input data.jsonl
136
+ ```
137
+
138
+ `gpu run` sets `OPENAI_BASE_URL`, `OPENAI_API_KEY`, `JAMBU_GPU_ENDPOINT` and `JAMBU_MODEL_ID` ([USAGE.md](USAGE.md#running-a-workload)). Any OpenAI SDK reads the first two by itself.
139
+
140
+ ### A long-running service: ensure, then heal
141
+
142
+ 1. On the first request (or at boot), call `gpu --json setup` and keep the result.
143
+ 2. Send requests with any OpenAI client, with a client timeout that is not too long (1–2 minutes), so a stopped machine fails fast.
144
+ 3. When a request fails with a **connection** error (refused, reset, timeout), call `gpu --json setup` again and retry once with the new result. Do not do this for HTTP errors like `400` or `401`: those come from a live server.
145
+ 4. Share one in-flight `setup` between concurrent requests.
146
+
147
+ The TypeScript helper below does exactly this.
148
+
149
+ ### A batch that must not be interrupted
150
+
151
+ `gpu lock --for 3h` before the batch and `gpu unlock` after it. Traffic already counts as activity, so a lock only matters if the batch has quiet stretches longer than `idle_timeout`.
152
+
153
+ ## 4. Recipes
154
+
155
+ ### TypeScript / Node
156
+
157
+ [`examples/typescript-client/src/jambuGpu.ts`](../examples/typescript-client/src/jambuGpu.ts) is a single file with no dependencies beyond Node 18+. Copy it into the app.
158
+
159
+ ```ts
160
+ import OpenAI from "openai";
161
+ import { Gpu } from "./jambuGpu.ts";
162
+
163
+ const gpu = new Gpu({ cwd: "/srv/my-app" }); // where jambu.yaml lives
164
+
165
+ const reply = await gpu.call((c) =>
166
+ new OpenAI({ baseURL: c.baseURL, apiKey: c.apiKey, maxRetries: 0, timeout: 120_000 })
167
+ .chat.completions.create({ model: c.model, messages: [{ role: "user", content: "hi" }] }),
168
+ );
169
+ ```
170
+
171
+ `gpu.call` starts the GPU on first use, shares one `setup` between concurrent calls, and restarts the GPU and retries once when the call fails because the machine is gone. The file also exports `ensureGpu`, `currentGpu`, `stopGpu`, `destroyGpu`, `gpuStatus`, `GpuError` (with `exitCode`) and the `EXIT` codes. [`examples/typescript-client/`](../examples/typescript-client/) runs it end to end:
172
+
173
+ ```bash
174
+ npm --prefix examples/typescript-client install
175
+ node --experimental-strip-types examples/typescript-client/src/index.ts "Say hi"
176
+ ```
177
+
178
+ ### Python
179
+
180
+ ```python
181
+ import json, subprocess
182
+ from openai import OpenAI, APIConnectionError
183
+
184
+ def ensure_gpu(cwd="/srv/my-app"):
185
+ out = subprocess.run(["gpu", "--json", "setup"], cwd=cwd, check=True,
186
+ capture_output=True, text=True).stdout
187
+ return json.loads(out)
188
+
189
+ conn = ensure_gpu()
190
+ def ask(prompt):
191
+ global conn
192
+ for attempt in (1, 2):
193
+ client = OpenAI(base_url=conn["base_url"], api_key=conn["api_key"], max_retries=0, timeout=120)
194
+ try:
195
+ return client.chat.completions.create(
196
+ model=conn["model"], messages=[{"role": "user", "content": prompt}]
197
+ ).choices[0].message.content
198
+ except APIConnectionError:
199
+ if attempt == 2:
200
+ raise
201
+ conn = ensure_gpu() # stopped while idle: restart it, then retry
202
+ ```
203
+
204
+ ### Shell / curl
205
+
206
+ ```bash
207
+ eval "$(gpu --json setup >/dev/null && gpu env)"
208
+ curl -s "$OPENAI_BASE_URL/chat/completions" \
209
+ -H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
210
+ -d "{\"model\": \"$JAMBU_MODEL_ID\", \"messages\": [{\"role\": \"user\", \"content\": \"hi\"}]}"
211
+ ```
212
+
213
+ ### Any other language
214
+
215
+ Run `gpu --json setup` as a subprocess, parse the JSON, and point the language's OpenAI client (or HTTP client) at `base_url` with `Authorization: Bearer <api_key>`. On a connection error, run it again and retry once.
216
+
217
+ ## 5. Security
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.
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.
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).
222
+ - `jambu.yaml` never holds credentials. `.env` and `.gpu/` do not belong in version control.
223
+
224
+ ## 6. Troubleshooting
225
+
226
+ | Symptom | Look at |
227
+ | --- | --- |
228
+ | `setup` fails with exit 11 | stderr has the model server's last log lines; `gpu logs --source runtime` |
229
+ | `setup` slow on the first call | `gpu logs --source onstart`; on Vast, `compute.image: vastai/vllm:v0.32.0-cuda-13.0` pulls faster than Docker Hub's `vllm/vllm-openai` |
230
+ | `401` from the endpoint | the key changed (a new machine was rented): take it from a fresh `setup` |
231
+ | the machine stopped while the app was busy | `gpu events`; check `idle_timeout` against the longest silence between requests, or `gpu lock` |
232
+ | unsure what is still billing | `gpu ls` lists every jambu instance in the account |
@@ -0,0 +1,128 @@
1
+ # How shutdown works
2
+
3
+ The CLI process cannot be responsible for idle shutdown. Close the laptop and a local watchdog dies while the GPU keeps billing. `setup` installs a watchdog on the instance itself:
4
+
5
+ ```
6
+ CLI ──provisions──> instance
7
+ │
8
+ ├── supervisor (bash respawn loop)
9
+ │ └── watchdog (HTTP :8777, authenticated)
10
+ │ ├── holds the activity and heartbeat state
11
+ │ ├── reads vLLM /metrics for inference traffic
12
+ │ ├── evaluates the lifecycle policy every 60s
13
+ │ └── calls the provider API to stop or destroy itself
14
+ └── vLLM :8000
15
+ ```
16
+
17
+ The watchdog script is generated at provision time. The lifecycle policy source is inlined from `jambu_gpu/core/policy.py`, so the remote guard and `gpu status` apply the same rule. The provider adapter injects only the two calls that stop or destroy the machine.
18
+
19
+ An instance stays billable only while:
20
+
21
+ 1. a workload is running (heartbeat alive), or
22
+ 2. a workload is queued, or the model is still starting, or
23
+ 3. the model server is serving requests, or
24
+ 4. an explicit lock is active.
25
+
26
+ Anything else becomes `STOPPED` or `DESTROYED`, with the reason recorded.
27
+
28
+ Activity is a running workload, a queued workload, model startup, inference traffic, or an active lock. A healthy vLLM server, a health check, a metrics scrape, and an open SSH session are not activity. If they were, an idle server would stay up forever.
29
+
30
+ A crashed CLI, a dropped SSH session, or a sleeping laptop stops the heartbeat. After `lifecycle.heartbeat_grace` the workload is stale, the instance goes idle, and the normal idle rules apply.
31
+
32
+ ## Inference traffic is activity
33
+
34
+ An agent that calls the vLLM endpoint directly, without `gpu run`, is still using the machine. The watchdog reads vLLM's Prometheus endpoint (`http://127.0.0.1:8000/metrics`) every `min(interval, 15s)` and marks activity when:
35
+
36
+ - a gauge is above zero: `vllm:num_requests_running`, `vllm:num_requests_waiting`, or
37
+ - a counter grew since the last successful read: `vllm:request_success_total`, `vllm:prompt_tokens_total`.
38
+
39
+ The first read only sets the baseline, so totals left over from earlier traffic do not keep the machine up. A counter that goes down means vLLM restarted; it rebaselines and does not count. A probe error is never activity, and it is logged as `activity_probe_error` at most once every 10 minutes. `GET /state` shows the last read under `last_probe` (`ok`, `values`, `last_traffic_at`).
40
+
41
+ Traffic only feeds the idle timer. It does not override `max_lifetime`, the session budget, or locks.
42
+
43
+ ## Sessions and restarts
44
+
45
+ A Vast.ai stop keeps the container disk, and the onstart script runs again on every container start. The watchdog state in `/var/lib/jambu/activity.json` therefore survives a stop/start, including the flag that says the instance was already stopped.
46
+
47
+ The onstart script sets `JAMBU_SESSION_STARTED_AT=$(date +%s)` once per container start and passes it to the watchdog. On boot the watchdog compares it with the stored `session_started_at`:
48
+
49
+ - **Different** (the instance was restarted): new session. The guard is re-armed, startup grace starts over, `last_activity` is now, workloads left running or queued are marked failed, and retry counters reset. Logged as `session_started`.
50
+ - **Equal** (the supervisor respawned a crashed watchdog in the same container start): state is kept.
51
+
52
+ `max_lifetime`, the session budget (`hourly_cost × session_age`) and startup grace are measured from the session start, not from creation. A restarted instance gets a fresh lease and is not killed the moment it boots. `GET /state` reports both `age_seconds` (since creation) and `session_age_seconds`.
53
+
54
+ ## Retries and escalation
55
+
56
+ A failed provider call never disarms the guard. A transient API error is logged as `provider_error`, then `guard_retry` with the attempt count, and the guard tries again on its next tick.
57
+
58
+ When stops have failed continuously for `escalate_after_s` (watchdog config, default 30 minutes), the watchdog destroys the instance instead, if the provider supports destroy (reason `stop_failed_escalation`). A machine that bills forever costs more than a lost disk, but a few minutes of provider API outage is not worth the disk. The failure count and clock reset after a successful call and whenever the decision stops being a shutdown (the instance is in use again), so failures from separate idle spells never add up. A completed manual shutdown clears its pending request.
59
+
60
+ A successful call does not end the loop either. If the provider accepted the call but the machine is still running after `max(300s, 5 × interval)`, the watchdog issues it again (`guard_reissue`). A failed manual `POST /shutdown` is retried the same way.
61
+
62
+ ## The supervisor
63
+
64
+ The watchdog runs inside a bash respawn loop started with `nohup`. If the watchdog crashes, the loop logs its exit code to `/var/log/jambu/watchdog.log` and starts it again 5 seconds later. `/var/run/jambu/watchdog.pid` holds the loop's pid; sending it `TERM` stops the loop and the watchdog together. The watchdog still starts before the model server.
65
+
66
+ ## Stopped instances
67
+
68
+ A stopped Vast.ai instance still bills for storage, and its watchdog is not running. Set `lifecycle.stopped_ttl` and the policy destroys an instance that has been stopped longer than that (reason `stopped_ttl`). This needs destroy support and no active lock. `gpu guard` enforces it from outside, since nothing runs on a stopped machine.
69
+
70
+ `stopped_since` keeps the first time the CLI saw the instance stopped, but only while nothing says it ran since. A session start, local activity, watchdog contact or a provider start time newer than `stopped_since` means the instance was restarted and stopped again while the CLI was not looking (from the web console, say), so the clock restarts at the current observation instead of destroying a fresh stop.
71
+
72
+ ## A second path
73
+
74
+ Run the same rule from anywhere that has API access:
75
+
76
+ ```bash
77
+ */5 * * * * cd /path/to/experiment && gpu guard
78
+ ```
79
+
80
+ `guard` evaluates the policy against reconciled state and enforces it. `gpu sweep --install-timer` runs it for every registered project; see [Keeping the account clean](USAGE.md#keeping-the-account-clean).
81
+
82
+ Inference traffic is counted on the instance only. When the watchdog is enabled and installed but did not answer during this reconcile, `guard` therefore does not apply `idle_timeout`: its local idle clock would stop an endpoint that is busy serving requests. `max_lifetime`, the session budget and `stopped_ttl` still apply, and the decision detail records `idle_skipped: watchdog_unreachable`. With `lifecycle.watchdog.enabled: false` the CLI guard owns the idle rule as before.
83
+
84
+ ## A model server that dies at boot
85
+
86
+ The runtime start command writes the model server's pid to `/var/run/jambu/vllm.pid`. `GET /state` reports `runtime_alive`: `true` or `false` from that pid, `null` while there is no pid file yet (or only one older than the current container start). If the process is gone while the instance is still starting, the watchdog logs `runtime_exited` once and ends startup, so the idle rules apply instead of a startup grace (`boot_timeout` + `startup_timeout` + 5 minutes) kept open by a dead server.
87
+
88
+ `gpu setup` asks the watchdog on every readiness poll. When `runtime_alive` is `false` it fails at once with the last 30 lines of the model server's log in the error (a CUDA driver mismatch, an out-of-memory, a bad flag), and `cleanup_on_setup_failure` destroys the instance.
89
+
90
+ `GET /logs?name=runtime|onstart|watchdog&lines=N` (authenticated, default 200, at most 2000) serves the tail of those files without SSH. `gpu logs` reads it.
91
+
92
+ ## The watchdog credential
93
+
94
+ The watchdog stops its own machine, so it needs provider API access. `VAST_API_KEY` is written on the instance at `/etc/jambu/watchdog.json` (mode 600).
95
+
96
+ Use a dedicated, restricted Vast.ai key for this, not a personal key. Set `lifecycle.watchdog.enabled: false` to opt out. Shutdown then depends on `gpu guard` or on the CLI staying alive, and `validate` warns you.
97
+
98
+ ## Architecture
99
+
100
+ ```
101
+ jambu.yaml (gpu_runtime:)
102
+ │
103
+ ▼
104
+ gpu
105
+ │
106
+ ├── Lifecycle core/policy.py the only shutdown rule
107
+ ├── Runtime runtime/vllm.py how the model runs
108
+ └── State core/state.py .gpu/state.json
109
+ │
110
+ Provider contract providers/base.py
111
+ │
112
+ ┌───────┴────────┐
113
+ ▼ ▼
114
+ Vast adapter future adapter
115
+ ```
116
+
117
+ Four concerns stay independent: model, inference runtime, lifecycle policy, infrastructure provider. The core never branches on a provider name. It branches on `provider.capabilities`.
118
+
119
+ ```
120
+ jambu_gpu/
121
+ ├── cli/ setup, run, status, lifecycle, providers, validate
122
+ ├── core/ config, models, policy, state, events, engine, execution, health
123
+ ├── providers/ base, registry, vast/
124
+ ├── runtime/ base, vllm
125
+ └── watchdog/ builder, agent_body
126
+ ```
127
+
128
+ Adding a provider: [ADDING_A_PROVIDER.md](ADDING_A_PROVIDER.md). The guarantees and the tests that hold them: [ACCEPTANCE.md](ACCEPTANCE.md).