levers9 0.2.2__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 (179) hide show
  1. {levers9-0.2.2 → levers9-0.3.0}/PKG-INFO +1 -1
  2. {levers9-0.2.2 → levers9-0.3.0}/pyproject.toml +1 -1
  3. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/integrations/fastmcp.py +11 -1
  4. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/integrations/vllm.py +170 -27
  5. levers9-0.3.0/src/levers9/checkpoint_memory.py +128 -0
  6. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/main.py +2 -0
  7. levers9-0.3.0/src/levers9/cli/workspace.py +160 -0
  8. levers9-0.3.0/src/levers9/clients/function/__init__.py +140 -0
  9. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/google/api/__init__.py +1 -1
  10. levers9-0.3.0/src/levers9/clients/image/__init__.py +108 -0
  11. levers9-0.3.0/src/levers9/clients/pod/__init__.py +597 -0
  12. levers9-0.3.0/src/levers9/clients/taskqueue/__init__.py +162 -0
  13. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/endpoint.py +8 -0
  14. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-llm-serving/SKILL.md +2 -1
  15. {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/PKG-INFO +1 -1
  16. {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/SOURCES.txt +9 -0
  17. levers9-0.3.0/tests/test_checkpoint_memory.py +50 -0
  18. levers9-0.3.0/tests/test_vllm_engine_args_bridge.py +99 -0
  19. levers9-0.3.0/tests/test_vllm_served_model_name.py +76 -0
  20. {levers9-0.2.2 → levers9-0.3.0}/README.md +0 -0
  21. {levers9-0.2.2 → levers9-0.3.0}/setup.cfg +0 -0
  22. {levers9-0.2.2 → levers9-0.3.0}/src/__init__.py +0 -0
  23. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/__init__.py +0 -0
  24. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/__main__.py +0 -0
  25. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/__init__.py +0 -0
  26. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/__init__.py +0 -0
  27. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/capacity.py +0 -0
  28. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/container.py +0 -0
  29. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/runner.py +0 -0
  30. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/utils.py +0 -0
  31. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/blueprint.py +0 -0
  32. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/box.py +0 -0
  33. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/endpoint.py +0 -0
  34. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/__init__.py +0 -0
  35. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/bot/__init__.py +0 -0
  36. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/bot/bot.py +0 -0
  37. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/bot/marker.py +0 -0
  38. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/bot/types.py +0 -0
  39. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/signal.py +0 -0
  40. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/integrations/__init__.py +0 -0
  41. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/job.py +0 -0
  42. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/map.py +0 -0
  43. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/mixins.py +0 -0
  44. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/output.py +0 -0
  45. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/queue.py +0 -0
  46. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/sandbox.py +0 -0
  47. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/service.py +0 -0
  48. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/shell.py +0 -0
  49. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/volume.py +0 -0
  50. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/agent/__init__.py +0 -0
  51. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/agent/server.py +0 -0
  52. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/agent/tools.py +0 -0
  53. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/aio.py +0 -0
  54. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/channel.py +0 -0
  55. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/__init__.py +0 -0
  56. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/config.py +0 -0
  57. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/container.py +0 -0
  58. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/database.py +0 -0
  59. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/deployment.py +0 -0
  60. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/dev.py +0 -0
  61. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/disk.py +0 -0
  62. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/extraclick.py +0 -0
  63. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/llm.py +0 -0
  64. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/login.py +0 -0
  65. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/machine.py +0 -0
  66. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/machine_format.py +0 -0
  67. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/mcp.py +0 -0
  68. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/pool.py +0 -0
  69. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/run.py +0 -0
  70. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/secret.py +0 -0
  71. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/serve.py +0 -0
  72. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/shell.py +0 -0
  73. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/skills.py +0 -0
  74. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/task.py +0 -0
  75. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/token.py +0 -0
  76. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/update_check.py +0 -0
  77. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/volume.py +0 -0
  78. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/worker.py +0 -0
  79. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/worker_management.py +0 -0
  80. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/client/__init__.py +0 -0
  81. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/client/client.py +0 -0
  82. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/client/deployment.py +0 -0
  83. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/client/task.py +0 -0
  84. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/__init__.py +0 -0
  85. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/blueprint/__init__.py +0 -0
  86. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/bot/__init__.py +0 -0
  87. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/box/__init__.py +0 -0
  88. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/controlplane/__init__.py +0 -0
  89. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/disk/__init__.py +0 -0
  90. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/endpoint/__init__.py +0 -0
  91. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/google/__init__.py +0 -0
  92. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/job/__init__.py +0 -0
  93. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/map/__init__.py +0 -0
  94. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/output/__init__.py +0 -0
  95. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/queue/__init__.py +0 -0
  96. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/secret/__init__.py +0 -0
  97. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/shell/__init__.py +0 -0
  98. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/signal/__init__.py +0 -0
  99. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/types/__init__.py +0 -0
  100. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/volume/__init__.py +0 -0
  101. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/config.py +0 -0
  102. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/env.py +0 -0
  103. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/exceptions.py +0 -0
  104. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/integrations.py +0 -0
  105. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/logging.py +0 -0
  106. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/middleware.py +0 -0
  107. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/multipart.py +0 -0
  108. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/__init__.py +0 -0
  109. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/bot/__init__.py +0 -0
  110. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/bot/transition.py +0 -0
  111. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/common.py +0 -0
  112. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/container.py +0 -0
  113. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/job.py +0 -0
  114. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/queue.py +0 -0
  115. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/serve.py +0 -0
  116. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/schema.py +0 -0
  117. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/README.md +0 -0
  118. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/__init__.py +0 -0
  119. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-blueprints/SKILL.md +0 -0
  120. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-boxes/SKILL.md +0 -0
  121. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-cli/SKILL.md +0 -0
  122. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-crons/SKILL.md +0 -0
  123. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-dashboard/SKILL.md +0 -0
  124. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-data-lab/SKILL.md +0 -0
  125. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-endpoints/SKILL.md +0 -0
  126. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-gpu/SKILL.md +0 -0
  127. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-jobs/SKILL.md +0 -0
  128. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-mcp-server/SKILL.md +0 -0
  129. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-pipelines/SKILL.md +0 -0
  130. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-queues/SKILL.md +0 -0
  131. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-rl-arena/SKILL.md +0 -0
  132. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-sandboxes/SKILL.md +0 -0
  133. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-volumes-secrets/SKILL.md +0 -0
  134. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/sync.py +0 -0
  135. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/terminal.py +0 -0
  136. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/type.py +0 -0
  137. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/utils.py +0 -0
  138. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/__init__.py +0 -0
  139. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/_meta.py +0 -0
  140. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/gitignore.py +0 -0
  141. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/pathspec.py +0 -0
  142. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/pattern.py +0 -0
  143. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/patterns/__init__.py +0 -0
  144. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/patterns/gitwildmatch.py +0 -0
  145. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/py.typed +0 -0
  146. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/util.py +0 -0
  147. {levers9-0.2.2 → levers9-0.3.0}/src/levers9/whereami.py +0 -0
  148. {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/dependency_links.txt +0 -0
  149. {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/entry_points.txt +0 -0
  150. {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/requires.txt +0 -0
  151. {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/top_level.txt +0 -0
  152. {levers9-0.2.2 → levers9-0.3.0}/tests/test_capacity.py +0 -0
  153. {levers9-0.2.2 → levers9-0.3.0}/tests/test_cli.py +0 -0
  154. {levers9-0.2.2 → levers9-0.3.0}/tests/test_client_base_url.py +0 -0
  155. {levers9-0.2.2 → levers9-0.3.0}/tests/test_config.py +0 -0
  156. {levers9-0.2.2 → levers9-0.3.0}/tests/test_container_runner.py +0 -0
  157. {levers9-0.2.2 → levers9-0.3.0}/tests/test_deployment.py +0 -0
  158. {levers9-0.2.2 → levers9-0.3.0}/tests/test_env.py +0 -0
  159. {levers9-0.2.2 → levers9-0.3.0}/tests/test_filesyncer.py +0 -0
  160. {levers9-0.2.2 → levers9-0.3.0}/tests/test_function.py +0 -0
  161. {levers9-0.2.2 → levers9-0.3.0}/tests/test_function_runner.py +0 -0
  162. {levers9-0.2.2 → levers9-0.3.0}/tests/test_image.py +0 -0
  163. {levers9-0.2.2 → levers9-0.3.0}/tests/test_machine_cli.py +0 -0
  164. {levers9-0.2.2 → levers9-0.3.0}/tests/test_map.py +0 -0
  165. {levers9-0.2.2 → levers9-0.3.0}/tests/test_multipart.py +0 -0
  166. {levers9-0.2.2 → levers9-0.3.0}/tests/test_pod_instance.py +0 -0
  167. {levers9-0.2.2 → levers9-0.3.0}/tests/test_pool.py +0 -0
  168. {levers9-0.2.2 → levers9-0.3.0}/tests/test_runner.py +0 -0
  169. {levers9-0.2.2 → levers9-0.3.0}/tests/test_sandbox_convenience.py +0 -0
  170. {levers9-0.2.2 → levers9-0.3.0}/tests/test_sandbox_docker_manager.py +0 -0
  171. {levers9-0.2.2 → levers9-0.3.0}/tests/test_sandbox_log_streaming_integration.py +0 -0
  172. {levers9-0.2.2 → levers9-0.3.0}/tests/test_sandbox_process_stream.py +0 -0
  173. {levers9-0.2.2 → levers9-0.3.0}/tests/test_service.py +0 -0
  174. {levers9-0.2.2 → levers9-0.3.0}/tests/test_shell.py +0 -0
  175. {levers9-0.2.2 → levers9-0.3.0}/tests/test_skills.py +0 -0
  176. {levers9-0.2.2 → levers9-0.3.0}/tests/test_task_queue.py +0 -0
  177. {levers9-0.2.2 → levers9-0.3.0}/tests/test_update_check.py +0 -0
  178. {levers9-0.2.2 → levers9-0.3.0}/tests/test_utils.py +0 -0
  179. {levers9-0.2.2 → levers9-0.3.0}/tests/test_volumes.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: levers9
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: levers9 SDK and CLI: run jobs, endpoints, queues, crons, boxes and GPU work on a levers9 cluster
5
5
  Author-email: Levers AI Lab <hello@uselevers.com>
6
6
  Requires-Python: <4.0,>=3.8
@@ -27,7 +27,7 @@ dependencies = [
27
27
  "pyyaml<7.0.0,>=6.0.0",
28
28
  ]
29
29
  name = "levers9"
30
- version = "0.2.2"
30
+ version = "0.3.0"
31
31
  description = "levers9 SDK and CLI: run jobs, endpoints, queues, crons, boxes and GPU work on a levers9 cluster"
32
32
 
33
33
  [project.scripts]
@@ -153,6 +153,7 @@ class MCPServer(ASGI):
153
153
  name: Optional[str] = None,
154
154
  context: Optional[ConfigContext] = None,
155
155
  invocation_details_func: Optional[Callable[..., None]] = None,
156
+ rollout: str = "auto",
156
157
  **invocation_details_options: Any,
157
158
  ) -> Tuple[Dict[str, Any], bool]:
158
159
  self.name = name or self.name
@@ -172,19 +173,28 @@ class MCPServer(ASGI):
172
173
 
173
174
  terminal.header("Deploying")
174
175
  deploy_response: DeployStubResponse = self.controlplane_stub.deploy_stub(
175
- DeployStubRequest(stub_id=self.stub_id, name=self.name)
176
+ DeployStubRequest(stub_id=self.stub_id, name=self.name, rollout=rollout)
176
177
  )
177
178
 
178
179
  self.deployment_id = deploy_response.deployment_id
180
+ warn_msg = deploy_response.warn_msg if isinstance(deploy_response.warn_msg, str) else ""
179
181
  if deploy_response.ok:
182
+ # A rollout can succeed and still have something to say - that a revision was replaced, or
183
+ # that downtime is expected. Swallowing it is how a rollout that quietly did nothing stayed
184
+ # invisible for nine versions.
185
+ if warn_msg:
186
+ terminal.warn(warn_msg)
180
187
  terminal.header("Deployed 🎉")
181
188
  if invocation_details_func:
182
189
  invocation_details_func(**invocation_details_options)
183
190
  else:
184
191
  self.print_invocation_snippet(**invocation_details_options)
192
+ elif deploy_response.err_msg:
193
+ terminal.error(deploy_response.err_msg, exit=False)
185
194
 
186
195
  return {
187
196
  "deployment_id": deploy_response.deployment_id,
197
+ "warning": warn_msg,
188
198
  }, deploy_response.ok
189
199
 
190
200
  @with_grpc_error_handling
@@ -10,6 +10,7 @@ from ...abstractions.endpoint import ASGI
10
10
  from ...abstractions.blueprint import Image
11
11
  from ...abstractions.volume import CloudBucket, Volume
12
12
  from ...channel import with_grpc_error_handling
13
+ from ...checkpoint_memory import VLLM_PROCESS_RSS_GIB, advise
13
14
  from ...clients.endpoint import StartEndpointServeRequest, StartEndpointServeResponse
14
15
  from ...clients.controlplane import DeployStubRequest, DeployStubResponse
15
16
  from ...config import ConfigContext
@@ -35,6 +36,47 @@ def _served_model_name(value: Optional[Union[str, List[str]]]) -> str:
35
36
  return value or ""
36
37
 
37
38
 
39
+ class _ServerArgs(SimpleNamespace):
40
+ """A namespace for vLLM's OpenAI server arguments that cannot raise AttributeError.
41
+
42
+ `init_app_state` reads these by name and vLLM adds to them every release, so a hand-listed namespace
43
+ breaks on the first argument a new version starts reading. Known values are set explicitly; anything
44
+ else falls back to the installed release's own default if it publishes one, and to None otherwise -
45
+ which is what vLLM itself defaults almost all of these to.
46
+ """
47
+
48
+ def __getattr__(self, name: str) -> Any:
49
+ if name.startswith("__"):
50
+ raise AttributeError(name)
51
+ value = _server_arg_default(name)
52
+ # cache it, so the same lookup does not re-import on every access
53
+ setattr(self, name, value)
54
+ return value
55
+
56
+
57
+ def _server_arg_default(name: str) -> Any:
58
+ """This vLLM's own default for a server argument, or None if it does not publish one."""
59
+ import dataclasses
60
+
61
+ for module, cls_name in (
62
+ ("vllm.entrypoints.launchers.cli_args", "FrontendArgs"), # 0.29+
63
+ ("vllm.entrypoints.openai.cli_args", "FrontendArgs"),
64
+ ):
65
+ try:
66
+ mod = __import__(module, fromlist=[cls_name])
67
+ spec = getattr(mod, cls_name)
68
+ for field in dataclasses.fields(spec):
69
+ if field.name != name:
70
+ continue
71
+ if field.default is not dataclasses.MISSING:
72
+ return field.default
73
+ if field.default_factory is not dataclasses.MISSING: # type: ignore[misc]
74
+ return field.default_factory() # type: ignore[misc]
75
+ except Exception:
76
+ continue
77
+ return None
78
+
79
+
38
80
  # vllm/engine/arg_utils.py:EngineArgs
39
81
  @dataclass
40
82
  class VLLMArgs:
@@ -167,6 +209,10 @@ class VLLMArgs:
167
209
  data_parallel_size: int = 1
168
210
  compilation_config: Optional[Any] = None
169
211
  vllm_cache_root: Optional[str] = DEFAULT_VLLM_CACHE_ROOT
212
+ # Any engine argument this dataclass does not name, applied by name onto the installed vLLM's own
213
+ # arguments. The fields above mirror one release; vLLM adds and removes arguments every release, so
214
+ # without this a flag newer than this SDK cannot be set at all except by subclassing.
215
+ extra_args: Optional[Dict[str, Any]] = None
170
216
 
171
217
 
172
218
  class VLLM(ASGI):
@@ -186,9 +232,13 @@ class VLLM(ASGI):
186
232
  The container image used for the task execution. Whatever you pass here will have an additional `add_python_packages` call
187
233
  with `["fastapi", "vllm", "huggingface_hub"]` added to it to ensure that we can run vLLM in the container.
188
234
  vllm_version (str):
189
- The version of vLLM that will be installed from PyPI. As the configuration of the vLLM engine depends on the version of vLLM, using a non-default vllm_version might require subclassing VLLMArgs in order to add the missing configuration options. Default is version 0.8.4.
190
- huggingface_hub_version (str):
191
- The version of huggingface_hub that will be installed from PyPI. Different versions of vLLM require different versions of huggingface_hub, thus using a non-default vLLM version might require using a non-default version of huggingface_hub. Default is version 0.30.2.
235
+ The version of vLLM installed from PyPI. Engine arguments are built from whatever that
236
+ release declares, so a non-default version no longer needs VLLMArgs subclassed: arguments it
237
+ does not have are ignored, arguments it adds are reachable through VLLMArgs.extra_args.
238
+ Default is 0.29.0.
239
+ huggingface_hub_version (Optional[str]):
240
+ Pin huggingface_hub rather than letting vLLM's own dependency bound choose it. Default None,
241
+ which is almost always right: the bound differs sharply between vLLM releases.
192
242
  workers (int):
193
243
  The number of workers to run in the container. Default is 1.
194
244
  concurrent_requests (int):
@@ -232,14 +282,16 @@ class VLLM(ASGI):
232
282
  gpu: Union[GpuTypeAlias, List[GpuTypeAlias]] = GpuType.NoGPU,
233
283
  gpu_count: int = 0,
234
284
  image: Image = Image(python_version="python3.11"),
235
- vllm_version: str = "0.8.4",
236
- huggingface_hub_version: str = "0.30.2",
285
+ vllm_version: str = "0.29.0",
286
+ huggingface_hub_version: Optional[str] = None,
237
287
  workers: int = 1,
238
288
  concurrent_requests: int = 1,
239
289
  keep_warm_seconds: int = 60,
240
290
  max_pending_tasks: int = 100,
241
291
  timeout: int = 3600,
242
292
  authorized: bool = True,
293
+ checkpoint_enabled: bool = False,
294
+ env: Optional[Dict[str, str]] = None,
243
295
  name: Optional[str] = None,
244
296
  volumes: Optional[List[Union[Volume, CloudBucket]]] = [],
245
297
  disks: Optional[List[DurableDisk]] = None,
@@ -253,14 +305,18 @@ class VLLM(ASGI):
253
305
 
254
306
  volumes.append(Volume(name="vllm_cache_root", mount_path=vllm_args.vllm_cache_root))
255
307
 
256
- image = image.add_python_packages(
257
- [
258
- "fastapi",
259
- "numpy",
260
- f"vllm=={vllm_version}",
261
- f"huggingface_hub=={huggingface_hub_version}",
262
- ]
263
- )
308
+ # vLLM already depends on fastapi and numpy, with the bounds its own dependencies need. Asking for
309
+ # them unconstrained alongside it made pip take the newest of each first, find that numba wants
310
+ # numpy<2.3, and backtrack - walking dozens of transformers releases to get back out. Naming only
311
+ # what vLLM does not bring lets the resolver go straight to a working set.
312
+ #
313
+ # huggingface_hub is one vLLM does bring, and pinning it alongside is how this broke: 0.8.4 wants
314
+ # <1.0 and 0.29 wants >=1.28, so a single pinned version makes one of them unresolvable outright.
315
+ # Left unset, vLLM's own bound decides; pass huggingface_hub_version only to override that.
316
+ packages = [f"vllm=={vllm_version}"]
317
+ if huggingface_hub_version:
318
+ packages.append(f"huggingface_hub=={huggingface_hub_version}")
319
+ image = image.add_python_packages(packages)
264
320
 
265
321
  super().__init__(
266
322
  cpu=cpu,
@@ -274,6 +330,8 @@ class VLLM(ASGI):
274
330
  max_pending_tasks=max_pending_tasks,
275
331
  timeout=timeout,
276
332
  authorized=authorized,
333
+ checkpoint_enabled=checkpoint_enabled,
334
+ env=env or {},
277
335
  name=name,
278
336
  volumes=volumes,
279
337
  disks=disks,
@@ -281,6 +339,24 @@ class VLLM(ASGI):
281
339
  autoscaler=autoscaler,
282
340
  )
283
341
 
342
+ # A GPU checkpoint has to fit a copy of the card inside the container. vLLM reserves
343
+ # gpu_memory_utilization of the device up front and holds it, so the footprint is known here -
344
+ # which means an impossible deployment can be named now rather than after a ten-minute cold start
345
+ # and a dump log that blames CUDA for what is really an out-of-memory kill.
346
+ if checkpoint_enabled and gpu_count > 0:
347
+ gpu_name = gpu[0] if isinstance(gpu, list) and gpu else gpu
348
+ sizing = advise(
349
+ gpu=str(getattr(gpu_name, "value", gpu_name)),
350
+ gpu_count=gpu_count,
351
+ requested_memory_gib=self.parse_memory(memory) / 1024
352
+ if isinstance(memory, str)
353
+ else float(memory) / 1024,
354
+ gpu_memory_utilization=vllm_args.gpu_memory_utilization,
355
+ process_rss_gib=VLLM_PROCESS_RSS_GIB,
356
+ )
357
+ for warning in sizing.warnings:
358
+ terminal.warn(warning)
359
+
284
360
  self.serving = ServingConfig(
285
361
  app_kind=LLM_APP_KIND,
286
362
  serving_protocol=OPENAI_SERVING_PROTOCOL,
@@ -296,7 +372,21 @@ class VLLM(ASGI):
296
372
  )
297
373
  self.chat_template_url = vllm_args.chat_template_url
298
374
  self.engine_args = vllm_args
299
- self.app_args = SimpleNamespace(
375
+ # vLLM's --served-model-name is nargs="+", so both the engine and the API server's model registry
376
+ # expect a list. A bare string is iterated character by character: "qwen15" is registered as six
377
+ # models named q, w, e, n, 1 and 5, and the name you actually asked for matches none of them.
378
+ # engine_args below is this same object, so normalising here settles both.
379
+ if isinstance(vllm_args.served_model_name, str):
380
+ vllm_args.served_model_name = (
381
+ [vllm_args.served_model_name] if vllm_args.served_model_name else None
382
+ )
383
+
384
+ # The server arguments the OpenAI app reads off this namespace. init_app_state accesses them by
385
+ # name, so anything it expects and this does not set is an AttributeError at startup - the same
386
+ # failure the engine bridge fixed, in a second place. _ServerArgs seeds the installed version's own
387
+ # defaults where it publishes them and answers None for the rest, so a release that starts reading
388
+ # a new argument gets a sane value instead of a crash.
389
+ self.app_args = _ServerArgs(
300
390
  model=vllm_args.model,
301
391
  served_model_name=vllm_args.served_model_name,
302
392
  disable_log_requests=vllm_args.disable_log_requests,
@@ -325,6 +415,37 @@ class VLLM(ASGI):
325
415
  def func(self, *args: Any, **kwargs: Any):
326
416
  pass
327
417
 
418
+ def _engine_args_for(self, cls: Any) -> Any:
419
+ """Build the engine's arguments from whatever version of vLLM is installed.
420
+
421
+ `AsyncEngineArgs.from_cli_args` does `getattr(args, name)` with no default for every field the
422
+ installed vLLM declares, so a VLLMArgs missing even one of them raises AttributeError. That is why
423
+ VLLMArgs mirrors a single release by hand, and why changing vllm_version used to mean subclassing
424
+ it - vLLM's EngineArgs has gone from 104 fields to 236 across releases, and 32 of the ones named
425
+ here no longer exist at all.
426
+
427
+ Starting from the installed version's own defaults inverts that: fields it knows take our value
428
+ when we have one and its default when we do not, fields it has dropped are ignored, and anything
429
+ in extra_args is applied by name, so a flag added after this SDK was written can still be set.
430
+ """
431
+ import dataclasses
432
+
433
+ missing = object()
434
+ engine = cls() # this vLLM's defaults, whatever version it is
435
+ known = {f.name for f in dataclasses.fields(cls)}
436
+ for name in known:
437
+ value = getattr(self.engine_args, name, missing)
438
+ if value is not missing:
439
+ setattr(engine, name, value)
440
+
441
+ for name, value in (getattr(self.engine_args, "extra_args", None) or {}).items():
442
+ if name not in known:
443
+ # Not an argument of the installed vLLM. Saying so beats a setting that does nothing.
444
+ print(f"[levers9] ignoring unknown vLLM engine argument {name!r}", flush=True)
445
+ continue
446
+ setattr(engine, name, value)
447
+ return engine
448
+
328
449
  def __call__(self, *args: Any, **kwargs: Any):
329
450
  import asyncio
330
451
 
@@ -332,9 +453,16 @@ class VLLM(ASGI):
332
453
  from fastapi import FastAPI
333
454
  from vllm.engine.arg_utils import AsyncEngineArgs
334
455
  from vllm.engine.async_llm_engine import AsyncLLMEngine
335
- from vllm.entrypoints.openai.tool_parsers import ToolParserManager
336
456
  from vllm.usage.usage_lib import UsageContext
337
457
 
458
+ # 0.29 reorganised vllm/entrypoints/openai into subpackages and moved the tool parsers out of it
459
+ # entirely. The class and its API are unchanged, so both paths are tried rather than pinning the
460
+ # SDK to one layout - an endpoint can pin its own vllm_version, and the default has not moved yet.
461
+ try:
462
+ from vllm.tool_parsers import ToolParserManager # 0.29+
463
+ except ImportError:
464
+ from vllm.entrypoints.openai.tool_parsers import ToolParserManager
465
+
338
466
  if self.engine_args.vllm_cache_root:
339
467
  os.environ["VLLM_CACHE_ROOT"] = self.engine_args.vllm_cache_root
340
468
 
@@ -379,23 +507,28 @@ class VLLM(ASGI):
379
507
  async def health_check():
380
508
  return {"status": "healthy"}
381
509
 
382
- app.include_router(api_server.router)
510
+ # 0.29 replaced the module-level router with a function that registers them onto the app.
511
+ if hasattr(api_server, "register_api_routers"):
512
+ api_server.register_api_routers(app)
513
+ else:
514
+ app.include_router(api_server.router)
383
515
 
384
- engine_args = AsyncEngineArgs.from_cli_args(self.engine_args)
516
+ engine_args = self._engine_args_for(AsyncEngineArgs)
385
517
 
386
518
  engine_client = AsyncLLMEngine.from_engine_args(
387
519
  engine_args, usage_context=UsageContext.OPENAI_API_SERVER
388
520
  )
389
521
 
390
- model_config = asyncio.run(engine_client.get_model_config())
391
- asyncio.run(
392
- api_server.init_app_state(
393
- engine_client,
394
- model_config,
395
- app.state,
396
- self.app_args,
397
- )
398
- )
522
+ # 0.8.4 wanted the model config passed in; 0.29 reads it off the engine client and dropped the
523
+ # parameter. Ask the function what it takes rather than branching on a version string.
524
+ import inspect
525
+
526
+ takes_model_config = "model_config" in inspect.signature(api_server.init_app_state).parameters
527
+ if takes_model_config:
528
+ state_args = (engine_client, asyncio.run(engine_client.get_model_config()), app.state, self.app_args)
529
+ else:
530
+ state_args = (engine_client, app.state, self.app_args)
531
+ asyncio.run(api_server.init_app_state(*state_args))
399
532
 
400
533
  return app
401
534
 
@@ -404,6 +537,7 @@ class VLLM(ASGI):
404
537
  name: Optional[str] = None,
405
538
  context: Optional[ConfigContext] = None,
406
539
  invocation_details_func: Optional[Callable[..., None]] = None,
540
+ rollout: str = "auto",
407
541
  **invocation_details_options: Any,
408
542
  ) -> Tuple[Dict[str, Any], bool]:
409
543
  self.name = name or self.name
@@ -431,19 +565,28 @@ class VLLM(ASGI):
431
565
 
432
566
  terminal.header("Deploying")
433
567
  deploy_response: DeployStubResponse = self.controlplane_stub.deploy_stub(
434
- DeployStubRequest(stub_id=self.stub_id, name=self.name)
568
+ DeployStubRequest(stub_id=self.stub_id, name=self.name, rollout=rollout)
435
569
  )
436
570
 
437
571
  self.deployment_id = deploy_response.deployment_id
572
+ warn_msg = deploy_response.warn_msg if isinstance(deploy_response.warn_msg, str) else ""
438
573
  if deploy_response.ok:
574
+ # A rollout can succeed and still have something to say - that a revision was replaced, or
575
+ # that downtime is expected. Swallowing it is how a rollout that quietly did nothing stayed
576
+ # invisible for nine versions.
577
+ if warn_msg:
578
+ terminal.warn(warn_msg)
439
579
  terminal.header("Deployed 🎉")
440
580
  if invocation_details_func:
441
581
  invocation_details_func(**invocation_details_options)
442
582
  else:
443
583
  self.print_invocation_snippet(**invocation_details_options)
584
+ elif deploy_response.err_msg:
585
+ terminal.error(deploy_response.err_msg, exit=False)
444
586
 
445
587
  return {
446
588
  "deployment_id": deploy_response.deployment_id,
589
+ "warning": warn_msg,
447
590
  }, deploy_response.ok
448
591
 
449
592
  @with_grpc_error_handling
@@ -0,0 +1,128 @@
1
+ """How much container memory a GPU checkpoint needs, and why.
2
+
3
+ A GPU checkpoint copies device memory into shmem-backed host allocations that are charged to the
4
+ container's memory cgroup, at roughly 1.2x the live device footprint. So a container that will be
5
+ checkpointed has to hold its own resident memory *and* a copy of everything on the card:
6
+
7
+ container_memory > process_rss + HOST_COPY_RATIO * device_footprint + slack
8
+
9
+ Get this wrong and the kernel OOM-kills the process part way through the dump. What surfaces is not an
10
+ out-of-memory error - CRIU's NVIDIA plugin reports
11
+
12
+ cuda_plugin: CHECKPOINT_DEVICES failed ... "OS call failed or operation not supported on this OS"
13
+
14
+ which reads like the card or the driver cannot do checkpointing at all. It can. The tell is in the kernel
15
+ log rather than the container log: `Memory cgroup out of memory` with a large `shmem-rss`, identical
16
+ across runs with very different device footprints, because the copy is killed on reaching the ceiling
17
+ while it is still growing.
18
+
19
+ The point of this module is that nobody should have to learn that twice. A deployment that cannot
20
+ possibly checkpoint is worth saying so at deploy time, in a second, rather than after a ten-minute cold
21
+ start and an unreadable dump log.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+ from typing import Optional
28
+
29
+ # Usable VRAM per card in GiB - a little under the nameplate figure, which is what the driver reports
30
+ # once its own reservations are taken out (a "16GB" T4 profiles at 14.76GiB).
31
+ GPU_VRAM_GIB = {
32
+ "T4": 14.7,
33
+ "L4": 22.3,
34
+ "A10": 22.3,
35
+ "A10G": 22.3,
36
+ "A16": 14.7,
37
+ "A30": 23.5,
38
+ "A40": 44.3,
39
+ "L40": 44.3,
40
+ "L40S": 44.3,
41
+ "A100": 39.5,
42
+ "A100-40": 39.5,
43
+ "A100-80": 79.2,
44
+ "H100": 79.2,
45
+ "H200": 140.0,
46
+ "A4000": 15.7,
47
+ "A5000": 23.5,
48
+ "A6000": 47.5,
49
+ "RTX4000Ada": 19.5,
50
+ "RTXPro6000": 95.0,
51
+ }
52
+
53
+ # Measured: a dump of an 8GiB device buffer fitted under an 11Gi limit alongside ~1.5GiB of interpreter,
54
+ # and vLLM at 12.5GiB device needed ~18.8GiB. Both put the host copy at ~1.2x the device footprint.
55
+ HOST_COPY_RATIO = 1.2
56
+
57
+ # What the runtime itself costs before any model. A bare Python endpoint with torch sits near 1.5GiB;
58
+ # vLLM carries ~3.8GiB before a single weight is loaded.
59
+ DEFAULT_PROCESS_RSS_GIB = 1.5
60
+ VLLM_PROCESS_RSS_GIB = 3.8
61
+
62
+ # Calibrated against the runs, not guessed. A T4 at 0.85 (12.5GiB device) checkpointed inside 20Gi, so the
63
+ # figure has to stay under that or it warns about a configuration known to work; a T4 at 0.45 inside 11Gi
64
+ # failed, so it has to stay above that. 1GiB sits between them.
65
+ SLACK_GIB = 1.0
66
+
67
+
68
+ @dataclass
69
+ class MemoryAdvice:
70
+ """What a checkpointed GPU deployment needs, and whether what was asked for is enough."""
71
+
72
+ required_gib: float = 0.0
73
+ device_gib: float = 0.0
74
+ process_rss_gib: float = 0.0
75
+ fits: bool = True
76
+ warnings: list = field(default_factory=list)
77
+
78
+ @property
79
+ def suggestion(self) -> str:
80
+ return f'memory="{int(self.required_gib + 0.999)}Gi"'
81
+
82
+
83
+ def device_footprint_gib(
84
+ gpu: str, gpu_count: int = 1, gpu_memory_utilization: Optional[float] = None
85
+ ) -> float:
86
+ """How much of the card will actually be live when the snapshot is taken.
87
+
88
+ For vLLM that is `gpu_memory_utilization` of the card, because it reserves that up front and holds it.
89
+ Without a utilisation figure the whole card is the only safe assumption: a process is free to fill it.
90
+ """
91
+ vram = GPU_VRAM_GIB.get(gpu, 0.0) * max(gpu_count, 1)
92
+ if not vram:
93
+ return 0.0
94
+ return vram * (gpu_memory_utilization if gpu_memory_utilization else 1.0)
95
+
96
+
97
+ def advise(
98
+ gpu: str,
99
+ gpu_count: int = 1,
100
+ requested_memory_gib: Optional[float] = None,
101
+ gpu_memory_utilization: Optional[float] = None,
102
+ process_rss_gib: float = DEFAULT_PROCESS_RSS_GIB,
103
+ ) -> MemoryAdvice:
104
+ """Size a checkpointed GPU container, and say plainly when what was asked for cannot work."""
105
+ device = device_footprint_gib(gpu, gpu_count, gpu_memory_utilization)
106
+ if not device:
107
+ # An unknown card: no basis to advise on, and a wrong number is worse than none.
108
+ return MemoryAdvice()
109
+
110
+ required = process_rss_gib + HOST_COPY_RATIO * device + SLACK_GIB
111
+ advice = MemoryAdvice(
112
+ required_gib=required,
113
+ device_gib=device,
114
+ process_rss_gib=process_rss_gib,
115
+ )
116
+
117
+ if requested_memory_gib is not None and requested_memory_gib < required:
118
+ advice.fits = False
119
+ advice.warnings.append(
120
+ f"checkpoint_enabled needs about {required:.0f}Gi of container memory on "
121
+ f"{gpu}{f' x{gpu_count}' if gpu_count > 1 else ''}, but this asks for "
122
+ f"{requested_memory_gib:.0f}Gi. A GPU checkpoint copies device memory into the container "
123
+ f"({device:.1f}GiB live on the card, about {HOST_COPY_RATIO * device:.1f}GiB of host memory) "
124
+ f"on top of the process itself (~{process_rss_gib:.1f}GiB). Too little and the dump is "
125
+ f"OOM-killed half way through, which is reported as a CUDA error rather than an out-of-memory "
126
+ f"one. Set {advice.suggestion}, lower gpu_memory_utilization, or drop checkpoint_enabled."
127
+ )
128
+ return advice
@@ -32,6 +32,7 @@ from . import (
32
32
  token,
33
33
  volume,
34
34
  worker,
35
+ workspace,
35
36
  )
36
37
  from .extraclick import CLICK_CONTEXT_SETTINGS, ClickCommonGroup, CommandGroupCollection
37
38
 
@@ -125,6 +126,7 @@ def load_cli(check_config=True, **kwargs: Any) -> CLI:
125
126
  cli.register(secret)
126
127
  cli.register(token)
127
128
  cli.register(worker)
129
+ cli.register(workspace)
128
130
  cli.register(shell)
129
131
  cli.register(run)
130
132
  cli.register(dev)
@@ -0,0 +1,160 @@
1
+ from typing import Any, Dict
2
+
3
+ import click
4
+ from rich.table import Column, Table, box
5
+
6
+ from .. import terminal
7
+ from ..client import get as rest_get
8
+ from ..client import post as rest_post
9
+ from ..client.client import Client
10
+ from .extraclick import ClickManagementGroup
11
+
12
+
13
+ @click.group(
14
+ name="workspace",
15
+ help="Inspect and configure the workspace.",
16
+ cls=ClickManagementGroup,
17
+ )
18
+ def management():
19
+ pass
20
+
21
+
22
+ @management.group(name="storage", help="The object storage this workspace keeps its data in.")
23
+ def storage():
24
+ pass
25
+
26
+
27
+ def _client() -> Client:
28
+ return Client()
29
+
30
+
31
+ def _get(client: Client, path: str) -> Dict[str, Any]:
32
+ r = rest_get(token=client.token, url=client.base_url, path=path)
33
+ if r.status_code >= 400:
34
+ terminal.error(f"{r.status_code}: {_message(r)}")
35
+ return r.json()
36
+
37
+
38
+ def _message(response) -> str:
39
+ try:
40
+ body = response.json()
41
+ except ValueError:
42
+ return response.text.strip() or "no detail"
43
+ if isinstance(body, dict):
44
+ return str(body.get("message") or body.get("detail") or body)
45
+ return str(body)
46
+
47
+
48
+ @storage.command(
49
+ name="show",
50
+ help="Show which bucket this workspace uses.",
51
+ epilog="""
52
+ Examples:
53
+
54
+ {cli_name} workspace storage show
55
+ {cli_name} workspace storage show --format json
56
+ \b
57
+ """,
58
+ )
59
+ @click.option("--format", type=click.Choice(("table", "json")), default="table", show_default=True)
60
+ def show(format: str):
61
+ client = _client()
62
+ info = _get(client, f"/api/v1/workspace/{client.workspace_id}/storage")
63
+
64
+ if format == "json":
65
+ terminal.print_json(info)
66
+ return
67
+
68
+ if not info.get("configured"):
69
+ terminal.warn("This workspace has no storage of its own.")
70
+ terminal.print(
71
+ "Checkpoints need it, and objects fall back to shared storage without it.\n"
72
+ "Attach your own bucket with: levers9 workspace storage set --help"
73
+ )
74
+ return
75
+
76
+ table = Table(Column("Field"), Column("Value"), box=box.SIMPLE)
77
+ for label, key in (
78
+ ("Bucket", "bucket_name"),
79
+ ("Endpoint", "endpoint_url"),
80
+ ("Region", "region"),
81
+ ("Access key", "access_key"),
82
+ ("Created", "created_at"),
83
+ ):
84
+ if info.get(key):
85
+ table.add_row(label, str(info[key]))
86
+ terminal.print(table)
87
+
88
+
89
+ @storage.command(
90
+ name="set",
91
+ help="Point this workspace at an S3-compatible bucket you own.",
92
+ epilog="""
93
+ The credentials are checked against the bucket before anything is saved: it must exist and accept a
94
+ write. They are then stored encrypted and handed to workers to mount, so scope them to this bucket.
95
+
96
+ Examples:
97
+
98
+ # AWS S3
99
+ {cli_name} workspace storage set --bucket my-bucket --region us-east-2 \\
100
+ --endpoint-url https://s3.us-east-2.amazonaws.com \\
101
+ --access-key AKIA... --secret-key ...
102
+
103
+ # Google Cloud Storage, via its S3-compatible API. Use an HMAC key
104
+ # (Cloud Storage -> Settings -> Interoperability), not a service account JSON.
105
+ {cli_name} workspace storage set --bucket my-bucket --region us-central1 \\
106
+ --endpoint-url https://storage.googleapis.com \\
107
+ --access-key GOOG1E... --secret-key ...
108
+
109
+ # Cloudflare R2, MinIO, or anything else that speaks S3
110
+ {cli_name} workspace storage set --bucket my-bucket --region auto \\
111
+ --endpoint-url https://<account>.r2.cloudflarestorage.com \\
112
+ --access-key ... --secret-key ...
113
+ \b
114
+ """,
115
+ )
116
+ @click.option("--bucket", required=True, help="Bucket name. It must already exist.")
117
+ @click.option("--endpoint-url", required=True, help="e.g. https://s3.us-east-2.amazonaws.com")
118
+ @click.option("--region", required=True, help="e.g. us-east-2, or auto for R2")
119
+ @click.option(
120
+ "--access-key", required=True, help="For GCS this is an HMAC key id, not a service account."
121
+ )
122
+ @click.option("--secret-key", required=True, help="Stored encrypted.")
123
+ @click.option(
124
+ "--replace",
125
+ is_flag=True,
126
+ help="Replace the bucket this workspace already uses. Nothing is copied: whatever is in the old bucket stops being visible.",
127
+ )
128
+ def set_storage(
129
+ bucket: str, endpoint_url: str, region: str, access_key: str, secret_key: str, replace: bool
130
+ ):
131
+ client = _client()
132
+
133
+ if (
134
+ replace
135
+ and not terminal.prompt(
136
+ text=f"Point this workspace at {bucket}? Data in its current bucket will not be moved and will stop being visible. Type the bucket name to confirm",
137
+ default="",
138
+ )
139
+ == bucket
140
+ ):
141
+ terminal.error("Cancelled: the bucket name did not match.")
142
+
143
+ r = rest_post(
144
+ token=client.token,
145
+ url=client.base_url,
146
+ path=f"/api/v1/workspace/{client.workspace_id}/set-external-storage",
147
+ data={
148
+ "bucket_name": bucket,
149
+ "endpoint_url": endpoint_url,
150
+ "region": region,
151
+ "access_key": access_key,
152
+ "secret_key": secret_key,
153
+ "replace": replace,
154
+ },
155
+ )
156
+ if r.status_code >= 400:
157
+ terminal.error(f"{r.status_code}: {_message(r)}")
158
+
159
+ terminal.success(f"This workspace now stores its data in {bucket}.")
160
+ terminal.print("Existing containers keep their current mount until they restart.")