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.
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/PKG-INFO +37 -1
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/README.md +36 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/pyproject.toml +1 -1
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/__init__.py +10 -1
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/client.py +78 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/e2e.py +77 -0
- singularity_grid-0.9.2/singularity_grid/pods.py +492 -0
- singularity_grid-0.9.2/tests/test_pod_webhook.py +50 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/.github/workflows/publish.yml +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/.gitignore +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/LICENSE +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/examples/check_capacity.py +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/examples/openai_compatible.py +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/examples/quickstart.py +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/models.py +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/openai_compat.py +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/processors.py +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/py.typed +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/singularity_grid/vault.py +0 -0
- {singularity_grid-0.9.0 → singularity_grid-0.9.2}/tests/test_processors.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|