singularity-grid 0.9.1__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.1 → singularity_grid-0.9.2}/PKG-INFO +37 -1
  2. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/README.md +36 -0
  3. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/pyproject.toml +1 -1
  4. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/__init__.py +1 -1
  5. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/client.py +78 -0
  6. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/e2e.py +77 -0
  7. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/.github/workflows/publish.yml +0 -0
  8. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/.gitignore +0 -0
  9. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/LICENSE +0 -0
  10. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/examples/check_capacity.py +0 -0
  11. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/examples/openai_compatible.py +0 -0
  12. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/examples/quickstart.py +0 -0
  13. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/models.py +0 -0
  14. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/openai_compat.py +0 -0
  15. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/pods.py +0 -0
  16. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/processors.py +0 -0
  17. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/py.typed +0 -0
  18. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/singularity_grid/vault.py +0 -0
  19. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/tests/test_pod_webhook.py +0 -0
  20. {singularity_grid-0.9.1 → singularity_grid-0.9.2}/tests/test_processors.py +0 -0
  21. {singularity_grid-0.9.1 → 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.1
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.1"
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"
@@ -29,7 +29,7 @@ from .models import (
29
29
  )
30
30
  from .openai_compat import create_openai_client
31
31
 
32
- __version__ = "0.2.0"
32
+ __version__ = "0.9.2"
33
33
 
34
34
  __all__ = [
35
35
  # Processors — a SEPARATE client on processors.x402compute.cc. Until 0.9.0 these methods
@@ -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))