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.
- {levers9-0.2.2 → levers9-0.3.0}/PKG-INFO +1 -1
- {levers9-0.2.2 → levers9-0.3.0}/pyproject.toml +1 -1
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/integrations/fastmcp.py +11 -1
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/integrations/vllm.py +170 -27
- levers9-0.3.0/src/levers9/checkpoint_memory.py +128 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/main.py +2 -0
- levers9-0.3.0/src/levers9/cli/workspace.py +160 -0
- levers9-0.3.0/src/levers9/clients/function/__init__.py +140 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/google/api/__init__.py +1 -1
- levers9-0.3.0/src/levers9/clients/image/__init__.py +108 -0
- levers9-0.3.0/src/levers9/clients/pod/__init__.py +597 -0
- levers9-0.3.0/src/levers9/clients/taskqueue/__init__.py +162 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/endpoint.py +8 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-llm-serving/SKILL.md +2 -1
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/PKG-INFO +1 -1
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/SOURCES.txt +9 -0
- levers9-0.3.0/tests/test_checkpoint_memory.py +50 -0
- levers9-0.3.0/tests/test_vllm_engine_args_bridge.py +99 -0
- levers9-0.3.0/tests/test_vllm_served_model_name.py +76 -0
- {levers9-0.2.2 → levers9-0.3.0}/README.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/setup.cfg +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/__main__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/capacity.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/container.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/runner.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/base/utils.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/blueprint.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/box.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/endpoint.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/bot/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/bot/bot.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/bot/marker.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/bot/types.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/experimental/signal.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/integrations/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/job.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/map.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/mixins.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/output.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/queue.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/sandbox.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/service.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/shell.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/abstractions/volume.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/agent/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/agent/server.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/agent/tools.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/aio.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/channel.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/config.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/container.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/database.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/deployment.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/dev.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/disk.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/extraclick.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/llm.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/login.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/machine.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/machine_format.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/mcp.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/pool.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/run.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/secret.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/serve.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/shell.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/skills.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/task.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/token.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/update_check.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/volume.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/worker.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/cli/worker_management.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/client/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/client/client.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/client/deployment.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/client/task.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/blueprint/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/bot/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/box/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/controlplane/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/disk/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/endpoint/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/google/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/job/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/map/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/output/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/queue/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/secret/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/shell/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/signal/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/types/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/clients/volume/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/config.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/env.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/exceptions.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/integrations.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/logging.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/middleware.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/multipart.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/bot/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/bot/transition.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/common.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/container.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/job.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/queue.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/runner/serve.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/schema.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/README.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-blueprints/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-boxes/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-cli/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-crons/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-dashboard/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-data-lab/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-endpoints/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-gpu/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-jobs/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-mcp-server/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-pipelines/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-queues/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-rl-arena/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-sandboxes/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/skills/levers9-volumes-secrets/SKILL.md +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/sync.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/terminal.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/type.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/utils.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/_meta.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/gitignore.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/pathspec.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/pattern.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/patterns/__init__.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/patterns/gitwildmatch.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/py.typed +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/vendor/pathspec/util.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9/whereami.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/dependency_links.txt +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/entry_points.txt +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/requires.txt +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/src/levers9.egg-info/top_level.txt +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_capacity.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_cli.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_client_base_url.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_config.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_container_runner.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_deployment.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_env.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_filesyncer.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_function.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_function_runner.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_image.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_machine_cli.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_map.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_multipart.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_pod_instance.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_pool.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_runner.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_sandbox_convenience.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_sandbox_docker_manager.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_sandbox_log_streaming_integration.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_sandbox_process_stream.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_service.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_shell.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_skills.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_task_queue.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_update_check.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_utils.py +0 -0
- {levers9-0.2.2 → levers9-0.3.0}/tests/test_volumes.py +0 -0
|
@@ -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
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
236
|
-
huggingface_hub_version: str =
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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.")
|