prismnetwork 0.1.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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: prismnetwork
3
+ Version: 0.1.0
4
+ Summary: Headless GPU leasing on Prism Network for autonomous agents. Wallet-signature auth, on-chain USDG payment, SSH.
5
+ Project-URL: Homepage, https://prismnetwork.tech
6
+ Project-URL: Source, https://github.com/prismnetwork-tech/prism
7
+ License-Expression: Apache-2.0
8
+ Keywords: agent,gpu,llm,prism,usdg,web3
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: eth-account>=0.11
11
+ Requires-Dist: requests>=2.31
12
+ Requires-Dist: web3>=6.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # prismnetwork
16
+
17
+ Headless GPU leasing on [Prism Network](https://prismnetwork.tech) for autonomous
18
+ agents — the Python counterpart to `@prismnetwork/agent-sdk`. Give it a wallet; it
19
+ authenticates with a signature, pays on-chain in USDG, provisions a GPU, and runs
20
+ over SSH. No browser, no dashboard.
21
+
22
+ ```sh
23
+ pip install prismnetwork
24
+ ```
25
+
26
+ ```python
27
+ from prismnetwork import PrismAgent, DEFAULT_IMAGE
28
+
29
+ agent = PrismAgent(private_key=AGENT_KEY, escrow="0x71Df0eF3bc81022cB3bec0b1a05f52f12bAfcDeD")
30
+ agent.authenticate()
31
+
32
+ lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
33
+ out = agent.run(lease, "nvidia-smi")
34
+ print(out["stdout"])
35
+ agent.end_lease(lease)
36
+ ```
37
+
38
+ ## What it does
39
+
40
+ `authenticate()` signs a challenge with the wallet and exchanges it for a bearer
41
+ session. `lease()` gets a quote, funds an on-chain USDG escrow bound to the quote
42
+ (`createLease`), waits for the GPU to provision, and returns SSH access. `run()`
43
+ executes a command over SSH, retrying through the host's sshd warmup. Metering is
44
+ per-second; each lease settles on-chain with a verifiable receipt.
45
+
46
+ Read-only helpers: `offers()`, `balances()`, `leases()`, `quote(...)`, `access(id)`.
47
+
48
+ The wallet needs USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) and
49
+ native Robinhood-Chain gas.
50
+
51
+ Requires `ssh` and `ssh-keygen` on `PATH`. Chain id 4663, RPC
52
+ `https://rpc.mainnet.chain.robinhood.com`.
53
+
54
+ Prism is pre-production and unaudited. A permissionless supplier is not a trusted
55
+ computing environment; do not lease with a wallet or workload you cannot lose.
@@ -0,0 +1,41 @@
1
+ # prismnetwork
2
+
3
+ Headless GPU leasing on [Prism Network](https://prismnetwork.tech) for autonomous
4
+ agents — the Python counterpart to `@prismnetwork/agent-sdk`. Give it a wallet; it
5
+ authenticates with a signature, pays on-chain in USDG, provisions a GPU, and runs
6
+ over SSH. No browser, no dashboard.
7
+
8
+ ```sh
9
+ pip install prismnetwork
10
+ ```
11
+
12
+ ```python
13
+ from prismnetwork import PrismAgent, DEFAULT_IMAGE
14
+
15
+ agent = PrismAgent(private_key=AGENT_KEY, escrow="0x71Df0eF3bc81022cB3bec0b1a05f52f12bAfcDeD")
16
+ agent.authenticate()
17
+
18
+ lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
19
+ out = agent.run(lease, "nvidia-smi")
20
+ print(out["stdout"])
21
+ agent.end_lease(lease)
22
+ ```
23
+
24
+ ## What it does
25
+
26
+ `authenticate()` signs a challenge with the wallet and exchanges it for a bearer
27
+ session. `lease()` gets a quote, funds an on-chain USDG escrow bound to the quote
28
+ (`createLease`), waits for the GPU to provision, and returns SSH access. `run()`
29
+ executes a command over SSH, retrying through the host's sshd warmup. Metering is
30
+ per-second; each lease settles on-chain with a verifiable receipt.
31
+
32
+ Read-only helpers: `offers()`, `balances()`, `leases()`, `quote(...)`, `access(id)`.
33
+
34
+ The wallet needs USDG (`0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, 6 decimals) and
35
+ native Robinhood-Chain gas.
36
+
37
+ Requires `ssh` and `ssh-keygen` on `PATH`. Chain id 4663, RPC
38
+ `https://rpc.mainnet.chain.robinhood.com`.
39
+
40
+ Prism is pre-production and unaudited. A permissionless supplier is not a trusted
41
+ computing environment; do not lease with a wallet or workload you cannot lose.
@@ -0,0 +1,46 @@
1
+ """Authenticate a wallet, list GPUs, and optionally lease one and run a command.
2
+
3
+ PRISM_AGENT_KEY=0x<agent wallet private key> \\
4
+ PRISM_ESCROW=0x71Df0eF3bc81022cB3bec0b1a05f52f12bAfcDeD \\
5
+ python quickstart.py
6
+
7
+ By default this authenticates and lists GPUs without spending. Set PRISM_RUN_LEASE=1
8
+ to lease, run nvidia-smi, and release the lease (spends USDG + gas). Prism is
9
+ pre-production and unaudited; do not use funds you cannot lose.
10
+ """
11
+ import os
12
+ import sys
13
+
14
+ from prismnetwork import DEFAULT_IMAGE, PrismAgent
15
+
16
+
17
+ def require(name: str) -> str:
18
+ value = os.environ.get(name)
19
+ if not value:
20
+ sys.exit(f"missing {name}")
21
+ return value
22
+
23
+
24
+ agent = PrismAgent(private_key=require("PRISM_AGENT_KEY"), escrow=require("PRISM_ESCROW"))
25
+
26
+ session = agent.authenticate()
27
+ print("authenticated as", session["subject"])
28
+
29
+ offers = agent.offers()
30
+ if not offers:
31
+ sys.exit("no GPUs online right now — try again shortly.")
32
+ print(f"{len(offers)} offer(s):", ", ".join(o["gpu"]["model"] for o in offers))
33
+
34
+ if os.environ.get("PRISM_RUN_LEASE") != "1":
35
+ print("\nauth OK. Set PRISM_RUN_LEASE=1 to lease + run (spends USDG + gas).")
36
+ sys.exit(0)
37
+
38
+ print("leasing a GPU (provisioning takes a few minutes)...")
39
+ lease = agent.lease(image=DEFAULT_IMAGE, duration_seconds=600, min_vram_mib=16000)
40
+ print("leased", lease.lease_id, "funded on-chain:", lease.funding_hash)
41
+
42
+ result = agent.run(lease, "nvidia-smi --query-gpu=name,memory.total --format=csv,noheader")
43
+ print(f"\nremote output (exit {result['code']}):\n{result['stdout'] or result['stderr']}")
44
+
45
+ agent.end_lease(lease)
46
+ print("lease released. Settlement and a public receipt follow on chain.")
@@ -0,0 +1,4 @@
1
+ from ._agent import DEFAULT_IMAGE, USDG, Lease, PrismAgent, PrismError
2
+
3
+ __all__ = ["PrismAgent", "PrismError", "Lease", "DEFAULT_IMAGE", "USDG"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,271 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import time
8
+ from dataclasses import dataclass
9
+
10
+ import requests
11
+ from eth_account import Account
12
+ from eth_account.messages import encode_defunct
13
+ from web3 import Web3
14
+
15
+ ROBINHOOD_RPC = "https://rpc.mainnet.chain.robinhood.com"
16
+ CHAIN_ID = 4663
17
+ USDG = Web3.to_checksum_address("0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168")
18
+
19
+ # A digest-pinned image, matching the Node SDK so a default can't drift.
20
+ DEFAULT_IMAGE = "docker.io/ollama/ollama@sha256:a61a8fd395dbb931cc8cb1b5da7a2510746575c87113fdc45b647ee59ef7f808"
21
+
22
+ CONFIRMATIONS = 12
23
+ FETCH_TIMEOUT = 30
24
+
25
+ _ERC20 = [
26
+ {"name": "approve", "type": "function", "stateMutability": "nonpayable",
27
+ "inputs": [{"name": "spender", "type": "address"}, {"name": "value", "type": "uint256"}],
28
+ "outputs": [{"type": "bool"}]},
29
+ {"name": "allowance", "type": "function", "stateMutability": "view",
30
+ "inputs": [{"name": "owner", "type": "address"}, {"name": "spender", "type": "address"}],
31
+ "outputs": [{"type": "uint256"}]},
32
+ {"name": "balanceOf", "type": "function", "stateMutability": "view",
33
+ "inputs": [{"name": "owner", "type": "address"}], "outputs": [{"type": "uint256"}]},
34
+ ]
35
+ _ESCROW = [
36
+ {"name": "createLease", "type": "function", "stateMutability": "nonpayable",
37
+ "inputs": [{"name": "nodeId", "type": "bytes32"}, {"name": "duration", "type": "uint32"},
38
+ {"name": "clientReference", "type": "bytes32"}],
39
+ "outputs": [{"type": "uint256"}]},
40
+ ]
41
+
42
+ _DIGEST = re.compile(r"@sha256:[0-9a-f]{64}$")
43
+
44
+
45
+ class PrismError(Exception):
46
+ def __init__(self, status: int, code: str, body=None):
47
+ super().__init__(f"prism {status}: {code}")
48
+ self.status = status
49
+ self.code = code
50
+ self.body = body
51
+
52
+
53
+ @dataclass
54
+ class Lease:
55
+ lease_id: int
56
+ access: dict
57
+ key_path: str
58
+ key_dir: str
59
+ public_key: str
60
+ funding_hash: str
61
+ quote: dict
62
+
63
+
64
+ class PrismAgent:
65
+ """Headless GPU leasing for a wallet-holding agent. Authenticate with a wallet
66
+ signature, pay on-chain in USDG, provision, and run over SSH."""
67
+
68
+ def __init__(self, private_key: str, escrow: str,
69
+ api_base: str = "https://prismnetwork.tech", rpc_url: str = ROBINHOOD_RPC):
70
+ if not escrow:
71
+ raise ValueError("escrow address is required")
72
+ self.api_base = api_base.rstrip("/")
73
+ self.escrow = Web3.to_checksum_address(escrow)
74
+ self.account = Account.from_key(private_key)
75
+ self.w3 = Web3(Web3.HTTPProvider(rpc_url))
76
+ self._usdg = self.w3.eth.contract(address=USDG, abi=_ERC20)
77
+ self._escrow = self.w3.eth.contract(address=self.escrow, abi=_ESCROW)
78
+ self.session: str | None = None
79
+
80
+ @property
81
+ def address(self) -> str:
82
+ return self.account.address
83
+
84
+ def authenticate(self) -> dict:
85
+ challenge = self._json("GET", f"/api/agent/challenge?address={self.address}")
86
+ signed = self.account.sign_message(encode_defunct(text=challenge["message"]))
87
+ sig = signed.signature.hex()
88
+ session = self._json("POST", "/api/agent/session", {
89
+ "challenge": challenge["challenge"],
90
+ "address": self.address,
91
+ "signature": sig if sig.startswith("0x") else "0x" + sig,
92
+ })
93
+ self.session = session["session"]
94
+ return session
95
+
96
+ def offers(self) -> list:
97
+ return self._proxy("GET", ["offers"])
98
+
99
+ def balances(self) -> dict:
100
+ return {
101
+ "address": self.address,
102
+ "usdg": self._usdg.functions.balanceOf(self.address).call(),
103
+ "eth": self.w3.eth.get_balance(self.address),
104
+ }
105
+
106
+ def quote(self, image: str, duration_seconds: int, min_vram_mib: int = 16000,
107
+ preferred_node_id: str | None = None) -> dict:
108
+ if not isinstance(image, str) or not _DIGEST.search(image):
109
+ raise PrismError(400, "image_must_be_digest_pinned")
110
+ return self._proxy("POST", ["leases", "match"], {"request": {
111
+ "image": image,
112
+ "duration_seconds": duration_seconds,
113
+ "min_vram_mib": min_vram_mib,
114
+ "preferred_node_id": preferred_node_id,
115
+ }})
116
+
117
+ def confirm(self, quote_id: str, transaction_hash: str, ssh_authorized_key: str) -> dict:
118
+ return self._proxy("POST", ["leases", "confirm"], {
119
+ "quote_id": quote_id,
120
+ "transaction_hash": transaction_hash,
121
+ "ssh_authorized_key": ssh_authorized_key,
122
+ })
123
+
124
+ def leases(self) -> list:
125
+ return self._proxy("GET", ["leases"])
126
+
127
+ def access(self, lease_id) -> dict:
128
+ return self._proxy("GET", ["leases", str(lease_id), "access"])
129
+
130
+ def wait_for_access(self, lease_id, timeout: int = 600, interval: int = 10) -> dict:
131
+ deadline = time.time() + timeout
132
+ while time.time() < deadline:
133
+ status, body = self._proxy("GET", ["leases", str(lease_id), "access"], raw=True)
134
+ if status == 200:
135
+ return body
136
+ if status != 404:
137
+ raise PrismError(status, (body or {}).get("error", "access_error"))
138
+ time.sleep(interval)
139
+ raise PrismError(408, "access_timeout")
140
+
141
+ def lease(self, image: str, duration_seconds: int, min_vram_mib: int = 16000,
142
+ preferred_node_id: str | None = None, max_deposit: int | None = None) -> Lease:
143
+ if not self.session:
144
+ self.authenticate()
145
+ quote = self.quote(image, duration_seconds, min_vram_mib, preferred_node_id)
146
+ if max_deposit is not None and int(quote["maximum_escrow"]) > int(max_deposit):
147
+ raise PrismError(402, "cost_exceeds_max",
148
+ {"required": quote["maximum_escrow"], "max": str(max_deposit)})
149
+ key = self._generate_ssh_key()
150
+ try:
151
+ funding = self._fund(quote)
152
+ record = self.confirm(quote["quote_id"], funding, key["public_key"])
153
+ lease_id = record["lease_id"]
154
+ return Lease(lease_id, self.wait_for_access(lease_id),
155
+ key["key_path"], key["dir"], key["public_key"], funding, quote)
156
+ except Exception:
157
+ shutil.rmtree(key["dir"], ignore_errors=True)
158
+ raise
159
+
160
+ def run(self, lease: Lease, command: str, timeout: int = 120,
161
+ connect_retries: int = 24, connect_delay: int = 10) -> dict:
162
+ a = lease.access
163
+ args = ["ssh", "-i", lease.key_path, "-p", str(a["ssh_port"]),
164
+ "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
165
+ "-o", "BatchMode=yes", "-o", "ConnectTimeout=15",
166
+ f"{a.get('ssh_user', 'root')}@{a['ssh_host']}", command]
167
+ last = None
168
+ for attempt in range(connect_retries + 1):
169
+ try:
170
+ p = subprocess.run(args, capture_output=True, text=True, timeout=timeout + 20)
171
+ res = {"code": p.returncode, "stdout": p.stdout.strip(), "stderr": p.stderr.strip()}
172
+ except subprocess.TimeoutExpired:
173
+ res = {"code": -1, "stdout": "", "stderr": "timed out"}
174
+ if not _is_ssh_warmup(res):
175
+ return res
176
+ last = res
177
+ if attempt < connect_retries:
178
+ time.sleep(connect_delay)
179
+ return last
180
+
181
+ def end_lease(self, lease: Lease) -> None:
182
+ """Release local key material. The on-chain lease settles at the end of its duration."""
183
+ if lease and lease.key_dir:
184
+ shutil.rmtree(lease.key_dir, ignore_errors=True)
185
+
186
+ def _fund(self, quote: dict) -> str:
187
+ deposit = int(quote["maximum_escrow"])
188
+ duration = int(quote["duration_seconds"])
189
+ client_ref = Web3.keccak(text=quote["quote_id"])
190
+ node_id = bytes.fromhex(quote["node_id"].removeprefix("0x"))
191
+ allowance = self._usdg.functions.allowance(self.address, self.escrow).call()
192
+ if allowance < deposit:
193
+ self._send(self._usdg.functions.approve(self.escrow, deposit))
194
+ return self._send(self._escrow.functions.createLease(node_id, duration, client_ref),
195
+ confirmations=CONFIRMATIONS)
196
+
197
+ def _send(self, call, confirmations: int = 1) -> str:
198
+ tx = call.build_transaction({
199
+ "from": self.address,
200
+ "nonce": self.w3.eth.get_transaction_count(self.address),
201
+ "chainId": CHAIN_ID,
202
+ })
203
+ signed = self.account.sign_transaction(tx)
204
+ h = self.w3.eth.send_raw_transaction(signed.raw_transaction)
205
+ receipt = self.w3.eth.wait_for_transaction_receipt(h)
206
+ if receipt.status != 1:
207
+ raise PrismError(402, "tx_reverted", {"hash": h.hex()})
208
+ if confirmations > 1:
209
+ target = receipt.blockNumber + confirmations - 1
210
+ while self.w3.eth.block_number < target:
211
+ time.sleep(2)
212
+ return h.hex()
213
+
214
+ def _proxy(self, method: str, segments: list, body=None, raw: bool = False, reauthed: bool = False):
215
+ if not self.session:
216
+ self.authenticate()
217
+ res = self._request(f"/api/agent/proxy/{'/'.join(segments)}", method, body,
218
+ {"authorization": f"Bearer {self.session}"})
219
+ if res.status_code == 401 and not reauthed:
220
+ self.session = None
221
+ self.authenticate()
222
+ return self._proxy(method, segments, body, raw, True)
223
+ if raw:
224
+ return res.status_code, _safe_json(res)
225
+ return self._unwrap(res)
226
+
227
+ def _json(self, method: str, path: str, body=None):
228
+ return self._unwrap(self._request(path, method, body))
229
+
230
+ def _request(self, path: str, method: str, body=None, headers: dict | None = None):
231
+ try:
232
+ return requests.request(method, f"{self.api_base}{path}",
233
+ json=body, headers={"accept": "application/json", **(headers or {})},
234
+ timeout=FETCH_TIMEOUT)
235
+ except requests.RequestException as e:
236
+ raise PrismError(504, "control_plane_unreachable", {"cause": str(e)})
237
+
238
+ @staticmethod
239
+ def _unwrap(res):
240
+ data = _safe_json(res)
241
+ if not res.ok:
242
+ raise PrismError(res.status_code, (data or {}).get("error") or (data or {}).get("code") or "request_failed", data)
243
+ return data
244
+
245
+ def _generate_ssh_key(self) -> dict:
246
+ d = tempfile.mkdtemp(prefix="prism-ssh-")
247
+ try:
248
+ key_path = f"{d}/id_ed25519"
249
+ subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-q", "-f", key_path, "-C", "prism-agent"],
250
+ check=True, capture_output=True)
251
+ with open(f"{key_path}.pub") as f:
252
+ return {"dir": d, "key_path": key_path, "public_key": f.read().strip()}
253
+ except Exception as e:
254
+ shutil.rmtree(d, ignore_errors=True)
255
+ raise PrismError(500, "ssh_keygen_failed", {"cause": str(e)})
256
+
257
+
258
+ def _safe_json(res):
259
+ try:
260
+ return res.json()
261
+ except ValueError:
262
+ return None
263
+
264
+
265
+ def _is_ssh_warmup(res: dict) -> bool:
266
+ if res["code"] != 255:
267
+ return False
268
+ e = res["stderr"]
269
+ return (e.startswith("ssh: ") or "\nssh: " in e
270
+ or "kex_exchange_identification" in e or "Connection reset by peer" in e
271
+ or ("Permission denied (publickey" in e and res["stdout"] == ""))
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "prismnetwork"
7
+ version = "0.1.0"
8
+ description = "Headless GPU leasing on Prism Network for autonomous agents. Wallet-signature auth, on-chain USDG payment, SSH."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ keywords = ["prism", "gpu", "agent", "web3", "usdg", "llm"]
13
+ dependencies = [
14
+ "requests>=2.31",
15
+ "web3>=6.0",
16
+ "eth-account>=0.11",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://prismnetwork.tech"
21
+ Source = "https://github.com/prismnetwork-tech/prism"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["prismnetwork"]