singularity-grid 0.9.0__tar.gz → 0.9.2__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.
Files changed (21) hide show
  1. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/PKG-INFO +37 -1
  2. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/README.md +36 -0
  3. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/pyproject.toml +1 -1
  4. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/__init__.py +10 -1
  5. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/client.py +78 -0
  6. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/e2e.py +77 -0
  7. singularity_grid-0.9.2/singularity_grid/pods.py +492 -0
  8. singularity_grid-0.9.2/tests/test_pod_webhook.py +50 -0
  9. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/.github/workflows/publish.yml +0 -0
  10. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/.gitignore +0 -0
  11. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/LICENSE +0 -0
  12. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/examples/check_capacity.py +0 -0
  13. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/examples/openai_compatible.py +0 -0
  14. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/examples/quickstart.py +0 -0
  15. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/models.py +0 -0
  16. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/openai_compat.py +0 -0
  17. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/processors.py +0 -0
  18. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/py.typed +0 -0
  19. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/vault.py +0 -0
  20. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/tests/test_processors.py +0 -0
  21. {singularity_grid-0.9.0 → singularity_grid-0.9.2}/tests/test_vault.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: singularity-grid
3
- Version: 0.9.0
3
+ Version: 0.9.2
4
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/Singularity-Layer/sgl-network-sdk
@@ -119,6 +119,39 @@ attestation = grid.get_attestation(job.job_id)
119
119
  print(f"TEE verified: {attestation.verified}")
120
120
  ```
121
121
 
122
+ ### 4. System One / Laya
123
+
124
+ Laya is served as a typed-decision model, not as chat completions.
125
+
126
+ ```python
127
+ from singularity_grid import GridClient
128
+
129
+ grid = GridClient(api_key="x402c_your_api_key")
130
+
131
+ systemone_models = grid.systemone_models()
132
+ print([model["id"] for model in systemone_models])
133
+
134
+ decision = grid.system_one(
135
+ model="convaiinnovations/laya",
136
+ state={"ticket": "Enterprise customer cannot access billing exports"},
137
+ questions={
138
+ "route": {
139
+ "type": "choice",
140
+ "instructions": "Choose the best team.",
141
+ "criteria": {
142
+ "billing": "Billing, invoice, refund, or account credit issue.",
143
+ "support": "Product defect or technical troubleshooting.",
144
+ },
145
+ },
146
+ "urgency": {
147
+ "type": "score",
148
+ "instructions": "Score urgency from 0 to 1.",
149
+ },
150
+ },
151
+ )
152
+ print(decision["answers"])
153
+ ```
154
+
122
155
  ## API reference
123
156
 
124
157
  ### GridClient
@@ -127,7 +160,10 @@ print(f"TEE verified: {attestation.verified}")
127
160
  |---|---|---|
128
161
  | `capacity()` | No | Grid-wide capacity summary |
129
162
  | `models()` | No | Available models with pricing and TEE info |
163
+ | `v1_models(type=None)` | No | OpenAI-compatible model list; pass `type="systemone"` for Laya |
164
+ | `systemone_models()` | No | List Laya/System One typed-decision models |
130
165
  | `pricing()` | No | Pricing table for all models |
166
+ | `system_one(state, questions, ...)` | Yes | Call `/v1/systemone` for typed decisions |
131
167
  | `submit_job(model, input_payload, ...)` | Yes | Submit a compute job |
132
168
  | `get_job(job_id)` | Yes | Get job status and result |
133
169
  | `get_attestation(job_id)` | Yes | Get TEE attestation proof |
@@ -88,6 +88,39 @@ attestation = grid.get_attestation(job.job_id)
88
88
  print(f"TEE verified: {attestation.verified}")
89
89
  ```
90
90
 
91
+ ### 4. System One / Laya
92
+
93
+ Laya is served as a typed-decision model, not as chat completions.
94
+
95
+ ```python
96
+ from singularity_grid import GridClient
97
+
98
+ grid = GridClient(api_key="x402c_your_api_key")
99
+
100
+ systemone_models = grid.systemone_models()
101
+ print([model["id"] for model in systemone_models])
102
+
103
+ decision = grid.system_one(
104
+ model="convaiinnovations/laya",
105
+ state={"ticket": "Enterprise customer cannot access billing exports"},
106
+ questions={
107
+ "route": {
108
+ "type": "choice",
109
+ "instructions": "Choose the best team.",
110
+ "criteria": {
111
+ "billing": "Billing, invoice, refund, or account credit issue.",
112
+ "support": "Product defect or technical troubleshooting.",
113
+ },
114
+ },
115
+ "urgency": {
116
+ "type": "score",
117
+ "instructions": "Score urgency from 0 to 1.",
118
+ },
119
+ },
120
+ )
121
+ print(decision["answers"])
122
+ ```
123
+
91
124
  ## API reference
92
125
 
93
126
  ### GridClient
@@ -96,7 +129,10 @@ print(f"TEE verified: {attestation.verified}")
96
129
  |---|---|---|
97
130
  | `capacity()` | No | Grid-wide capacity summary |
98
131
  | `models()` | No | Available models with pricing and TEE info |
132
+ | `v1_models(type=None)` | No | OpenAI-compatible model list; pass `type="systemone"` for Laya |
133
+ | `systemone_models()` | No | List Laya/System One typed-decision models |
99
134
  | `pricing()` | No | Pricing table for all models |
