singularity-grid 0.2.0__tar.gz → 0.4.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.
@@ -3,3 +3,6 @@ __pycache__/
3
3
  dist/
4
4
  *.egg-info/
5
5
  .venv/
6
+
7
+ # agent tooling state
8
+ .omx/
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: singularity-grid
3
- Version: 0.2.0
4
- Summary: Python SDK for the SGL Network confidential compute grid
3
+ Version: 0.4.0
4
+ Summary: Python SDK for the SGL Network confidential compute grid (end-to-end encrypted + streaming)
5
5
  Project-URL: Homepage, https://singularitylayer.xyz
6
6
  Project-URL: Repository, https://github.com/SingularityLayer/sgl-network-sdk
7
7
  Project-URL: Documentation, https://docs.singularitylayer.xyz/sdk
@@ -20,8 +20,11 @@ Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Requires-Python: >=3.9
23
+ Requires-Dist: base58>=2.1
24
+ Requires-Dist: cryptography>=42.0
23
25
  Requires-Dist: httpx>=0.24
24
26
  Requires-Dist: pydantic>=2.0
27
+ Requires-Dist: pynacl>=1.5
25
28
  Provides-Extra: openai
26
29
  Requires-Dist: openai>=1.0; extra == 'openai'
27
30
  Description-Content-Type: text/markdown
@@ -52,7 +55,7 @@ The SGL Network orchestrator exposes an OpenAI-compatible `/v1/chat/completions`
52
55
  from openai import OpenAI
53
56
 
54
57
  client = OpenAI(
55
- base_url="https://sgl-network-orchestrator.ivaavimusicproductions.workers.dev/v1",
58
+ base_url="https://grid.x402compute.cc/v1",
56
59
  api_key="scg_your_api_key",
57
60
  )
58
61
 
@@ -24,7 +24,7 @@ The SGL Network orchestrator exposes an OpenAI-compatible `/v1/chat/completions`
24
24
  from openai import OpenAI
25
25
 
26
26
  client = OpenAI(
27
- base_url="https://sgl-network-orchestrator.ivaavimusicproductions.workers.dev/v1",
27
+ base_url="https://grid.x402compute.cc/v1",
28
28
  api_key="scg_your_api_key",
29
29
  )
30
30
 
@@ -10,7 +10,7 @@ beyond swapping the base URL and API key.
10
10
  from openai import OpenAI
11
11
 
12
12
  client = OpenAI(
13
- base_url="https://sgl-network-orchestrator.ivaavimusicproductions.workers.dev/v1",
13
+ base_url="https://grid.x402compute.cc/v1",
14
14
  api_key="scg_your_api_key_here",
15
15
  )
