prismnetwork 0.1.0__py3-none-any.whl
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.
prismnetwork/__init__.py
ADDED
prismnetwork/_agent.py
ADDED
|
@@ -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,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,5 @@
|
|
|
1
|
+
prismnetwork/__init__.py,sha256=BeCieXU9XSvERo4osxqjHTcVrV4sOJoYi7JoU2RelG4,167
|
|
2
|
+
prismnetwork/_agent.py,sha256=6eRtEGLsX6EmqVRa1NawaGIh_9iAEUr0q_tB7itBbu4,11340
|
|
3
|
+
prismnetwork-0.1.0.dist-info/METADATA,sha256=yuLif6wEVoHnq7fJxwLKDoMkw4JS9MJjbalUeyE6ORI,2078
|
|
4
|
+
prismnetwork-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
5
|
+
prismnetwork-0.1.0.dist-info/RECORD,,
|