135
+ | `system_one(state, questions, ...)` | Yes | Call `/v1/systemone` for typed decisions |
100
136
  | `submit_job(model, input_payload, ...)` | Yes | Submit a compute job |
101
137
  | `get_job(job_id)` | Yes | Get job status and result |
102
138
  | `get_attestation(job_id)` | Yes | Get TEE attestation proof |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "singularity-grid"
7
- version = "0.9.0"
7
+ version = "0.9.2"
8
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"
@@ -10,6 +10,11 @@ from .client import (
10
10
  SGLError,
11
11
  SGLNotFoundError,
12
12
  )
13
+ from .pods import (
14
+ DEFAULT_PODS_BASE_URL,
15
+ PodsClient,
16
+ verify_pod_webhook,
17
+ )
13
18
  from .models import (
14
19
  AttestationProof,
15
20
  CapacityResponse,
@@ -24,13 +29,17 @@ from .models import (
24
29
  )
25
30
  from .openai_compat import create_openai_client
26
31
 
27
- __version__ = "0.2.0"
32
+ __version__ = "0.9.2"
28
33
 
29
34
  __all__ = [
30
35
  # Processors — a SEPARATE client on processors.x402compute.cc. Until 0.9.0 these methods
31
36
  # lived on GridClient and pointed at /grid/processors, which has never existed.
32
37
  "ProcessorsClient",
33
38
  "PROCESSORS_BASE_URL",
39
+ # Agent Pods — hosted agents, on compute.x402layer.cc.
40
+ "PodsClient",
41
+ "DEFAULT_PODS_BASE_URL",
42
+ "verify_pod_webhook",
34
43
  # Client
35
44
  "GridClient",
36
45
  "create_openai_client",
@@ -170,6 +170,22 @@ class GridClient:
170
170
  wrapped = PricingResponse.model_validate(data)
171
171
  return wrapped.pricing
172
172
 
173
+ def v1_models(self, type: Optional[str] = None) -> List[Dict[str, Any]]:
174
+ """Return OpenAI-compatible ``/v1/models`` descriptors.
175
+
176
+ Pass ``type="systemone"`` to discover Laya/Jev-style typed-decision
177
+ models.
178
+ """
179
+ params: Dict[str, Any] = {}
180
+ if type is not None:
181
+ params["type"] = type
182
+ data = self._request("GET", "/v1/models", params=params or None)
183
+ return list(data.get("data", []))
184
+
185
+ def systemone_models(self) -> List[Dict[str, Any]]:
186
+ """List Laya/Jev-style System One typed-decision models."""
187
+ return self.v1_models(type="systemone")
188
+
173
189
  # -- authenticated endpoints --------------------------------------------
174
190
 
175
191
  def submit_job(
@@ -310,6 +326,9 @@ class GridClient:
310
326
  sealed = data.get("sealed_result")
311
327
  if not sealed:
312
328
  raise SGLAPIError(500, "No sealed result returned")
329
+ # Prove WHO produced this before opening it. AEAD only proves someone
330
+ # sealed it to our key, and the orchestrator knows that key.
331
+ e2e.require_verified_reply(reservation, data)
313
332
  plain = e2e.open_output(resp_sk, resp_pub, sealed["ephemeral_public_key"], sealed["ciphertext"])
314
333
  parsed = _json.loads(plain)
315
334
  return {
@@ -357,6 +376,44 @@ class GridClient:
357
376
  raise SGLAPIError(402, "Payment required — pass api_key (credits); the Python GridClient does not sign x402 payments.") from err
358
377
  raise
359
378
 
379
+ def system_one(
380
+ self,
381
+ state: Dict[str, Any],
382
+ questions: Dict[str, Dict[str, Any]],
383
+ *,
384
+ model: str = "convaiinnovations/laya",
385
+ node: Optional[str] = None,
386
+ cluster: Optional[str] = None,
387
+ max_price: Optional[float] = None,
388
+ ) -> Dict[str, Any]:
389
+ """Call Laya/System One typed decisions via ``POST /v1/systemone``.
390
+
391
+ This is not a chat-completions model. Use it for compact typed outputs
392
+ such as choices, scores, and no-output-language summaries. Requires
393
+ ``api_key`` (credits); x402 pay-per-call is not signed by this client.
394
+ """
395
+ body: Dict[str, Any] = {
396
+ "model": model,
397
+ "state": state,
398
+ "questions": questions,
399
+ }
400
+ if node is not None:
401
+ body["node"] = node
402
+ if cluster is not None:
403
+ body["cluster"] = cluster
404
+ if max_price is not None:
405
+ body["max_price"] = max_price
406
+ try:
407
+ return self._request("POST", "/v1/systemone", json=body)
408
+ except SGLAPIError as err:
409
+ if err.status_code == 402:
410
+ raise SGLAPIError(402, "Payment required — pass api_key (credits); the Python GridClient does not sign x402 payments.") from err
411
+ raise
412
+
413
+ def systemone(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:
414
+ """Alias for :meth:`system_one` matching the HTTP endpoint spelling."""
415
+ return self.system_one(*args, **kwargs)
416
+
360
417
  def embed(self, model: str, input: Any, **kwargs: Any) -> List[List[float]]:
361
418
  """Convenience wrapper around :meth:`embeddings` that returns just the list of
362
419
  vectors, ordered to match ``input``."""
@@ -417,6 +474,9 @@ class GridClient:
417
474
  sealed = data.get("sealed_result")
418
475
  if not sealed:
419
476
  raise SGLAPIError(500, "No sealed result returned")
477
+ # Same check on the non-streaming fallback: an orchestrator that
478
+ # can force this path must not get an unverified reply through it.
479
+ e2e.require_verified_reply(reservation, data)
420
480
  plain = e2e.open_output(resp_sk, resp_pub, sealed["ephemeral_public_key"], sealed["ciphertext"])
421
481
  content = _json.loads(plain).get("content", "")
422
482
  if content:
@@ -453,6 +513,24 @@ class GridClient:
453
513
  raise SGLAPIError(502, "stream chunk 0 missing ephemeral key")
454
514
  out_key = e2e.stream_out_key(resp_sk, stream_eph)
455
515
  is_final = chunk.get("final") is True
516
+ # Verify EVERY chunk before opening it. The node signs each with
517
+ # kind "stream:{seq}:{final}" over the chunk ciphertext, and the
518
+ # orchestrator relays that signature rather than consuming it.
519
+ # Without this a compromised relay could splice or invent chunks,
520
+ # i.e. edit the answer as it arrives.
521
+ if chunk.get("sigv") and chunk["sigv"] != "v1":
522
+ raise e2e.UnverifiedReply(f"unknown chunk envelope version {chunk['sigv']!r}")
523
+ kind = f"stream:{seq}:{1 if is_final else 0}"
524
+ if not e2e.verify_result_envelope(
525
+ reservation.get("node_ed25519_pubkey"),
526
+ chunk.get("job"),
527
+ kind,
528
+ chunk["ct"],
529
+ chunk.get("sig"),
530
+ ):
531
+ raise e2e.UnverifiedReply(
532
+ f"stream chunk {seq} is not signed by the reserved node — refusing it."
533
+ )
456
534
  text = e2e.open_stream_chunk(out_key, resp_pub, stream_eph, nonce, seq, is_final, chunk["ct"]).decode()
457
535
  if text:
458
536
  yield text
@@ -80,6 +80,83 @@ def seal_input(node_pub_b58: str, resp_pub_b58: str, plaintext: bytes) -> tuple[
80
80
  return _b58e(nonce + ct), eph_b58
81
81
 
82
82
 
83
+ class UnverifiedReply(Exception):
84
+ """A sealed reply could not be proven to come from the reserved node."""
85
+
86
+
87
+ def verify_result_envelope(
88
+ node_ed25519_b58: str | None,
89
+ job_id: str | None,
90
+ kind: str,
91
+ ciphertext: str,
92
+ signature_b58: str | None,
93
+ ) -> bool:
94
+ """Verify the node's v1 envelope signature over a sealed reply.
95
+
96
+ The signature covers the PUBLIC ciphertext, not the plaintext, so it can be
97
+ checked exactly -- no re-serialising, no key-ordering problem. Message:
98
+ ``sgl-result-v1\\n{job_id}\\n{kind}\\n{sha256_hex(ciphertext)}``, and the
99
+ ciphertext is hashed as the literal UTF-8 string, not as decoded bytes.
100
+ """
101
+ if not node_ed25519_b58 or not signature_b58 or not job_id:
102
+ return False
103
+ try:
104
+ from hashlib import sha256
105
+
106
+ from nacl.signing import VerifyKey
107
+
108
+ digest = sha256(ciphertext.encode()).hexdigest()
109
+ msg = f"sgl-result-v1\n{job_id}\n{kind}\n{digest}".encode()
110
+ VerifyKey(_b58d(node_ed25519_b58)).verify(msg, _b58d(signature_b58))
111
+ return True
112
+ except Exception:
113
+ return False
114
+
115
+
116
+ def require_verified_reply(
117
+ reservation: dict,
118
+ data: dict,
119
+ kind: str = "sealed",
120
+ ) -> None:
121
+ """Refuse a sealed reply we cannot attribute to the reserved node.
122
+
123
+ Why this is mandatory rather than best-effort: ``client_response_pubkey``
124
+ reaches the orchestrator in cleartext, so a compromised orchestrator cannot
125
+ READ a prompt but CAN fabricate a reply, seal it to that key, and have it
126
+ decrypt cleanly. AEAD proves only that someone sealed it to us; this proves
127
+ WHO. Without it the grid could put words in the model's mouth -- and for an
128
+ agentic caller, tool calls in its hands.
129
+
130
+ A MISSING signature fails exactly like an invalid one. Every sealed reply
131
+ carries one, so absence means something is wrong, and "warn and continue" is
132
+ what let this gap survive unnoticed in the first place.
133
+ """
134
+ sealed = data.get("sealed_result") or {}
135
+ version = data.get("result_envelope_version")
136
+ if version is not None and version != "v1":
137
+ raise UnverifiedReply(f"unknown result envelope version {version!r}")
138
+ # `job_id` is explicit on current grids; older ones only embed it in `id`.
139
+ job_id = data.get("job_id")
140
+ if not job_id:
141
+ raw_id = data.get("id") or ""
142
+ job_id = raw_id[len("chatcmpl-") :] if raw_id.startswith("chatcmpl-") else None
143
+ ok = verify_result_envelope(
144
+ reservation.get("node_ed25519_pubkey"),
145
+ job_id,
146
+ kind,
147
+ sealed.get("ciphertext", ""),
148
+ data.get("result_envelope_signature"),
149
+ )
150
+ if not ok:
151
+ # NEVER fall back to `result_signature`: that legacy field signs the
152
+ # PLAINTEXT and is always absent for sealed replies, so reaching for it
153
+ # would make this check silently meaningless.
154
+ raise UnverifiedReply(
155
+ "sealed reply is not signed by the reserved node — refusing it. "
156
+ "Update the grid if this persists."
157
+ )
158
+
159
+
83
160
  def open_output(resp_sk: bytes, resp_pub_b58: str, node_eph_b58: str, ct_b58: str) -> bytes:
84
161
  """Open the node's (non-stream) reply sealed to our response key."""
85
162
  shared = bindings.crypto_scalarmult(resp_sk, _b58d(node_eph_b58))
@@ -0,0 +1,492 @@
1
+ """Agent Pods — hosted agents you can create, drive and destroy over an API key.
2
+
3
+ A pod is a running agent on its own machine: it holds a conversation, keeps a workspace, can
4
+ be given scheduled tasks and connectors, and exposes an OpenAI-compatible endpoint so anything
5
+ that already speaks to OpenAI can speak to it instead.
6
+
7
+ Two things are worth knowing before you use this.
8
+
9
+ **Create is idempotent, and the key is not decoration.** Creating a pod provisions a machine
10
+ and charges for it, so a retry after a timeout must not do it twice. ``create_pod`` generates
11
+ an idempotency key when you do not pass one, and replaying the same key returns the original
12
+ response byte for byte rather than making a second pod. Pass your own key when your caller has
13
+ a natural id for the attempt (an order number, a job id) — that is what makes a retry after a
14
+ crash safe rather than merely likely to be safe.
15
+
16
+ **Delete is not instant.** Providers refuse to delete a machine that is still installing, so a
17
+ destroy can come back as ``destroying`` rather than ``destroyed``. ``delete_pod`` reports
18
+ which, and ``wait_for_destroyed`` holds until the machine is genuinely gone. Treating the first
19
+ as the second is how you keep paying for something you thought you deleted.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import hmac
26
+ import re
27
+ import time
28
+ import uuid
29
+ from typing import Any, Dict, List, Optional
30
+
31
+ import httpx
32
+
33
+ from .client import SGLAPIError, SGLAuthError, SGLConnectionError, SGLNotFoundError
34
+
35
+ DEFAULT_PODS_BASE_URL = "https://compute.x402layer.cc"
36
+ DEFAULT_TIMEOUT = 120.0
37
+
38
+
39
+ class PodsClient:
40
+ """Client for the Agent Pods API.
41
+
42
+ Args:
43
+ api_key: mint one in the dashboard under Settings -> API Keys.
44
+ base_url: override for testing against a local worker.
45
+ timeout: seconds. Creating a pod is the slow call; it provisions a machine.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ api_key: str,
51
+ base_url: str = DEFAULT_PODS_BASE_URL,
52
+ timeout: float = DEFAULT_TIMEOUT,
53
+ ) -> None:
54
+ if not api_key:
55
+ raise SGLAuthError(401, "An api_key is required. Mint one under Settings -> API Keys.")
56
+ self.api_key = api_key
57
+ self.base_url = base_url.rstrip("/")
58
+ self.timeout = timeout
59
+ self._client = httpx.Client(timeout=timeout)
60
+
61
+ def __enter__(self) -> "PodsClient":
62
+ return self
63
+
64
+ def __exit__(self, *_: Any) -> None:
65
+ self.close()
66
+
67
+ def close(self) -> None:
68
+ self._client.close()
69
+
70
+ # ── transport ──────────────────────────────────────────────────────────────────────
71
+
72
+ def _request(
73
+ self,
74
+ method: str,
75
+ path: str,
76
+ json: Optional[Dict[str, Any]] = None,
77
+ extra_headers: Optional[Dict[str, str]] = None,
78
+ ) -> Any:
79
+ headers = {"X-API-Key": self.api_key, "Accept": "application/json"}
80
+ if extra_headers:
81
+ headers.update(extra_headers)
82
+ try:
83
+ resp = self._client.request(
84
+ method, f"{self.base_url}/pods/v1{path}", json=json, headers=headers
85
+ )
86
+ except httpx.RequestError as exc:
87
+ raise SGLConnectionError(f"Could not reach the pods API: {exc}") from exc
88
+
89
+ try:
90
+ parsed = resp.json() if resp.content else None
91
+ except ValueError:
92
+ parsed = None
93
+
94
+ if resp.status_code >= 400:
95
+ err = (parsed or {}).get("error", {}) if isinstance(parsed, dict) else {}
96
+ message = err.get("message") or f"HTTP {resp.status_code}"
97
+ # Echoed on every response, and the fastest way for us to find your request in our
98
+ # logs — so it travels with the error rather than being dropped.
99
+ request_id = resp.headers.get("x-request-id")
100
+ if request_id:
101
+ message = f"{message} (request id {request_id})"
102
+ body = {"code": err.get("code"), "details": err.get("details"), "request_id": request_id}
103
+ if resp.status_code in (401, 403):
104
+ raise SGLAuthError(resp.status_code, message, body)
105
+ if resp.status_code == 404:
106
+ raise SGLNotFoundError(resp.status_code, message, body)
107
+ raise SGLAPIError(resp.status_code, message, body)
108
+ return parsed
109
+
110
+ # ── pods ───────────────────────────────────────────────────────────────────────────
111
+
112
+ def create_pod(
113
+ self,
114
+ tier: str = "starter",
115
+ name: Optional[str] = None,
116
+ external_ref: Optional[str] = None,
117
+ model: Optional[str] = None,
118
+ system_prompt: Optional[str] = None,
119
+ region: Optional[str] = None,
120
+ term_hours: Optional[int] = None,
121
+ capabilities: Optional[Dict[str, bool]] = None,
122
+ ai: Optional[Dict[str, str]] = None,
123
+ idempotency_key: Optional[str] = None,
124
+ ) -> Dict[str, Any]:
125
+ """Create a pod. Provisions a machine and charges for it.
126
+
127
+ Returns as soon as the order exists, with ``status == "provisioning"``; the machine
128
+ takes a few minutes to boot. Use :meth:`wait_for_online` if you need to wait.
129
+
130
+ ``external_ref`` is YOUR id for this pod. It comes back on reads and filters
131
+ :meth:`list_pods`, so you can find a pod again from your own database without storing
132
+ ours.
133
+ """
134
+ body: Dict[str, Any] = {"tier": tier}
135
+ for key, value in (
136
+ ("name", name), ("external_ref", external_ref), ("model", model),
137
+ ("system_prompt", system_prompt), ("region", region),
138
+ ("term_hours", term_hours), ("capabilities", capabilities), ("ai", ai),
139
+ ):
140
+ if value is not None:
141
+ body[key] = value
142
+ return self._request(
143
+ "POST", "/pods", body,
144
+ {"Idempotency-Key": idempotency_key or f"sdk-{uuid.uuid4()}"},
145
+ )["pod"]
146
+
147
+ def get_pod(self, pod_id: str) -> Dict[str, Any]:
148
+ return self._request("GET", f"/pods/{pod_id}")["pod"]
149
+
150
+ def list_pods(
151
+ self,
152
+ limit: Optional[int] = None,
153
+ cursor: Optional[str] = None,
154
+ external_ref: Optional[str] = None,
155
+ ) -> Dict[str, Any]:
156
+ """List pods, newest first. ``external_ref`` filters by your own id."""
157
+ parts = []
158
+ if limit:
159
+ parts.append(f"limit={limit}")
160
+ if cursor:
161
+ parts.append(f"cursor={cursor}")
162
+ if external_ref:
163
+ parts.append(f"external_ref={external_ref}")
164
+ query = f"?{'&'.join(parts)}" if parts else ""
165
+ return self._request("GET", f"/pods{query}")
166
+
167
+ def update_pod(self, pod_id: str, **patch: Any) -> Dict[str, Any]:
168
+ """Change ``name``, ``model``, ``slug`` or ``auto_renew``."""
169
+ return self._request("PATCH", f"/pods/{pod_id}", patch)["pod"]
170
+
171
+ def delete_pod(self, pod_id: str) -> Dict[str, Any]:
172
+ """Destroy a pod.
173
+
174
+ ``destroyed`` means the machine is confirmed gone. ``destroying`` means the provider
175
+ would not delete it yet — usually because it is still installing — and a sweep will
176
+ retry; it may bill for a few more minutes. Do not treat the second as the first.
177
+ """
178
+ res = self._request("DELETE", f"/pods/{pod_id}")
179
+ return {"status": res["pod"]["status"], "message": res.get("message", "")}
180
+
181
+ def wait_for_online(
182
+ self, pod_id: str, timeout: float = 900.0, interval: float = 10.0
183
+ ) -> Dict[str, Any]:
184
+ """Poll until the pod is online, or raise when it lands somewhere it cannot leave."""
185
+ return self._wait(pod_id, {"online"}, {"destroyed", "destroying"}, timeout, interval)
186
+
187
+ def wait_for_destroyed(
188
+ self, pod_id: str, timeout: float = 900.0, interval: float = 10.0
189
+ ) -> Dict[str, Any]:
190
+ """Poll until the machine is genuinely gone, not merely accepted for teardown."""
191
+ return self._wait(pod_id, {"destroyed"}, set(), timeout, interval)
192
+
193
+ def _wait(
194
+ self,
195
+ pod_id: str,
196
+ done: set,
197
+ fatal: set,
198
+ timeout: float,
199
+ interval: float,
200
+ ) -> Dict[str, Any]:
201
+ deadline = time.monotonic() + timeout
202
+ while True:
203
+ pod = self.get_pod(pod_id)
204
+ status = pod.get("status")
205
+ if status in done:
206
+ return pod
207
+ if status in fatal:
208
+ raise SGLAPIError(409, f"Pod {pod_id} is {status}; it will not come online.")
209
+ if time.monotonic() >= deadline:
210
+ raise SGLAPIError(504, f"Timed out waiting for pod {pod_id}; it is {status}.")
211
+ time.sleep(interval)
212
+
213
+ # ── talking to a pod ───────────────────────────────────────────────────────────────
214
+
215
+ def endpoint_url(self, pod: Any) -> str:
216
+ """The pod's OpenAI-compatible base URL.
217
+
218
+ Point an OpenAI client at this with a key from :meth:`mint_pod_key` and use the model
219
+ id ``agent-pod``. Anything that already speaks OpenAI works unchanged::
220
+
221
+ from openai import OpenAI
222
+ key = pods.mint_pod_key(pod["id"])["secret"]
223
+ oa = OpenAI(base_url=pods.endpoint_url(pod), api_key=key)
224
+ oa.chat.completions.create(model="agent-pod", messages=[...])
225
+ """
226
+ if isinstance(pod, str):
227
+ return f"{self.base_url}/pods/{pod}/v1"
228
+ endpoint = pod.get("endpoint") or {}
229
+ return endpoint.get("base_url") or f"{self.base_url}/pods/{pod['id']}/v1"
230
+
231
+ def mint_pod_key(
232
+ self, pod_id: str, label: Optional[str] = None, daily_cap_usd: Optional[float] = None
233
+ ) -> Dict[str, Any]:
234
+ """Mint a key for the pod's own endpoint. The secret is returned ONCE."""
235
+ body: Dict[str, Any] = {}
236
+ if label is not None:
237
+ body["label"] = label
238
+ if daily_cap_usd is not None:
239
+ body["daily_cap_usd"] = daily_cap_usd
240
+ return self._request("POST", f"/pods/{pod_id}/keys", body)["key"]
241
+
242
+ def list_pod_keys(self, pod_id: str) -> List[Dict[str, Any]]:
243
+ return self._request("GET", f"/pods/{pod_id}/keys")["keys"]
244
+
245
+ def revoke_pod_key(self, pod_id: str, key_id: str) -> None:
246
+ self._request("DELETE", f"/pods/{pod_id}/keys/{key_id}")
247
+
248
+ # ── operating a pod ────────────────────────────────────────────────────────────────
249
+
250
+ def usage(self, pod_id: str) -> Dict[str, Any]:
251
+ return self._request("GET", f"/pods/{pod_id}/usage")["usage"]
252
+
253
+ def queue_action(self, pod_id: str, action: str) -> Dict[str, Any]:
254
+ """Queue ``restart``, ``stop``, ``redeploy``, ``update``, ``diagnose`` or ``logs``.
255
+
256
+ Applied on the pod's next check-in, usually within a minute, which is why this returns
257
+ 202 rather than pretending it already happened. ``diagnose`` and ``logs`` write their
258
+ output back; read it from :meth:`get_actions`.
259
+ """
260
+ return self._request("POST", f"/pods/{pod_id}/actions", {"action": action})["action"]
261
+
262
+ def get_actions(self, pod_id: str) -> Dict[str, Any]:
263
+ return self._request("GET", f"/pods/{pod_id}/actions")["actions"]
264
+
265
+ def list_tasks(self, pod_id: str) -> Dict[str, Any]:
266
+ return self._request("GET", f"/pods/{pod_id}/tasks")
267
+
268
+ def add_task(
269
+ self,
270
+ pod_id: str,
271
+ name: str,
272
+ kind: str,
273
+ schedule: str,
274
+ message: Optional[str] = None,
275
+ session: Optional[str] = None,
276
+ ) -> Dict[str, Any]:
277
+ body: Dict[str, Any] = {"name": name, "kind": kind, "schedule": schedule}
278
+ if message is not None:
279
+ body["message"] = message
280
+ if session is not None:
281
+ body["session"] = session
282
+ return self._request("POST", f"/pods/{pod_id}/tasks", body)
283
+
284
+ def remove_task(self, pod_id: str, job_id: str) -> Dict[str, Any]:
285
+ return self._request("DELETE", f"/pods/{pod_id}/tasks/{job_id}")
286
+
287
+ def get_wallet(self, pod_id: str) -> Dict[str, Any]:
288
+ return self._request("GET", f"/pods/{pod_id}/wallet")["wallet"]
289
+
290
+ def update_wallet(self, pod_id: str, **patch: Any) -> Dict[str, Any]:
291
+ """Change a pod wallet's spend controls.
292
+
293
+ Needs ``pods:wallet:write`` — a general key that manages pods must not be able to
294
+ raise the cap on the money it can spend. The field is ``per_tx_cap_usd``, not
295
+ ``spend_cap_usd``; the API lists the editable names if you get it wrong.
296
+ """
297
+ return self._request("PATCH", f"/pods/{pod_id}/wallet", patch)
298
+
299
+ def add_connector(
300
+ self,
301
+ pod_id: str,
302
+ name: str,
303
+ url: str,
304
+ transport: Optional[str] = None,
305
+ headers: Optional[Dict[str, str]] = None,
306
+ ) -> List[Dict[str, Any]]:
307
+ """Attach an MCP connector, giving the agent a new tool.
308
+
309
+ Needs ``pods:control:write`` for the same reason: handing an agent new tools is not
310
+ something a general-purpose key should do.
311
+ """
312
+ body: Dict[str, Any] = {"name": name, "url": url}
313
+ if transport is not None:
314
+ body["transport"] = transport
315
+ if headers is not None:
316
+ body["headers"] = headers
317
+ return self._request("POST", f"/pods/{pod_id}/connectors", body)["connectors"]
318
+
319
+ def remove_connector(self, pod_id: str, connector_id: str) -> List[Dict[str, Any]]:
320
+ return self._request("DELETE", f"/pods/{pod_id}/connectors/{connector_id}")["connectors"]
321
+
322
+ def update_backups(
323
+ self, pod_id: str, enabled: bool, passphrase: Optional[str] = None
324
+ ) -> Dict[str, Any]:
325
+ """Turn automatic backups on or off. Setting a passphrase needs ``pods:wallet:write``."""
326
+ body: Dict[str, Any] = {"enabled": enabled}
327
+ if passphrase is not None:
328
+ body["passphrase"] = passphrase
329
+ return self._request("PATCH", f"/pods/{pod_id}/backups", body)["backups"]
330
+
331
+ def telegram_join_code(self, pod_id: str) -> Dict[str, Any]:
332
+ """Mint a Telegram join code for a group."""
333
+ return self._request("POST", f"/pods/{pod_id}/channels/telegram/join-code")["join"]
334
+
335
+ def telegram_join_status(self, pod_id: str) -> Dict[str, Any]:
336
+ """Poll whether a group has claimed the code. Never echoes the code back."""
337
+ return self._request("GET", f"/pods/{pod_id}/channels/telegram/join-code")["join"]
338
+
339
+ def get_update_policy(self, pod_id: str) -> Dict[str, Any]:
340
+ """Who decides WHEN this pod takes our updates.
341
+
342
+ By default we do: the pod polls every six hours and applies whatever we answer,
343
+ restarting its gateway for about forty seconds. Fine for a pod you run for yourself,
344
+ wrong for pods you run for customers who did not choose that moment.
345
+ """
346
+ return self._request("GET", f"/pods/{pod_id}/updates")["updates"]
347
+
348
+ def set_update_policy(self, pod_id: str, mode: str) -> Dict[str, Any]:
349
+ """``auto`` or ``manual``.
350
+
351
+ ``manual`` makes us keep answering with the version the pod already has, so it never
352
+ updates itself; you apply it with ``queue_action(pod_id, "update")`` when it suits
353
+ you. The hold expires after 30 days, because security fixes ride these bundles, and
354
+ the response always names the date so it is never a surprise.
355
+ """
356
+ return self._request("PATCH", f"/pods/{pod_id}/updates", {"mode": mode})["updates"]
357
+
358
+ def chat_ticket(self, pod_id: str) -> Dict[str, Any]:
359
+ """A short-lived ticket for the pod's streaming chat socket.
360
+
361
+ ``scope`` says what the socket will accept. Without ``pods:control:write`` it is
362
+ ``chat``, conversation only. Most integrations want the OpenAI endpoint instead.
363
+ """
364
+ return self._request("POST", f"/pods/{pod_id}/chat-ticket")["chat"]
365
+
366
+ def connect_telegram_group(
367
+ self, pod_id: str, require_mention: Optional[bool] = None, prompt: Optional[str] = None
368
+ ) -> Dict[str, Any]:
369
+ """Attach the Telegram group that claimed the join code.
370
+
371
+ Takes no chat id: it attaches only the group that claimed the code, because a claim
372
+ proves somebody typed it inside that room. Existing groups are preserved.
373
+ """
374
+ body: Dict[str, Any] = {}
375
+ if require_mention is not None:
376
+ body["require_mention"] = require_mention
377
+ if prompt is not None:
378
+ body["prompt"] = prompt
379
+ return self._request("POST", f"/pods/{pod_id}/channels/telegram/connect", body)["channel"]
380
+
381
+ def approve_pairing(self, pod_id: str, code: str, channel: str = "telegram") -> Dict[str, Any]:
382
+ """Approve someone to DM the agent.
383
+
384
+ A stranger who finds the bot can DM it, and unlike a group nobody else sees that
385
+ conversation, so the agent refuses unknown people and shows them a code. The code must
386
+ come from the agent, so this approves a request somebody already made.
387
+ """
388
+ return self._request("POST", f"/pods/{pod_id}/channels/{channel}/pair", {"code": code})["pairing"]
389
+
390
+ def wallet_send(self, pod_id: str, **body: Any) -> Dict[str, Any]:
391
+ """Move funds out of the pod's wallet. Needs ``pods:wallet:write``.
392
+
393
+ The spend cap is enforced server-side before the transfer and the pod holds no keys,
394
+ so this cannot exceed the policy set by :meth:`update_wallet`.
395
+ """
396
+ return self._request("POST", f"/pods/{pod_id}/wallet/send", body)
397
+
398
+ def wallet_pay_x402(self, pod_id: str, **body: Any) -> Dict[str, Any]:
399
+ """Pay an x402 endpoint from the pod's wallet. Needs ``pods:wallet:write``."""
400
+ return self._request("POST", f"/pods/{pod_id}/wallet/x402/pay", body)
401
+
402
+ def list_connectors(self, pod_id: str) -> List[Dict[str, Any]]:
403
+ return self._request("GET", f"/pods/{pod_id}/connectors")["connectors"]
404
+
405
+ def get_backups(self, pod_id: str) -> Dict[str, Any]:
406
+ return self._request("GET", f"/pods/{pod_id}/backups")["backups"]
407
+
408
+ # ── events and webhooks ────────────────────────────────────────────────────────────
409
+
410
+ def list_events(
411
+ self,
412
+ after: Optional[int] = None,
413
+ limit: Optional[int] = None,
414
+ type: Optional[str] = None,
415
+ pod_id: Optional[str] = None,
416
+ ) -> Dict[str, Any]:
417
+ """Page the event log.
418
+
419
+ Paged by ``seq``, an integer that only goes up, NOT by timestamp — two events can
420
+ share a millisecond, and a timestamp cursor would either skip one or repeat it
421
+ forever. Store the ``next_after`` you get back and pass it as ``after`` next time.
422
+
423
+ This is also the answer to a missed webhook: delivery is a cursor over these same
424
+ rows, so anything a broken endpoint dropped is still here.
425
+ """
426
+ parts = []
427
+ if after is not None:
428
+ parts.append(f"after={after}")
429
+ if limit:
430
+ parts.append(f"limit={limit}")
431
+ if type:
432
+ parts.append(f"type={type}")
433
+ if pod_id:
434
+ parts.append(f"pod_id={pod_id}")
435
+ query = f"?{'&'.join(parts)}" if parts else ""
436
+ return self._request("GET", f"/events{query}")
437
+
438
+ def create_webhook(
439
+ self, url: str, event_types: Optional[List[str]] = None
440
+ ) -> Dict[str, Any]:
441
+ """Register a webhook. HTTPS only.
442
+
443
+ The signing ``secret`` comes back ONCE. Store it — it is how you verify a delivery
444
+ really came from us. Omit ``event_types`` to receive everything; an empty list is
445
+ refused, because a subscription to nothing is a webhook that silently never fires.
446
+ """
447
+ body: Dict[str, Any] = {"url": url}
448
+ if event_types is not None:
449
+ body["event_types"] = event_types
450
+ return self._request("POST", "/webhooks", body)["webhook"]
451
+
452
+ def list_webhooks(self) -> List[Dict[str, Any]]:
453
+ return self._request("GET", "/webhooks")["webhooks"]
454
+
455
+ def update_webhook(self, webhook_id: str, **patch: Any) -> Dict[str, Any]:
456
+ return self._request("PATCH", f"/webhooks/{webhook_id}", patch)["webhook"]
457
+
458
+ def delete_webhook(self, webhook_id: str) -> None:
459
+ self._request("DELETE", f"/webhooks/{webhook_id}")
460
+
461
+
462
+ _SIG_RE = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")
463
+
464
+
465
+ def verify_pod_webhook(
466
+ raw_body: str,
467
+ signature_header: str,
468
+ secret: str,
469
+ tolerance_sec: int = 300,
470
+ now: Optional[float] = None,
471
+ ) -> bool:
472
+ """Verify a webhook delivery came from us and is recent.
473
+
474
+ Pass the RAW request body, not a re-serialised object: re-encoding JSON changes bytes
475
+ (key order, spacing) and the signature is over the bytes we sent.
476
+
477
+ The timestamp is inside the signed string, so a captured delivery cannot be replayed later
478
+ under a fresh one. ``tolerance_sec`` is what stops an old capture being accepted at all.
479
+ """
480
+ match = _SIG_RE.match((signature_header or "").strip())
481
+ if not match:
482
+ return False
483
+ timestamp = int(match.group(1))
484
+ current = int(now if now is not None else time.time())
485
+ if abs(current - timestamp) > tolerance_sec:
486
+ return False
487
+ expected = hmac.new(
488
+ secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
489
+ ).hexdigest()
490
+ # compare_digest, not ==: a short-circuiting compare leaks how much of a forged signature
491
+ # was right, which is enough to reconstruct one byte at a time.
492
+ return hmac.compare_digest(match.group(2), expected)
@@ -0,0 +1,50 @@
1
+ """The wire format for webhook signatures, pinned against a vector the SERVER produced.
2
+
3
+ The signature is generated by the worker and verified here. Writing both halves from the same
4
+ understanding is how two sides agree with each other and disagree with reality, so the vector
5
+ below was emitted by the real `signWebhook` in the backend rather than recomputed here.
6
+ """
7
+
8
+ import time
9
+
10
+ from singularity_grid import verify_pod_webhook
11
+
12
+ # Emitted by SGLCompute_Backend `signWebhook` (src/lib/pod-events.ts).
13
+ SECRET = "whsec_" + "a" * 48
14
+ BODY = '{"event":{"id":"e1","seq":7,"type":"pod.destroyed"}}'
15
+ TS = 1788000000
16
+ SIG = "t=1788000000,v1=367d429c4547093ae53a97b3bf5d8b62670e39676709fda0fb3748279dc71531"
17
+
18
+ # Evaluated as if it were moments after signing, so the age check is not what is under test.
19
+ NOW = TS + 5
20
+
21
+
22
+ def test_accepts_a_signature_the_server_actually_produced():
23
+ assert verify_pod_webhook(BODY, SIG, SECRET, now=NOW) is True
24
+
25
+
26
+ def test_rejects_a_re_encoded_body():
27
+ # Re-serialising JSON changes key order and spacing. A partner who parses then re-encodes
28
+ # would fail to verify a genuine delivery and conclude our signatures are broken, so this
29
+ # documents that the RAW bytes are what must be passed.
30
+ import json
31
+
32
+ reencoded = json.dumps(json.loads(BODY), indent=2)
33
+ assert verify_pod_webhook(reencoded, SIG, SECRET, now=NOW) is False
34
+
35
+
36
+ def test_rejects_wrong_secret_and_tampered_body():
37
+ assert verify_pod_webhook(BODY, SIG, "whsec_" + "b" * 48, now=NOW) is False
38
+ assert verify_pod_webhook(BODY + " ", SIG, SECRET, now=NOW) is False
39
+
40
+
41
+ def test_rejects_a_genuine_signature_that_is_too_old():
42
+ # The timestamp is inside the signed string, so an old capture cannot be replayed under a
43
+ # fresh one — but only if the receiver enforces the window.
44
+ assert verify_pod_webhook(BODY, SIG, SECRET, now=TS + 4000) is False
45
+ assert verify_pod_webhook(BODY, SIG, SECRET, now=TS + 10) is True
46
+
47
+
48
+ def test_rejects_a_malformed_header_instead_of_raising():
49
+ for bad in ["", "nonsense", "t=abc,v1=def", "t=1,v1=short", "v1=" + "a" * 64, None]:
50
+ assert verify_pod_webhook(BODY, bad, SECRET, now=NOW) is False