16
16
 
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "singularity-grid"
7
- version = "0.2.0"
8
- description = "Python SDK for the SGL Network confidential compute grid"
7
+ version = "0.4.0"
8
+ description = "Python SDK for the SGL Network confidential compute grid (end-to-end encrypted + streaming)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.9"
@@ -28,6 +28,9 @@ classifiers = [
28
28
  dependencies = [
29
29
  "httpx>=0.24",
30
30
  "pydantic>=2.0",
31
+ "pynacl>=1.5",
32
+ "cryptography>=42.0",
33
+ "base58>=2.1",
31
34
  ]
32
35
 
33
36
  [project.optional-dependencies]
@@ -2,10 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from typing import Any, Dict, List, Optional
5
+ import json as _json
6
+ from typing import Any, Dict, Iterator, List, Optional
6
7
 
7
8
  import httpx
8
9
 
10
+ from . import e2e
9
11
  from .models import (
10
12
  AttestationProof,
11
13
  CapacityResponse,
@@ -23,9 +25,7 @@ from .models import (
23
25
  ProcessorResult,
24
26
  )
25
27
 
26
- DEFAULT_BASE_URL = (
27
- "https://sgl-network-orchestrator.ivaavimusicproductions.workers.dev"
28
- )
28
+ DEFAULT_BASE_URL = "https://grid.x402compute.cc"
29
29
 
30
30
  DEFAULT_TIMEOUT = 60.0
31
31
 
@@ -93,6 +93,9 @@ class GridClient:
93
93
  headers: Dict[str, str] = {"Accept": "application/json"}
94
94
  if api_key:
95
95
  headers["Authorization"] = f"Bearer {api_key}"
96
+ # Grid credit billing reads X-API-Key; send both so reserve + chat
97
+ # resolve the paying wallet (credits mode) for end-to-end requests.
98
+ headers["X-API-Key"] = api_key
96
99
  self._client = httpx.Client(
97
100
  base_url=self._base_url,
98
101
  headers=headers,
@@ -206,6 +209,197 @@ class GridClient:
206
209
  data = self._request("GET", f"/grid/jobs/{job_id}/attestation")
207
210
  return AttestationProof.model_validate(data)
208
211
 
212
+ # -- chat (end-to-end encrypted) ---------------------------------------
213
+
214
+ def providers(self, model: str, cluster: Optional[str] = None) -> List[Dict[str, Any]]:
215
+ """List nodes serving ``model`` with their effective price + reputation,
216
+ cheapest first — pick one and pass its ``node_id`` to ``chat_completions(node=...)``."""
217
+ q = f"/v1/providers?model={model}" + (f"&cluster={cluster}" if cluster else "")
218
+ data = self._request("GET", q)
219
+ return data.get("providers", [])
220
+
221
+ def _reserve(self, model: str, cluster: Optional[str] = None, node: Optional[str] = None) -> Dict[str, Any]:
222
+ """Reserve a node + learn its X25519 key so we can seal the prompt to it.
223
+
224
+ When ``cluster`` is given, a node *inside that cluster* (a Tokenised Compute
225
+ Market) is reserved. When ``node`` is given, that specific provider is reserved.
226
+ """
227
+ payload: Dict[str, Any] = {"model": model}
228
+ if cluster is not None:
229
+ payload["cluster"] = cluster
230
+ if node is not None:
231
+ payload["node"] = node
232
+ data = self._request("POST", "/v1/reserve", json=payload)
233
+ if not data.get("node_x25519_pubkey"):
234
+ raise SGLAPIError(503, "Reserved node does not support E2E encryption")
235
+ return data
236
+
237
+ def chat_completions(
238
+ self,
239
+ model: str,
240
+ messages: List[Dict[str, str]],
241
+ *,
242
+ temperature: float = 0.7,
243
+ max_tokens: int = 512,
244
+ cluster: Optional[str] = None,
245
+ node: Optional[str] = None,
246
+ pay_in_coin: bool = False,
247
+ ) -> Dict[str, Any]:
248
+ """End-to-end encrypted chat completion.
249
+
250
+ The prompt is sealed in this client to the serving node's key and only
251
+ decrypts inside its TEE — the orchestrator only relays ciphertext. Requires
252
+ ``api_key`` (credits); x402 pay-per-call isn't signed here (use the wallet
253
+ flow). Returns an OpenAI-style dict with an extra ``attestation`` field.
254
+
255
+ Parameters
256
+ ----------
257
+ cluster:
258
+ Optional cluster slug — route to a node inside that Tokenised Compute
259
+ Market (only nodes that joined the cluster serve it).
260
+ pay_in_coin:
261
+ When ``True`` and the cluster has a tradeable coin, pay for the request
262
+ in that coin (the believer lane) instead of USDC/credits. Falls back to
263
+ USDC if the coin's oracle price is untrusted.
264
+ """
265
+ reservation = self._reserve(model, cluster=cluster, node=node)
266
+ resp_sk, resp_pub = e2e.new_response_keypair()
267
+ sealed_ct, eph = e2e.seal_input(
268
+ reservation["node_x25519_pubkey"], resp_pub,
269
+ _json.dumps({"messages": messages, "temperature": temperature, "max_tokens": max_tokens}).encode(),
270
+ )
271
+ body: Dict[str, Any] = {
272
+ "reservation_token": reservation["reservation_token"],
273
+ "max_tokens": max_tokens, # cleartext, only used to quote the x402 price
274
+ "enc": {
275
+ "ciphertext": sealed_ct,
276
+ "client_ephemeral_pubkey": eph,
277
+ "client_response_pubkey": resp_pub,
278
+ "algorithm": e2e.ALGO_V2,
279
+ },
280
+ }
281
+ if pay_in_coin:
282
+ body["pay_in_coin"] = True
283
+ try:
284
+ data = self._request("POST", "/v1/chat/completions", json=body)
285
+ except SGLAPIError as err:
286
+ if err.status_code == 402:
287
+ raise SGLAPIError(402, "Payment required — pass api_key (credits); the Python GridClient does not sign x402 payments.") from err
288
+ raise
289
+
290
+ sealed = data.get("sealed_result")
291
+ if not sealed:
292
+ raise SGLAPIError(500, "No sealed result returned")
293
+ plain = e2e.open_output(resp_sk, resp_pub, sealed["ephemeral_public_key"], sealed["ciphertext"])
294
+ parsed = _json.loads(plain)
295
+ return {
296
+ "id": data.get("id", ""),
297
+ "object": "chat.completion",
298
+ "model": model,
299
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": parsed.get("content", "")}, "finish_reason": "stop"}],
300
+ "usage": data.get("usage") or parsed.get("usage", {}),
301
+ "attestation": {
302
+ "node_id": reservation.get("node_id"),
303
+ "tee_type": reservation.get("tee_type"),
304
+ "verified": bool(reservation.get("attestation_verified", False)),
305
+ },
306
+ }
307
+
308
+ def chat_completion_stream(
309
+ self,
310
+ model: str,
311
+ messages: List[Dict[str, str]],
312
+ *,
313
+ temperature: float = 0.7,
314
+ max_tokens: int = 512,
315
+ ) -> Iterator[str]:
316
+ """Yield decoded text as it streams (end-to-end encrypted). Requires
317
+ ``api_key`` (credits). Each chunk is decrypted and its ordering +
318
+ termination verified (a truncated stream raises). If streaming isn't
319
+ enabled server-side, the whole reply is yielded as one chunk."""
320
+ reservation = self._reserve(model)
321
+ resp_sk, resp_pub = e2e.new_response_keypair()
322
+ nonce = e2e.random_nonce_b58()
323
+ sealed_ct, eph = e2e.seal_input(
324
+ reservation["node_x25519_pubkey"], resp_pub,
325
+ _json.dumps({"messages": messages, "temperature": temperature, "max_tokens": max_tokens, "stream": True, "nonce": nonce}).encode(),
326
+ )
327
+ body = {
328
+ "reservation_token": reservation["reservation_token"],
329
+ "stream": True,
330
+ "max_tokens": max_tokens,
331
+ "enc": {
332
+ "ciphertext": sealed_ct,
333
+ "client_ephemeral_pubkey": eph,
334
+ "client_response_pubkey": resp_pub,
335
+ "algorithm": e2e.ALGO_V2,
336
+ },
337
+ }
338
+ with self._client.stream("POST", "/v1/chat/completions", json=body) as resp:
339
+ if resp.status_code != 200:
340
+ resp.read()
341
+ if resp.status_code == 402:
342
+ raise SGLAPIError(402, "Payment required — pass api_key (credits); the Python GridClient does not sign x402 payments.")
343
+ msg = resp.text
344
+ try:
345
+ err = resp.json().get("error")
346
+ msg = err.get("message", msg) if isinstance(err, dict) else (err or msg)
347
+ except Exception:
348
+ pass
349
+ raise SGLAPIError(resp.status_code, str(msg))
350
+
351
+ if "text/event-stream" not in resp.headers.get("content-type", ""):
352
+ resp.read()
353
+ data = _json.loads(resp.text)
354
+ sealed = data.get("sealed_result")
355
+ if not sealed:
356
+ raise SGLAPIError(500, "No sealed result returned")
357
+ plain = e2e.open_output(resp_sk, resp_pub, sealed["ephemeral_public_key"], sealed["ciphertext"])
358
+ content = _json.loads(plain).get("content", "")
359
+ if content:
360
+ yield content
361
+ return
362
+
363
+ expected_seq = 0
364
+ out_key = None
365
+ stream_eph = None
366
+ saw_final = False
367
+ for line in resp.iter_lines():
368
+ line = line.strip()
369
+ if not line:
370
+ continue
371
+ if line.startswith("event: error"):
372
+ raise SGLAPIError(502, "stream aborted by server")
373
+ if line.startswith(":") or not line.startswith("data:"):
374
+ continue
375
+ payload = line[5:].strip()
376
+ if payload == "[DONE]":
377
+ continue
378
+ try:
379
+ chunk = _json.loads(payload)
380
+ except _json.JSONDecodeError as exc:
381
+ raise SGLAPIError(502, "malformed stream chunk") from exc
382
+ seq = chunk.get("seq")
383
+ if seq is None or "ct" not in chunk:
384
+ raise SGLAPIError(502, "invalid stream chunk (missing seq/ciphertext)")
385
+ if seq != expected_seq:
386
+ raise SGLAPIError(502, f"stream out of order (expected {expected_seq}, got {seq})")
387
+ if seq == 0:
388
+ stream_eph = chunk.get("eph")
389
+ if not stream_eph:
390
+ raise SGLAPIError(502, "stream chunk 0 missing ephemeral key")
391
+ out_key = e2e.stream_out_key(resp_sk, stream_eph)
392
+ is_final = chunk.get("final") is True
393
+ text = e2e.open_stream_chunk(out_key, resp_pub, stream_eph, nonce, seq, is_final, chunk["ct"]).decode()
394
+ if text:
395
+ yield text
396
+ expected_seq += 1
397
+ if is_final:
398
+ saw_final = True
399
+ break
400
+ if not saw_final:
401
+ raise SGLAPIError(502, "stream ended before final chunk (truncated)")
402
+
209
403
  # -- processor endpoints -----------------------------------------------
210
404
 
211
405
  def deploy_processor(
@@ -0,0 +1,138 @@
1
+ """
2
+ End-to-end encryption for the SGL grid (client side).
3
+
4
+ Must match the node, orchestrator, and browser/TS clients byte-for-byte:
5
+ X25519 ECDH -> HKDF-SHA256 -> XChaCha20-Poly1305 (24-byte nonce), AAD-bound.
6
+ Sealed blob layout: nonce(24) || ciphertext, base58.
7
+
8
+ The orchestrator only ever relays ciphertext — it never sees the prompt or reply.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base58
14
+ from cryptography.hazmat.primitives import hashes
15
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
16
+ from nacl import bindings
17
+
18
+ ALGO_V2 = "x25519-xchacha20poly1305-hkdf-v2"
19
+ ALGO_V2_STREAM = "x25519-xchacha20poly1305-hkdf-v2-stream"
20
+
21
+ _SALT = b"sgl-e2e-v2-salt"
22
+ _INFO_INPUT = b"sgl-e2e-v2-input"
23
+ _INFO_OUTPUT = b"sgl-e2e-v2-output"
24
+
25
+
26
+ def _hkdf(shared: bytes, info: bytes) -> bytes:
27
+ return HKDF(algorithm=hashes.SHA256(), length=32, salt=_SALT, info=info).derive(shared)
28
+
29
+
30
+ def _aad_input(node_b58: str, eph_b58: str, resp_b58: str) -> bytes:
31
+ return f"sgl-aad/v2/input|node={node_b58}|eph={eph_b58}|resp={resp_b58}".encode()
32
+
33
+
34
+ def _aad_output(resp_b58: str, eph_b58: str) -> bytes:
35
+ return f"sgl-aad/v2/output|resp={resp_b58}|eph={eph_b58}".encode()
36
+
37
+
38
+ def _aad_stream(resp_b58: str, eph_b58: str, nonce_b58: str, seq: int, final: bool) -> bytes:
39
+ f = 1 if final else 0
40
+ return (
41
+ f"sgl-aad/v2/stream|resp={resp_b58}|eph={eph_b58}|nonce={nonce_b58}|seq={seq}|final={f}"
42
+ ).encode()
43
+
44
+
45
+ def _b58e(b: bytes) -> str:
46
+ return base58.b58encode(b).decode()
47
+
48
+
49
+ def _b58d(s: str) -> bytes:
50
+ return base58.b58decode(s)
51
+
52
+
53
+ def _gen_keypair() -> tuple[bytes, bytes]:
54
+ sk = bindings.randombytes(32)
55
+ pk = bindings.crypto_scalarmult_base(sk)
56
+ return sk, pk
57
+
58
+
59
+ def new_response_keypair() -> tuple[bytes, str]:
60
+ """The caller's response keypair — the node seals its reply to this."""
61
+ sk, pk = _gen_keypair()
62
+ return sk, _b58e(pk)
63
+
64
+
65
+ def random_nonce_b58() -> str:
66
+ """A per-request nonce bound into every stream chunk's AAD."""
67
+ return _b58e(bindings.randombytes(16))
68
+
69
+
70
+ def seal_input(node_pub_b58: str, resp_pub_b58: str, plaintext: bytes) -> tuple[str, str]:
71
+ """Seal the prompt to the node's X25519 key. Returns (ciphertext_b58, ephemeral_pub_b58)."""
72
+ node_pub = _b58d(node_pub_b58)
73
+ eph_sk, eph_pk = _gen_keypair()
74
+ eph_b58 = _b58e(eph_pk)
75
+ shared = bindings.crypto_scalarmult(eph_sk, node_pub)
76
+ key = _hkdf(shared, _INFO_INPUT)
77
+ nonce = bindings.randombytes(24)
78
+ aad = _aad_input(node_pub_b58, eph_b58, resp_pub_b58)
79
+ ct = bindings.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, aad, nonce, key)
80
+ return _b58e(nonce + ct), eph_b58
81
+
82
+
83
+ def open_output(resp_sk: bytes, resp_pub_b58: str, node_eph_b58: str, ct_b58: str) -> bytes:
84
+ """Open the node's (non-stream) reply sealed to our response key."""
85
+ shared = bindings.crypto_scalarmult(resp_sk, _b58d(node_eph_b58))
86
+ key = _hkdf(shared, _INFO_OUTPUT)
87
+ aad = _aad_output(resp_pub_b58, node_eph_b58)
88
+ blob = _b58d(ct_b58)
89
+ return bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(blob[24:], aad, blob[:24], key)
90
+
91
+
92
+ def stream_out_key(resp_sk: bytes, node_stream_eph_b58: str) -> bytes:
93
+ """Derive the stream output key once from the node's stream ephemeral (chunk 0)."""
94
+ shared = bindings.crypto_scalarmult(resp_sk, _b58d(node_stream_eph_b58))
95
+ return _hkdf(shared, _INFO_OUTPUT)
96
+
97
+
98
+ def open_stream_chunk(
99
+ out_key: bytes,
100
+ resp_pub_b58: str,
101
+ eph_b58: str,
102
+ nonce_b58: str,
103
+ seq: int,
104
+ final: bool,
105
+ ct_b58: str,
106
+ ) -> bytes:
107
+ """Open one stream chunk with the precomputed key + nonce/seq/final-bound AAD."""
108
+ aad = _aad_stream(resp_pub_b58, eph_b58, nonce_b58, seq, final)
109
+ blob = _b58d(ct_b58)
110
+ return bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(blob[24:], aad, blob[:24], out_key)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ # Cross-language vector from the node's encryption.rs: proves this module's
115
+ # X25519+HKDF+XChaCha20+AAD+base58 match the Rust/TS/browser clients byte-for-byte.
116
+ import hashlib
117
+ import json
118
+
119
+ node_secret = bytes([0x42] * 32)
120
+ node_x25519_sk = hashlib.sha256(b"sgl-x25519-derive:" + node_secret).digest()
121
+ node_pub_b58 = _b58e(bindings.crypto_scalarmult_base(node_x25519_sk))
122
+
123
+ eph_b58 = "DQFdwcBsqukJEBn9UNfQruaTHKHxHFMVRA2B5qZuFdfB"
124
+ resp_b58 = "2L54SXdEHm5mraF2X2GPid3m4PSkwVehEvhk487mWTx8"
125
+ ct_b58 = ("gbEA6dFFVPxdar6e8QsjPKWj7xHcBo32nAqweQC5arnt4M5LmHhjREKoUTdVZsU6mmkxKu1Xmv"
126
+ "Eo4oG8EUySndq2ytTyzDgyfMjyBSmPE2fqjdPDzKYtdrC2kZbAfCXv227GczHgmtQBqchA5qMB5"
127
+ "ydxgxYnk9V8jb8sifTjHM61iEQkisdwYCqna")
128
+
129
+ shared = bindings.crypto_scalarmult(node_x25519_sk, _b58d(eph_b58))
130
+ key = _hkdf(shared, _INFO_INPUT)
131
+ aad = _aad_input(node_pub_b58, eph_b58, resp_b58)
132
+ blob = _b58d(ct_b58)
133
+ pt = bindings.crypto_aead_xchacha20poly1305_ietf_decrypt(blob[24:], aad, blob[:24], key)
134
+ got = json.loads(pt)
135
+ expected = {"messages": [{"role": "user", "content": "cross-lang v2 test"}],
136
+ "temperature": 0.7, "max_tokens": 512}
137
+ assert got == expected, f"MISMATCH: {got}"
138
+ print("OK: singularity-grid E2E crypto matches the cross-language vector")