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.
@@ -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,8 @@
1
+ """python -m client_sdk — quick version/health check."""
2
+
3
+ from . import __version__
4
+ from .auth import get_platform_url
5
+
6
+ if __name__ == "__main__":
7
+ print(f"gridweave client v{__version__}")
8
+ print(f"platform: {get_platform_url()}")
@@ -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)