gridweave-sdk 0.5.5__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.
- gridweave_sdk-0.5.5/PKG-INFO +140 -0
- gridweave_sdk-0.5.5/README.md +122 -0
- gridweave_sdk-0.5.5/__init__.py +36 -0
- gridweave_sdk-0.5.5/__main__.py +8 -0
- gridweave_sdk-0.5.5/_http.py +31 -0
- gridweave_sdk-0.5.5/_validation.py +270 -0
- gridweave_sdk-0.5.5/auth.py +80 -0
- gridweave_sdk-0.5.5/chain.py +29 -0
- gridweave_sdk-0.5.5/execution.py +230 -0
- gridweave_sdk-0.5.5/gridweave_sdk.egg-info/PKG-INFO +140 -0
- gridweave_sdk-0.5.5/gridweave_sdk.egg-info/SOURCES.txt +35 -0
- gridweave_sdk-0.5.5/gridweave_sdk.egg-info/dependency_links.txt +1 -0
- gridweave_sdk-0.5.5/gridweave_sdk.egg-info/requires.txt +2 -0
- gridweave_sdk-0.5.5/gridweave_sdk.egg-info/top_level.txt +1 -0
- gridweave_sdk-0.5.5/images.py +168 -0
- gridweave_sdk-0.5.5/pyproject.toml +33 -0
- gridweave_sdk-0.5.5/registry.py +115 -0
- gridweave_sdk-0.5.5/remote.py +56 -0
- gridweave_sdk-0.5.5/resources.py +13 -0
- gridweave_sdk-0.5.5/serve.py +462 -0
- gridweave_sdk-0.5.5/setup.cfg +4 -0
- gridweave_sdk-0.5.5/storage.py +63 -0
- gridweave_sdk-0.5.5/train.py +25 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gridweave-sdk
|
|
3
|
+
Version: 0.5.5
|
|
4
|
+
Summary: Run Python functions and serve models on a GridWeave GPU cluster
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Project-URL: Homepage, https://gridweave.io
|
|
7
|
+
Project-URL: Documentation, https://docs.gridweave.io
|
|
8
|
+
Keywords: gpu,cluster,vllm,inference,compute
|
|
9
|
+
Classifier: License :: Other/Proprietary License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: ==3.12.*
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: httpx>=0.28
|
|
17
|
+
Requires-Dist: cloudpickle>=3.0
|
|
18
|
+
|
|
19
|
+
# gridweave-sdk
|
|
20
|
+
|
|
21
|
+
One Python API for a **heterogeneous GPU cluster** — mix NVIDIA and AMD, size GPUs by the gigabyte, run fractional or multi-GPU, and deploy models you can charge others to call. Works from a script, a notebook, or the REPL.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# First run — the tarball also bundles the onboarding notebook:
|
|
27
|
+
curl -sL https://pub-c48a651bbb2f42988602aa11bb9d9267.r2.dev/tarball/gridweave-sdk.tar.gz | tar xz
|
|
28
|
+
cd gridweave-sdk && python3.12 -m venv .venv && source .venv/bin/activate
|
|
29
|
+
pip install gridweave_sdk-*.whl jupyterlab
|
|
30
|
+
jupyter lab onboarding.ipynb
|
|
31
|
+
|
|
32
|
+
# Or into an existing env:
|
|
33
|
+
V=$(curl -s https://pub-c48a651bbb2f42988602aa11bb9d9267.r2.dev/tarball/sdk-latest-version.txt)
|
|
34
|
+
pip install https://pub-c48a651bbb2f42988602aa11bb9d9267.r2.dev/tarball/gridweave_sdk-${V}-py3-none-any.whl
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Python 3.12 required** — functions are shipped to the workers with cloudpickle, which won't unpickle across versions.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import gridweave
|
|
41
|
+
gridweave.auth("YOUR_TOKEN", platform_url="https://platform.gridweave.io")
|
|
42
|
+
gridweave.resources() # nodes, vendors, and free VRAM
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Run anything, on exactly the hardware you want
|
|
46
|
+
|
|
47
|
+
Decorate a function, `run()` it — it executes on a worker, streams its stdout back, and returns what it returned. **`vram` is how you ask for GPUs:**
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
@gridweave.remote() # no vram → CPU
|
|
51
|
+
def hello(name):
|
|
52
|
+
return f"hi {name}"
|
|
53
|
+
|
|
54
|
+
@gridweave.remote(vram="4GB") # a GPU with ≥4 GB free
|
|
55
|
+
def matmul():
|
|
56
|
+
import torch
|
|
57
|
+
x = torch.randn(4096, 4096, device="cuda")
|
|
58
|
+
return (x @ x).mean().item()
|
|
59
|
+
|
|
60
|
+
gridweave.run(matmul)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The cluster is a single pool of mixed hardware, and the same knobs pin any of it:
|
|
64
|
+
|
|
65
|
+
| Arg | Effect |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `vram="24GB"` | a GPU with ≥24 GB free — **omit for CPU**; ask for more than one GPU has → **multi-GPU** (auto `CUDA_VISIBLE_DEVICES`); two small jobs → **share a GPU** |
|
|
68
|
+
| `vendor="amd"` / `"nvidia"` | pin the GPU brand (default: auto) |
|
|
69
|
+
| `node="hostname"` | pin one machine (from `resources()`) |
|
|
70
|
+
| `paid=True` | run on **provider** hardware (paid credits) vs. the free org pool |
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
@gridweave.remote(vram="16GB", vendor="amd") # a 16 GB AMD GPU
|
|
74
|
+
def on_amd(): ...
|
|
75
|
+
|
|
76
|
+
@gridweave.remote(vram="100GB") # spans several GPUs automatically
|
|
77
|
+
def big(): ...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Serve a model — and get paid for it
|
|
81
|
+
|
|
82
|
+
Deploy a model behind an endpoint. vLLM models get an OpenAI-style chat interface; any HTTP container works via `spec=`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
ep = gridweave.serve(model="Qwen/Qwen2.5-0.5B", vram="4GB", name="qwen")
|
|
86
|
+
ep.chat("Explain quantum computing in one sentence.")
|
|
87
|
+
|
|
88
|
+
ep = gridweave.serve(spec={"image": "kennethreitz/httpbin", "port": 80,
|
|
89
|
+
"actions": {"echo": {"method": "POST", "path": "/post"}}},
|
|
90
|
+
name="httpbin")
|
|
91
|
+
ep.call("echo", data={"hello": "cluster"}).json()
|
|
92
|
+
|
|
93
|
+
gridweave.endpoints(); gridweave.stop("qwen"); gridweave.start("qwen"); gridweave.delete("qwen")
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Make it **public and paid** and you run a tiny inference business: callers pay your price (minus a small platform fee), you pay the GPU rental while it's up, and you keep the spread — on your own hardware you keep both sides.
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
gridweave.serve(model="Qwen/Qwen2.5-0.5B", vram="4GB", name="qwen-paid",
|
|
100
|
+
paid=True, public=True, price_per_call=0.01) # or price_per_1m_input/output
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Every vLLM endpoint is also on the **OpenAI-compatible API** (model id `{username}/{name}`), so Open WebUI, curl, or the OpenAI SDK work with no gridweave install:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
curl -H "Authorization: Bearer $TOKEN" \
|
|
107
|
+
-d '{"model":"you/qwen","messages":[{"role":"user","content":"hi"}]}' \
|
|
108
|
+
https://platform.gridweave.io/v1/chat/completions
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Distributed training
|
|
112
|
+
|
|
113
|
+
`@gridweave.train(gpus=N)` runs your function once per rank with PyTorch Distributed (NCCL/RCCL) already wired up — use `Trainer`/DDP as normal:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
@gridweave.train(gpus=2)
|
|
117
|
+
def finetune():
|
|
118
|
+
... # build model + Trainer, then trainer.train()
|
|
119
|
+
import os; return {"rank": int(os.environ.get("RANK", 0))}
|
|
120
|
+
|
|
121
|
+
gridweave.run(finetune)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Async, parallel, files, audit trail
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
h = gridweave.submit(matmul) # non-blocking
|
|
128
|
+
gridweave.status(h); gridweave.get(h) # poll / block-for-result
|
|
129
|
+
gridweave.gather([gridweave.submit(matmul) for _ in range(10)]) # 10 at once
|
|
130
|
+
|
|
131
|
+
uri = gridweave.upload("data.csv"); gridweave.download(uri, "data.csv") # cluster S3
|
|
132
|
+
gridweave.chain(limit=10) # your on-chain audit events
|
|
133
|
+
gridweave.chain_rpc("status") # or verify the ledger yourself — any read-only query
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Learn by doing
|
|
137
|
+
|
|
138
|
+
`onboarding.ipynb` (in the tarball) runs all of it against a live cluster — CPU / GPU / fractional / multi-GPU jobs, GPT-2 fine-tuning, vLLM + gated + S3 + custom-image serving, the OpenAI API, Open WebUI, paid endpoints, and the audit chain. Each cell is independently re-runnable.
|
|
139
|
+
|
|
140
|
+
The SDK sends its version on every call; on a **major** mismatch the call is rejected with an "SDK UPDATE REQUIRED" message and the exact `pip install` to run.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# gridweave-sdk
|
|
2
|
+
|
|
3
|
+
One Python API for a **heterogeneous GPU cluster** — mix NVIDIA and AMD, size GPUs by the gigabyte, run fractional or multi-GPU, and deploy models you can charge others to call. Works from a script, a notebook, or the REPL.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# First run — the tarball also bundles the onboarding notebook:
|
|
9
|
+
curl -sL https://pub-c48a651bbb2f42988602aa11bb9d9267.r2.dev/tarball/gridweave-sdk.tar.gz | tar xz
|
|
10
|
+
cd gridweave-sdk && python3.12 -m venv .venv && source .venv/bin/activate
|
|
11
|
+
pip install gridweave_sdk-*.whl jupyterlab
|
|
12
|
+
jupyter lab onboarding.ipynb
|
|
13
|
+
|
|
14
|
+
# Or into an existing env:
|
|
15
|
+
V=$(curl -s https://pub-c48a651bbb2f42988602aa11bb9d9267.r2.dev/tarball/sdk-latest-version.txt)
|
|
16
|
+
pip install https://pub-c48a651bbb2f42988602aa11bb9d9267.r2.dev/tarball/gridweave_sdk-${V}-py3-none-any.whl
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Python 3.12 required** — functions are shipped to the workers with cloudpickle, which won't unpickle across versions.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import gridweave
|
|
23
|
+
gridweave.auth("YOUR_TOKEN", platform_url="https://platform.gridweave.io")
|
|
24
|
+
gridweave.resources() # nodes, vendors, and free VRAM
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Run anything, on exactly the hardware you want
|
|
28
|
+
|
|
29
|
+
Decorate a function, `run()` it — it executes on a worker, streams its stdout back, and returns what it returned. **`vram` is how you ask for GPUs:**
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
@gridweave.remote() # no vram → CPU
|
|
33
|
+
def hello(name):
|
|
34
|
+
return f"hi {name}"
|
|
35
|
+
|
|
36
|
+
@gridweave.remote(vram="4GB") # a GPU with ≥4 GB free
|
|
37
|
+
def matmul():
|
|
38
|
+
import torch
|
|
39
|
+
x = torch.randn(4096, 4096, device="cuda")
|
|
40
|
+
return (x @ x).mean().item()
|
|
41
|
+
|
|
42
|
+
gridweave.run(matmul)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The cluster is a single pool of mixed hardware, and the same knobs pin any of it:
|
|
46
|
+
|
|
47
|
+
| Arg | Effect |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `vram="24GB"` | a GPU with ≥24 GB free — **omit for CPU**; ask for more than one GPU has → **multi-GPU** (auto `CUDA_VISIBLE_DEVICES`); two small jobs → **share a GPU** |
|
|
50
|
+
| `vendor="amd"` / `"nvidia"` | pin the GPU brand (default: auto) |
|
|
51
|
+
| `node="hostname"` | pin one machine (from `resources()`) |
|
|
52
|
+
| `paid=True` | run on **provider** hardware (paid credits) vs. the free org pool |
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
@gridweave.remote(vram="16GB", vendor="amd") # a 16 GB AMD GPU
|
|
56
|
+
def on_amd(): ...
|
|
57
|
+
|
|
58
|
+
@gridweave.remote(vram="100GB") # spans several GPUs automatically
|
|
59
|
+
def big(): ...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Serve a model — and get paid for it
|
|
63
|
+
|
|
64
|
+
Deploy a model behind an endpoint. vLLM models get an OpenAI-style chat interface; any HTTP container works via `spec=`:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
ep = gridweave.serve(model="Qwen/Qwen2.5-0.5B", vram="4GB", name="qwen")
|
|
68
|
+
ep.chat("Explain quantum computing in one sentence.")
|
|
69
|
+
|
|
70
|
+
ep = gridweave.serve(spec={"image": "kennethreitz/httpbin", "port": 80,
|
|
71
|
+
"actions": {"echo": {"method": "POST", "path": "/post"}}},
|
|
72
|
+
name="httpbin")
|
|
73
|
+
ep.call("echo", data={"hello": "cluster"}).json()
|
|
74
|
+
|
|
75
|
+
gridweave.endpoints(); gridweave.stop("qwen"); gridweave.start("qwen"); gridweave.delete("qwen")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Make it **public and paid** and you run a tiny inference business: callers pay your price (minus a small platform fee), you pay the GPU rental while it's up, and you keep the spread — on your own hardware you keep both sides.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
gridweave.serve(model="Qwen/Qwen2.5-0.5B", vram="4GB", name="qwen-paid",
|
|
82
|
+
paid=True, public=True, price_per_call=0.01) # or price_per_1m_input/output
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Every vLLM endpoint is also on the **OpenAI-compatible API** (model id `{username}/{name}`), so Open WebUI, curl, or the OpenAI SDK work with no gridweave install:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
curl -H "Authorization: Bearer $TOKEN" \
|
|
89
|
+
-d '{"model":"you/qwen","messages":[{"role":"user","content":"hi"}]}' \
|
|
90
|
+
https://platform.gridweave.io/v1/chat/completions
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Distributed training
|
|
94
|
+
|
|
95
|
+
`@gridweave.train(gpus=N)` runs your function once per rank with PyTorch Distributed (NCCL/RCCL) already wired up — use `Trainer`/DDP as normal:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
@gridweave.train(gpus=2)
|
|
99
|
+
def finetune():
|
|
100
|
+
... # build model + Trainer, then trainer.train()
|
|
101
|
+
import os; return {"rank": int(os.environ.get("RANK", 0))}
|
|
102
|
+
|
|
103
|
+
gridweave.run(finetune)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Async, parallel, files, audit trail
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
h = gridweave.submit(matmul) # non-blocking
|
|
110
|
+
gridweave.status(h); gridweave.get(h) # poll / block-for-result
|
|
111
|
+
gridweave.gather([gridweave.submit(matmul) for _ in range(10)]) # 10 at once
|
|
112
|
+
|
|
113
|
+
uri = gridweave.upload("data.csv"); gridweave.download(uri, "data.csv") # cluster S3
|
|
114
|
+
gridweave.chain(limit=10) # your on-chain audit events
|
|
115
|
+
gridweave.chain_rpc("status") # or verify the ledger yourself — any read-only query
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Learn by doing
|
|
119
|
+
|
|
120
|
+
`onboarding.ipynb` (in the tarball) runs all of it against a live cluster — CPU / GPU / fractional / multi-GPU jobs, GPT-2 fine-tuning, vLLM + gated + S3 + custom-image serving, the OpenAI API, Open WebUI, paid endpoints, and the audit chain. Each cell is independently re-runnable.
|
|
121
|
+
|
|
122
|
+
The SDK sends its version on every call; on a **major** mismatch the call is rejected with an "SDK UPDATE REQUIRED" message and the exact `pip install` to run.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""gridweave — client SDK for submitting jobs to the compute cluster."""
|
|
2
|
+
import logging as _logging
|
|
3
|
+
from pathlib import Path as _Path
|
|
4
|
+
|
|
5
|
+
_logging.getLogger("gridweave").addHandler(_logging.NullHandler())
|
|
6
|
+
|
|
7
|
+
_version_file = _Path(__file__).resolve().parent.parent / "VERSION_SDK"
|
|
8
|
+
if _version_file.exists():
|
|
9
|
+
__version__ = _version_file.read_text().strip()
|
|
10
|
+
else:
|
|
11
|
+
try:
|
|
12
|
+
from importlib.metadata import version as _pkg_version
|
|
13
|
+
__version__ = _pkg_version("gridweave-sdk")
|
|
14
|
+
except Exception:
|
|
15
|
+
__version__ = "0.0.0"
|
|
16
|
+
|
|
17
|
+
from .auth import auth
|
|
18
|
+
from .remote import remote
|
|
19
|
+
from .train import train
|
|
20
|
+
from .execution import run, submit, get, gather, logs, status, cancel
|
|
21
|
+
from .images import build_env, run_image, submit_image
|
|
22
|
+
from .storage import upload, download, list_files
|
|
23
|
+
from .resources import resources
|
|
24
|
+
from .serve import serve, endpoint, endpoints, stop, start, delete
|
|
25
|
+
from .chain import chain, chain_rpc
|
|
26
|
+
from .registry import registry_login, registry_status, registry_logout, push_image, delete_image
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"auth", "remote", "train", "run", "submit", "get", "gather", "logs", "status",
|
|
30
|
+
"cancel", "run_image", "submit_image", "build_env",
|
|
31
|
+
"upload", "download", "list_files", "resources",
|
|
32
|
+
"serve", "endpoint", "endpoints", "stop", "start", "delete",
|
|
33
|
+
"chain", "chain_rpc",
|
|
34
|
+
"registry_login", "registry_status", "registry_logout", "push_image",
|
|
35
|
+
"delete_image",
|
|
36
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Thin wrappers over the shared pooled client (auth.client()).
|
|
2
|
+
|
|
3
|
+
SDK modules import this as `http` — one connection pool for every call,
|
|
4
|
+
with connect-level retries, instead of a fresh TCP+TLS handshake per
|
|
5
|
+
request."""
|
|
6
|
+
|
|
7
|
+
from .auth import client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get(url, **kwargs):
|
|
11
|
+
return client().get(url, **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def post(url, **kwargs):
|
|
15
|
+
return client().post(url, **kwargs)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def put(url, **kwargs):
|
|
19
|
+
return client().put(url, **kwargs)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def delete(url, **kwargs):
|
|
23
|
+
return client().delete(url, **kwargs)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def head(url, **kwargs):
|
|
27
|
+
return client().head(url, **kwargs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stream(method, url, **kwargs):
|
|
31
|
+
return client().stream(method, url, **kwargs)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Client-side input validation — fail fast with actionable messages
|
|
2
|
+
instead of late, opaque server-side errors."""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
MAX_PAYLOAD_BYTES = int(os.environ.get("GRIDWEAVE_MAX_PAYLOAD_BYTES",
|
|
8
|
+
str(10 * 1024 * 1024)))
|
|
9
|
+
|
|
10
|
+
_VRAM_RE = re.compile(r"^\d+(\.\d+)?\s*(GB|MB)?$", re.IGNORECASE)
|
|
11
|
+
_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
|
|
12
|
+
_MODEL_RE = re.compile(r"^[\w.\-]+/[\w.\-]+$")
|
|
13
|
+
VALID_VENDORS = {"", "auto", "nvidia", "amd"}
|
|
14
|
+
MAX_VRAM_GB = 2048
|
|
15
|
+
MAX_GPUS = 32
|
|
16
|
+
MAX_CPUS = 1024
|
|
17
|
+
MAX_RAM_GB = 4096
|
|
18
|
+
# Package specs for per-job environments: torch==2.4, pkg[extra]>=1,
|
|
19
|
+
# conda-forge::ffmpeg. No shell metacharacters (;|&$`…) by construction —
|
|
20
|
+
# these specs end up on an installer command line.
|
|
21
|
+
_PKG_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-\[\],=<>!~ :]*$")
|
|
22
|
+
MAX_ENV_PKGS = 100
|
|
23
|
+
MAX_PKG_LEN = 200
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_vram(vram: str) -> str:
|
|
27
|
+
"""Accepts '24GB', '500MB', or a bare number of GB. Returns it stripped."""
|
|
28
|
+
if not vram:
|
|
29
|
+
return ""
|
|
30
|
+
v = str(vram).strip()
|
|
31
|
+
if not _VRAM_RE.match(v):
|
|
32
|
+
raise ValueError(
|
|
33
|
+
f"Invalid vram {vram!r}: expected a number with GB/MB suffix, e.g. '24GB'")
|
|
34
|
+
digits = re.sub(r"(?i)\s*(GB|MB)$", "", v)
|
|
35
|
+
gb = float(digits) / 1024 if v.upper().endswith("MB") else float(digits)
|
|
36
|
+
if not 0 < gb <= MAX_VRAM_GB:
|
|
37
|
+
raise ValueError(f"Invalid vram {vram!r}: must be between 0 and {MAX_VRAM_GB}GB")
|
|
38
|
+
return v
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_gpus(gpus: int) -> int:
|
|
42
|
+
if not isinstance(gpus, int) or isinstance(gpus, bool) or not 1 <= gpus <= MAX_GPUS:
|
|
43
|
+
raise ValueError(f"Invalid gpus {gpus!r}: must be an integer between 1 and {MAX_GPUS}")
|
|
44
|
+
return gpus
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_cpu(cpu: int) -> int:
|
|
48
|
+
"""Host CPUs to book for a no-vram (CPU-lane) job."""
|
|
49
|
+
if not isinstance(cpu, int) or isinstance(cpu, bool) or not 1 <= cpu <= MAX_CPUS:
|
|
50
|
+
raise ValueError(f"Invalid cpu {cpu!r}: must be an integer between 1 and {MAX_CPUS}")
|
|
51
|
+
return cpu
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_pkg_list(pkgs, kind: str = "pip") -> list:
|
|
55
|
+
"""Package list for a per-job environment (pip= / conda=)."""
|
|
56
|
+
if not pkgs:
|
|
57
|
+
return []
|
|
58
|
+
if not isinstance(pkgs, (list, tuple)):
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Invalid {kind} {pkgs!r}: expected a list of package specs, "
|
|
61
|
+
f"e.g. {kind}=[\"pandas>=2\", \"pyarrow\"]")
|
|
62
|
+
if len(pkgs) > MAX_ENV_PKGS:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"Invalid {kind}: {len(pkgs)} packages exceeds the maximum of "
|
|
65
|
+
f"{MAX_ENV_PKGS} — bake large environments into an image instead")
|
|
66
|
+
for p in pkgs:
|
|
67
|
+
if not isinstance(p, str) or not p.strip():
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"Invalid {kind} entry {p!r}: expected a non-empty string")
|
|
70
|
+
if len(p) > MAX_PKG_LEN or not _PKG_RE.match(p):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Invalid {kind} entry {p!r}: not a valid package spec "
|
|
73
|
+
f"(letters, digits, ._-[],=<>!~: only)")
|
|
74
|
+
return list(pkgs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_ram(ram: str) -> str:
|
|
78
|
+
"""Host RAM to book for a no-vram (CPU-lane) job.
|
|
79
|
+
Accepts '8GB', '512MB', or a bare number of GB. Returns it stripped."""
|
|
80
|
+
if not ram:
|
|
81
|
+
return ""
|
|
82
|
+
v = str(ram).strip()
|
|
83
|
+
if not _VRAM_RE.match(v):
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Invalid ram {ram!r}: expected a number with GB/MB suffix, e.g. '8GB'")
|
|
86
|
+
digits = re.sub(r"(?i)\s*(GB|MB)$", "", v)
|
|
87
|
+
gb = float(digits) / 1024 if v.upper().endswith("MB") else float(digits)
|
|
88
|
+
if not 0 < gb <= MAX_RAM_GB:
|
|
89
|
+
raise ValueError(f"Invalid ram {ram!r}: must be between 0 and {MAX_RAM_GB}GB")
|
|
90
|
+
return v
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def normalize_vendor(vendor: str) -> str:
|
|
94
|
+
v = (vendor or "").strip().lower()
|
|
95
|
+
if v not in VALID_VENDORS:
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Invalid vendor {vendor!r}: must be one of 'nvidia', 'amd', 'auto', or ''")
|
|
98
|
+
return "" if v == "auto" else v
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_endpoint_name(name: str) -> str:
|
|
102
|
+
"""Endpoint names become k8s labels: ≤63 chars of [a-z0-9-], starting
|
|
103
|
+
and ending alphanumeric."""
|
|
104
|
+
if not name or len(name) > 63 or not _NAME_RE.match(name):
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Invalid endpoint name {name!r}: up to 63 lowercase letters, "
|
|
107
|
+
f"digits, or hyphens, starting and ending with a letter or digit")
|
|
108
|
+
return name
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def validate_model(model: str) -> str:
|
|
112
|
+
"""A vLLM model source: either a HuggingFace 'owner/repo' name or an
|
|
113
|
+
s3:// URI to model weights the gateway downloads at deploy time."""
|
|
114
|
+
m = model or ""
|
|
115
|
+
if m.startswith("s3://"):
|
|
116
|
+
return validate_s3_uri(m)
|
|
117
|
+
if not _MODEL_RE.match(m):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Invalid model {model!r}: expected HuggingFace 'owner/repo' format "
|
|
120
|
+
f"or an s3:// model URI")
|
|
121
|
+
return model
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def check_payload_size(num_bytes: int, what: str = "payload") -> None:
|
|
125
|
+
if num_bytes > MAX_PAYLOAD_BYTES:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"{what} is {num_bytes / 1024 / 1024:.1f}MB — exceeds the "
|
|
128
|
+
f"{MAX_PAYLOAD_BYTES / 1024 / 1024:.0f}MB limit. Upload large data "
|
|
129
|
+
f"with gridweave.storage.upload() and load it inside the function "
|
|
130
|
+
f"(set GRIDWEAVE_MAX_PAYLOAD_BYTES to override).")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_job_id(job_id: str) -> str:
|
|
134
|
+
if not job_id or not str(job_id).strip():
|
|
135
|
+
raise ValueError("Empty job_id — the handle is not a valid job reference")
|
|
136
|
+
return job_id
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def validate_s3_uri(uri: str) -> str:
|
|
140
|
+
if not (uri or "").startswith("s3://"):
|
|
141
|
+
raise ValueError(f"Invalid storage uri {uri!r}: must start with 's3://'")
|
|
142
|
+
return uri
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# Characters that would let a spec.image string break out into the shell or
|
|
146
|
+
# carry whitespace a container reference can never contain. A valid image ref
|
|
147
|
+
# is just registry/name:tag@digest — none of these belong in it.
|
|
148
|
+
_IMAGE_BAD_CHARS = ";|&$`<>()\\'\" \t\n\r"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def validate_image(image: str) -> str:
|
|
152
|
+
"""Container image reference for a custom spec, e.g. 'org/name:tag'.
|
|
153
|
+
Rejected fast so a typo or injection never reaches the deploy (where it
|
|
154
|
+
would otherwise surface late as an ImagePullBackOff)."""
|
|
155
|
+
if not image or not str(image).strip():
|
|
156
|
+
raise ValueError(
|
|
157
|
+
"Invalid spec.image: a container image is required, e.g. 'org/name:tag'")
|
|
158
|
+
img = str(image).strip()
|
|
159
|
+
bad = "".join(sorted({c for c in img if c in _IMAGE_BAD_CHARS}))
|
|
160
|
+
if bad:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"Invalid spec.image {image!r}: contains disallowed character(s) {bad!r}. "
|
|
163
|
+
f"Use a plain image reference like 'org/name:tag' "
|
|
164
|
+
f"(no spaces or shell metacharacters)")
|
|
165
|
+
return img
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def validate_port(port: int) -> int:
|
|
169
|
+
"""TCP port the user's container listens on (HTTP-mode spec)."""
|
|
170
|
+
if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"Invalid spec.port {port!r}: must be an integer between 1 and 65535")
|
|
173
|
+
return port
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_spec(spec: dict) -> dict:
|
|
177
|
+
"""Mirror the server's custom-image deploy contract so an unrunnable spec
|
|
178
|
+
fails here — instantly, with the reason — instead of as a late pod or
|
|
179
|
+
request-time error. Server requires: 'image', plus either 'port' (HTTP
|
|
180
|
+
mode) or 'actions' (command mode); command-mode actions need a 'command'."""
|
|
181
|
+
if not isinstance(spec, dict):
|
|
182
|
+
raise ValueError(
|
|
183
|
+
f"Invalid spec: expected a dict (or path to a JSON file), "
|
|
184
|
+
f"got {type(spec).__name__}")
|
|
185
|
+
|
|
186
|
+
validate_image(spec.get("image", ""))
|
|
187
|
+
|
|
188
|
+
has_port = "port" in spec
|
|
189
|
+
has_actions = bool(spec.get("actions"))
|
|
190
|
+
if not has_port and not has_actions:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"Invalid spec: must include either 'port' (your container's HTTP "
|
|
193
|
+
"port) or 'actions' (command-mode handlers)")
|
|
194
|
+
if has_port:
|
|
195
|
+
validate_port(spec["port"])
|
|
196
|
+
if "gpus" in spec:
|
|
197
|
+
validate_gpus(spec["gpus"])
|
|
198
|
+
if "ram" in spec:
|
|
199
|
+
validate_ram(spec["ram"]) # becomes the pod's memory limit
|
|
200
|
+
|
|
201
|
+
actions = spec.get("actions") or {}
|
|
202
|
+
if actions and not isinstance(actions, dict):
|
|
203
|
+
raise ValueError(
|
|
204
|
+
f"Invalid spec.actions: expected a dict of name -> action, "
|
|
205
|
+
f"got {type(actions).__name__}")
|
|
206
|
+
for aname, action in actions.items():
|
|
207
|
+
if not isinstance(action, dict):
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"Invalid spec.actions[{aname!r}]: expected a dict, "
|
|
210
|
+
f"got {type(action).__name__}")
|
|
211
|
+
# In command mode (no port) every action runs a shell command; an
|
|
212
|
+
# empty command would deploy fine then fail at call time with a 500.
|
|
213
|
+
if not has_port and not str(action.get("command", "")).strip():
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"Invalid spec.actions[{aname!r}]: command-mode actions require "
|
|
216
|
+
f"a non-empty 'command', e.g. 'python /app/run.py'")
|
|
217
|
+
return spec
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def validate_action(action: str) -> str:
|
|
221
|
+
"""Named action on a custom endpoint — must be non-empty, else the call
|
|
222
|
+
hits /actions/ and 404s late."""
|
|
223
|
+
if not action or not str(action).strip():
|
|
224
|
+
raise ValueError("Empty action name — pass the action to call, e.g. ep.call('fold', ...)")
|
|
225
|
+
return action
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def validate_local_file(local_path: str) -> str:
|
|
229
|
+
"""A readable local file must exist before we try to upload it — fail
|
|
230
|
+
here with a clear message instead of a late open() error mid-call."""
|
|
231
|
+
if not local_path or not str(local_path).strip():
|
|
232
|
+
raise ValueError("upload() requires a local file path")
|
|
233
|
+
if not os.path.isfile(local_path):
|
|
234
|
+
raise ValueError(f"No such file to upload: {local_path!r}")
|
|
235
|
+
return local_path
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _check_price(label: str, value) -> None:
|
|
239
|
+
if value is None:
|
|
240
|
+
return
|
|
241
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
|
|
242
|
+
raise ValueError(f"Invalid {label} {value!r}: must be a number >= 0")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def validate_pricing(paid: bool, model: str,
|
|
246
|
+
price_per_call=None,
|
|
247
|
+
price_per_1m_input=None,
|
|
248
|
+
price_per_1m_output=None) -> None:
|
|
249
|
+
"""Validate deployer-set endpoint call pricing for serve(). Pricing applies
|
|
250
|
+
only to paid endpoints; per-call and per-token are mutually exclusive; and
|
|
251
|
+
per-token pricing is vLLM-only (custom images have no token concept)."""
|
|
252
|
+
per_call = price_per_call is not None
|
|
253
|
+
per_token = price_per_1m_input is not None or price_per_1m_output is not None
|
|
254
|
+
if not per_call and not per_token:
|
|
255
|
+
return
|
|
256
|
+
if not paid:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
"Endpoint pricing applies only to paid endpoints — pass paid=True "
|
|
259
|
+
"(free/org endpoints are auto-priced)")
|
|
260
|
+
if per_call and per_token:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
"Set either price_per_call or per-token pricing "
|
|
263
|
+
"(price_per_1m_input / price_per_1m_output), not both")
|
|
264
|
+
if per_token and not model:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"Per-token pricing (price_per_1m_*) is vLLM-only — set model=..., "
|
|
267
|
+
"or use price_per_call for a custom-image endpoint")
|
|
268
|
+
_check_price("price_per_call", price_per_call)
|
|
269
|
+
_check_price("price_per_1m_input", price_per_1m_input)
|
|
270
|
+
_check_price("price_per_1m_output", price_per_1m_output)
|