cyberian-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyberian-client
3
+ Version: 0.1.0
4
+ Summary: Python client for the Cyberian Systems verified-inference API
5
+ Author-email: Cyberian Systems <philippe@cyberiansystems.ai>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://cyberiansystems.ai
8
+ Project-URL: Documentation, https://cyberiansystems.ai/docs
9
+ Project-URL: Support, https://cyberiansystems.ai/docs
10
+ Keywords: ai,ml,verified-inference,merkle,embeddings,cyberian
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: httpx<1.0,>=0.27.0
24
+ Requires-Dist: numpy<3.0,>=1.26.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
28
+
29
+ # cyberian-client
30
+
31
+ Python client for the [Cyberian Systems](https://cyberiansystems.ai) verified-inference API.
32
+
33
+ Each call returns embeddings + a cryptographic receipt that proves an independent prover
34
+ re-executed a sample of your batch and got bitwise-identical results. The receipt is
35
+ self-verifying: any third party can validate it without access to the platform.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install cyberian-client
41
+ ```
42
+
43
+ Requires Python 3.9+. Pulls in `httpx` and `numpy`.
44
+
45
+ ## Get an API key
46
+
47
+ [https://cyberiansystems.ai/signup](https://cyberiansystems.ai/signup) — 14-day free
48
+ trial, no card required. The plaintext key is shown once at signup; save it. We store
49
+ only its SHA-256.
50
+
51
+ ## Quickstart — one shot
52
+
53
+ ```python
54
+ from cyberian import Client
55
+
56
+ client = Client(api_key="cyb_trial_…") # or set CYBERIAN_API_KEY in env and pass it in
57
+
58
+ result = client.submit_and_wait(
59
+ spec_yaml=open("spec.yaml").read(),
60
+ input_texts=[
61
+ "The property title search revealed no encumbrances.",
62
+ "Quarterly compliance attestation per SOX 404.",
63
+ ],
64
+ )
65
+
66
+ print("receipt_hash:", result.receipt["receipt_hash"])
67
+ print("verified: ", result.verification["valid"]) # True
68
+ print("shape: ", result.embeddings.shape) # (2, 384) for BGE-small
69
+ print("first vec: ", result.embeddings[0, :8])
70
+ ```
71
+
72
+ ## Step-by-step — when you need finer control
73
+
74
+ ```python
75
+ job = client.submit_job(spec_yaml="spec.yaml", input_texts=["a", "b", "c"])
76
+
77
+ # Poll yourself, or use wait_for_completion:
78
+ final = client.wait_for_completion(job["id"], poll_interval_sec=2.0, timeout_sec=600)
79
+
80
+ receipt = client.get_receipt(final["receipt_id"])
81
+ verification = client.verify_receipt(final["receipt_id"])
82
+ embeddings = client.get_job_output(final["id"]) # numpy ndarray, float32
83
+ ```
84
+
85
+ ## Verification as a Service (VaaS)
86
+
87
+ You ran inference on your own infrastructure. Cyberian re-runs it and certifies the
88
+ SHA-256 commitment of your output matches. Useful for compliance — you keep the
89
+ inference, we provide independent auditable verification.
90
+
91
+ ```python
92
+ import hashlib
93
+
94
+ # Your in-house inference output:
95
+ my_embeddings_bytes = my_pipeline.run(["text"]).astype("float32").tobytes()
96
+ my_commitment = hashlib.sha256(my_embeddings_bytes).hexdigest()
97
+
98
+ receipt = client.verify_outputs(
99
+ spec_yaml="spec.yaml",
100
+ input_texts=["text"],
101
+ claimed_output_commitment=my_commitment,
102
+ )
103
+ # Raises VerificationFailedError if our prover's re-execution doesn't match yours.
104
+ ```
105
+
106
+ Phase 2: VaaS is single-chunk only — `len(input_texts) <= spec.chunking.chunk_size`.
107
+
108
+ ## Account management
109
+
110
+ ```python
111
+ info = client.get_account()
112
+ print(info["tier"], info["effective_tier"], info["subscription_status"])
113
+ print(info["chunks_consumed_period"], "/", info["limits"]["chunks_per_period"])
114
+
115
+ # Mint an additional key (old one stays valid until you revoke it)
116
+ new_key = client.rotate_key()
117
+
118
+ # Revoke a specific key by its 12-char key_id (the prefix shown in /account)
119
+ client.revoke_key(key_id="a1b2c3d4e5f6")
120
+ ```
121
+
122
+ ## Error handling
123
+
124
+ Typed exceptions; catch the most specific one you care about.
125
+
126
+ ```python
127
+ from cyberian import (
128
+ Client,
129
+ AuthError, # 401, 403
130
+ TrialExpiredError, # 402 — email upgrade@cyberiansystems.ai
131
+ QuotaError, # 402 / 413
132
+ RateLimitError, # 429 — has .retry_after_sec
133
+ ServiceBusyError, # 503 — has .retry_after_sec
134
+ JobFailedError, # job ended in FAILED state during wait_for_completion
135
+ VerificationFailedError, # /verify returned valid=False
136
+ ApiError, # any other 4xx/5xx
137
+ CyberianError, # base of everything above
138
+ )
139
+
140
+ try:
141
+ result = client.submit_and_wait(spec_yaml=…, input_texts=[…])
142
+ except RateLimitError as exc:
143
+ time.sleep(exc.retry_after_sec)
144
+ result = client.submit_and_wait(spec_yaml=…, input_texts=[…])
145
+ except TrialExpiredError:
146
+ print("Email upgrade@cyberiansystems.ai for an extension or enterprise tier.")
147
+ raise
148
+ ```
149
+
150
+ ## Trial limits (Phase 2)
151
+
152
+ | | trial (default) | free (post-trial) |
153
+ |---|---|---|
154
+ | Period | 14 days | 30 days |
155
+ | Chunks per period | 400 | 100 |
156
+ | Chunks per day | 100 | 25 |
157
+ | Requests per minute | 60 | 10 |
158
+ | Max chunks per request | 100 | 50 |
159
+ | Max input texts | 1000 | 500 |
160
+
161
+ A "chunk" is one ONNX inference unit, sized by your CES `chunking.chunk_size`. With
162
+ `chunk_size: 1` each input text is its own chunk; with `chunk_size: 100` a 1000-text
163
+ batch is 10 chunks. Counting chunks (not requests) means the quota tracks actual
164
+ compute consumption.
165
+
166
+ ## Documentation
167
+
168
+ - API + SDK reference: <https://cyberiansystems.ai/docs>
169
+ - Trial terms: <https://cyberiansystems.ai/terms>
170
+ - Privacy: <https://cyberiansystems.ai/privacy>
171
+ - Issues / support: support@cyberiansystems.ai
172
+ - Upgrade / enterprise inquiries: upgrade@cyberiansystems.ai
173
+
174
+ ## Patents
175
+
176
+ The verification mechanism (Canonical Execution Specification, Merkle receipts,
177
+ sample-and-replay verification with game-theoretic deterrence, the four-tuple binding)
178
+ is covered by a U.S. provisional patent. Use of this client does not grant any patent
179
+ license beyond what's needed to call the API as documented.
@@ -0,0 +1,151 @@
1
+ # cyberian-client
2
+
3
+ Python client for the [Cyberian Systems](https://cyberiansystems.ai) verified-inference API.
4
+
5
+ Each call returns embeddings + a cryptographic receipt that proves an independent prover
6
+ re-executed a sample of your batch and got bitwise-identical results. The receipt is
7
+ self-verifying: any third party can validate it without access to the platform.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install cyberian-client
13
+ ```
14
+
15
+ Requires Python 3.9+. Pulls in `httpx` and `numpy`.
16
+
17
+ ## Get an API key
18
+
19
+ [https://cyberiansystems.ai/signup](https://cyberiansystems.ai/signup) — 14-day free
20
+ trial, no card required. The plaintext key is shown once at signup; save it. We store
21
+ only its SHA-256.
22
+
23
+ ## Quickstart — one shot
24
+
25
+ ```python
26
+ from cyberian import Client
27
+
28
+ client = Client(api_key="cyb_trial_…") # or set CYBERIAN_API_KEY in env and pass it in
29
+
30
+ result = client.submit_and_wait(
31
+ spec_yaml=open("spec.yaml").read(),
32
+ input_texts=[
33
+ "The property title search revealed no encumbrances.",
34
+ "Quarterly compliance attestation per SOX 404.",
35
+ ],
36
+ )
37
+
38
+ print("receipt_hash:", result.receipt["receipt_hash"])
39
+ print("verified: ", result.verification["valid"]) # True
40
+ print("shape: ", result.embeddings.shape) # (2, 384) for BGE-small
41
+ print("first vec: ", result.embeddings[0, :8])
42
+ ```
43
+
44
+ ## Step-by-step — when you need finer control
45
+
46
+ ```python
47
+ job = client.submit_job(spec_yaml="spec.yaml", input_texts=["a", "b", "c"])
48
+
49
+ # Poll yourself, or use wait_for_completion:
50
+ final = client.wait_for_completion(job["id"], poll_interval_sec=2.0, timeout_sec=600)
51
+
52
+ receipt = client.get_receipt(final["receipt_id"])
53
+ verification = client.verify_receipt(final["receipt_id"])
54
+ embeddings = client.get_job_output(final["id"]) # numpy ndarray, float32
55
+ ```
56
+
57
+ ## Verification as a Service (VaaS)
58
+
59
+ You ran inference on your own infrastructure. Cyberian re-runs it and certifies the
60
+ SHA-256 commitment of your output matches. Useful for compliance — you keep the
61
+ inference, we provide independent auditable verification.
62
+
63
+ ```python
64
+ import hashlib
65
+
66
+ # Your in-house inference output:
67
+ my_embeddings_bytes = my_pipeline.run(["text"]).astype("float32").tobytes()
68
+ my_commitment = hashlib.sha256(my_embeddings_bytes).hexdigest()
69
+
70
+ receipt = client.verify_outputs(
71
+ spec_yaml="spec.yaml",
72
+ input_texts=["text"],
73
+ claimed_output_commitment=my_commitment,
74
+ )
75
+ # Raises VerificationFailedError if our prover's re-execution doesn't match yours.
76
+ ```
77
+
78
+ Phase 2: VaaS is single-chunk only — `len(input_texts) <= spec.chunking.chunk_size`.
79
+
80
+ ## Account management
81
+
82
+ ```python
83
+ info = client.get_account()
84
+ print(info["tier"], info["effective_tier"], info["subscription_status"])
85
+ print(info["chunks_consumed_period"], "/", info["limits"]["chunks_per_period"])
86
+
87
+ # Mint an additional key (old one stays valid until you revoke it)
88
+ new_key = client.rotate_key()
89
+
90
+ # Revoke a specific key by its 12-char key_id (the prefix shown in /account)
91
+ client.revoke_key(key_id="a1b2c3d4e5f6")
92
+ ```
93
+
94
+ ## Error handling
95
+
96
+ Typed exceptions; catch the most specific one you care about.
97
+
98
+ ```python
99
+ from cyberian import (
100
+ Client,
101
+ AuthError, # 401, 403
102
+ TrialExpiredError, # 402 — email upgrade@cyberiansystems.ai
103
+ QuotaError, # 402 / 413
104
+ RateLimitError, # 429 — has .retry_after_sec
105
+ ServiceBusyError, # 503 — has .retry_after_sec
106
+ JobFailedError, # job ended in FAILED state during wait_for_completion
107
+ VerificationFailedError, # /verify returned valid=False
108
+ ApiError, # any other 4xx/5xx
109
+ CyberianError, # base of everything above
110
+ )
111
+
112
+ try:
113
+ result = client.submit_and_wait(spec_yaml=…, input_texts=[…])
114
+ except RateLimitError as exc:
115
+ time.sleep(exc.retry_after_sec)
116
+ result = client.submit_and_wait(spec_yaml=…, input_texts=[…])
117
+ except TrialExpiredError:
118
+ print("Email upgrade@cyberiansystems.ai for an extension or enterprise tier.")
119
+ raise
120
+ ```
121
+
122
+ ## Trial limits (Phase 2)
123
+
124
+ | | trial (default) | free (post-trial) |
125
+ |---|---|---|
126
+ | Period | 14 days | 30 days |
127
+ | Chunks per period | 400 | 100 |
128
+ | Chunks per day | 100 | 25 |
129
+ | Requests per minute | 60 | 10 |
130
+ | Max chunks per request | 100 | 50 |
131
+ | Max input texts | 1000 | 500 |
132
+
133
+ A "chunk" is one ONNX inference unit, sized by your CES `chunking.chunk_size`. With
134
+ `chunk_size: 1` each input text is its own chunk; with `chunk_size: 100` a 1000-text
135
+ batch is 10 chunks. Counting chunks (not requests) means the quota tracks actual
136
+ compute consumption.
137
+
138
+ ## Documentation
139
+
140
+ - API + SDK reference: <https://cyberiansystems.ai/docs>
141
+ - Trial terms: <https://cyberiansystems.ai/terms>
142
+ - Privacy: <https://cyberiansystems.ai/privacy>
143
+ - Issues / support: support@cyberiansystems.ai
144
+ - Upgrade / enterprise inquiries: upgrade@cyberiansystems.ai
145
+
146
+ ## Patents
147
+
148
+ The verification mechanism (Canonical Execution Specification, Merkle receipts,
149
+ sample-and-replay verification with game-theoretic deterrence, the four-tuple binding)
150
+ is covered by a U.S. provisional patent. Use of this client does not grant any patent
151
+ license beyond what's needed to call the API as documented.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "cyberian-client"
3
+ version = "0.1.0"
4
+ description = "Python client for the Cyberian Systems verified-inference API"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "Proprietary" }
8
+ authors = [{ name = "Cyberian Systems", email = "philippe@cyberiansystems.ai" }]
9
+ keywords = ["ai", "ml", "verified-inference", "merkle", "embeddings", "cyberian"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "License :: Other/Proprietary License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.9",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.27.0,<1.0",
24
+ "numpy>=1.26.0,<3.0",
25
+ ]
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0",
30
+ "pytest-asyncio>=0.23",
31
+ ]
32
+
33
+ [project.urls]
34
+ Homepage = "https://cyberiansystems.ai"
35
+ Documentation = "https://cyberiansystems.ai/docs"
36
+ Support = "https://cyberiansystems.ai/docs"
37
+
38
+ [build-system]
39
+ requires = ["setuptools>=69.0"]
40
+ build-backend = "setuptools.build_meta"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,30 @@
1
+ """Cyberian Systems — Python client for the verified-inference API."""
2
+
3
+ from .client import Client, JobResult
4
+ from .exceptions import (
5
+ ApiError,
6
+ AuthError,
7
+ CyberianError,
8
+ JobFailedError,
9
+ QuotaError,
10
+ RateLimitError,
11
+ ServiceBusyError,
12
+ TrialExpiredError,
13
+ VerificationFailedError,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "Client",
20
+ "JobResult",
21
+ "CyberianError",
22
+ "AuthError",
23
+ "TrialExpiredError",
24
+ "QuotaError",
25
+ "RateLimitError",
26
+ "ServiceBusyError",
27
+ "JobFailedError",
28
+ "VerificationFailedError",
29
+ "ApiError",
30
+ ]
@@ -0,0 +1,348 @@
1
+ """
2
+ Cyberian Systems client — Phase 2 SDK.
3
+
4
+ The Bearer key is the credential. There's no separate login; the key
5
+ is granted by /auth/register (use the /signup page on the website) or
6
+ issued by an operator.
7
+
8
+ Quick start:
9
+
10
+ from cyberian import Client
11
+
12
+ c = Client(api_key="cyb_trial_…")
13
+
14
+ # One-shot: submit, wait, fetch receipt + outputs
15
+ result = c.submit_and_wait(
16
+ spec_yaml=open("spec.yaml").read(),
17
+ input_texts=["sentence one", "sentence two"],
18
+ )
19
+ print(result.receipt["receipt_hash"])
20
+ print(result.embeddings.shape) # (2, 384) for BGE-small
21
+
22
+ # Or step-by-step
23
+ job = c.submit_job(spec_yaml=…, input_texts=[…])
24
+ final = c.wait_for_completion(job["id"])
25
+ receipt = c.get_receipt(final["receipt_id"])
26
+ verification = c.verify_receipt(final["receipt_id"])
27
+ embeddings = c.get_job_output(final["id"]) # numpy ndarray
28
+
29
+ # VaaS — verify a commitment for inference you ran yourself
30
+ receipt = c.verify_outputs(
31
+ spec_yaml=…,
32
+ input_texts=[…],
33
+ claimed_output_commitment="sha256-hex-of-your-output",
34
+ )
35
+
36
+ # Account info, key management
37
+ info = c.get_account()
38
+ new_key = c.rotate_key()
39
+ c.revoke_key(key_id="abc123def456")
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import time
44
+ from dataclasses import dataclass
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ import httpx
49
+ import numpy as np
50
+
51
+ from .exceptions import (
52
+ ApiError,
53
+ AuthError,
54
+ CyberianError,
55
+ JobFailedError,
56
+ QuotaError,
57
+ RateLimitError,
58
+ ServiceBusyError,
59
+ TrialExpiredError,
60
+ VerificationFailedError,
61
+ )
62
+
63
+ DEFAULT_API_URL = "https://api.cyberiansystems.ai"
64
+
65
+
66
+ @dataclass
67
+ class JobResult:
68
+ """Returned by submit_and_wait. Bundles the three things customers
69
+ most often need at the end of a job: the final job state, the full
70
+ receipt, and the decoded output embeddings."""
71
+
72
+ job: dict[str, Any]
73
+ receipt: dict[str, Any]
74
+ embeddings: np.ndarray
75
+ verification: dict[str, Any] | None = None
76
+
77
+
78
+ class Client:
79
+ """Synchronous Cyberian Systems API client."""
80
+
81
+ def __init__(
82
+ self,
83
+ api_key: str,
84
+ *,
85
+ base_url: str = DEFAULT_API_URL,
86
+ timeout: float = 60.0,
87
+ http_client: httpx.Client | None = None,
88
+ ):
89
+ if not api_key or not api_key.startswith("cyb_"):
90
+ raise AuthError(
91
+ "api_key must be a Cyberian Bearer key (starts with cyb_). "
92
+ "Mint one via the /signup page or via /auth/register."
93
+ )
94
+ self.base_url = base_url.rstrip("/")
95
+ self._owns_http = http_client is None
96
+ self._http = http_client or httpx.Client(
97
+ base_url=self.base_url,
98
+ timeout=timeout,
99
+ headers={
100
+ "Authorization": f"Bearer {api_key}",
101
+ "Content-Type": "application/json",
102
+ },
103
+ )
104
+
105
+ # ─── core HTTP wrapper ────────────────────────────────────────────────
106
+
107
+ def _request(
108
+ self,
109
+ method: str,
110
+ path: str,
111
+ *,
112
+ json: Any = None,
113
+ expect_json: bool = True,
114
+ ) -> Any:
115
+ try:
116
+ resp = self._http.request(method, path, json=json)
117
+ except httpx.RequestError as exc:
118
+ raise ApiError(f"network error talking to {path}: {exc}") from exc
119
+
120
+ if resp.status_code < 400:
121
+ if not expect_json:
122
+ return resp
123
+ if resp.status_code == 204 or not resp.content:
124
+ return None
125
+ return resp.json()
126
+
127
+ # 4xx/5xx — translate to typed exceptions.
128
+ try:
129
+ body = resp.json()
130
+ except Exception:
131
+ body = {"raw": resp.text}
132
+
133
+ msg = body.get("message") or body.get("error") or f"HTTP {resp.status_code}"
134
+ retry_after = int(resp.headers.get("retry-after", 0) or 0) or body.get("retry_after_sec", 0)
135
+
136
+ if resp.status_code == 401 or resp.status_code == 403:
137
+ raise AuthError(msg, status_code=resp.status_code, body=body)
138
+ if resp.status_code == 402:
139
+ err = (body.get("error") or "").lower()
140
+ if "trial" in err:
141
+ raise TrialExpiredError(msg, status_code=402, body=body)
142
+ raise QuotaError(msg, status_code=402, body=body)
143
+ if resp.status_code == 413:
144
+ raise QuotaError(msg, status_code=413, body=body)
145
+ if resp.status_code == 429:
146
+ raise RateLimitError(msg, retry_after_sec=retry_after or 60, status_code=429, body=body)
147
+ if resp.status_code == 503:
148
+ raise ServiceBusyError(msg, retry_after_sec=retry_after or 30, status_code=503, body=body)
149
+ raise ApiError(msg, status_code=resp.status_code, body=body)
150
+
151
+ # ─── public surface ──────────────────────────────────────────────────
152
+
153
+ def health(self) -> dict[str, Any]:
154
+ """GET /health — connectivity probe; doesn't require auth."""
155
+ return self._request("GET", "/health")
156
+
157
+ def get_account(self) -> dict[str, Any]:
158
+ """GET /account — tier, trial status, usage, key list."""
159
+ return self._request("GET", "/account")
160
+
161
+ def rotate_key(self) -> str:
162
+ """POST /auth/keys/rotate — returns the NEW plaintext key. Old key
163
+ remains valid until you call revoke_key with its key_id."""
164
+ body = self._request("POST", "/auth/keys/rotate")
165
+ return body["api_key"]
166
+
167
+ def revoke_key(self, *, key_id: str) -> None:
168
+ """POST /auth/keys/revoke — invalidate the key with the given 12-
169
+ char key_id prefix. Idempotent."""
170
+ self._request("POST", "/auth/keys/revoke", json={"key_id": key_id})
171
+
172
+ def submit_job(
173
+ self,
174
+ *,
175
+ spec_yaml: str | Path,
176
+ input_texts: list[str],
177
+ ) -> dict[str, Any]:
178
+ """POST /jobs — submit a verified inference batch."""
179
+ spec_text = _read_spec(spec_yaml)
180
+ return self._request(
181
+ "POST",
182
+ "/jobs",
183
+ json={"spec_yaml": spec_text, "input_texts": input_texts},
184
+ )
185
+
186
+ def get_job(self, job_id: str) -> dict[str, Any]:
187
+ """GET /jobs/:id — current job status."""
188
+ return self._request("GET", f"/jobs/{job_id}")
189
+
190
+ def get_job_results(self, job_id: str) -> dict[str, Any]:
191
+ """GET /jobs/:id/results — per-chunk progress (status, commitments)."""
192
+ return self._request("GET", f"/jobs/{job_id}/results")
193
+
194
+ def get_job_output(self, job_id: str) -> np.ndarray:
195
+ """GET /jobs/:id/output — decoded float32 embeddings as a 2D ndarray.
196
+
197
+ Shape is (total_input_texts, output_dimensions). Only available
198
+ after the job reaches SETTLED.
199
+ """
200
+ resp = self._request("GET", f"/jobs/{job_id}/output", expect_json=False)
201
+ shape_h = resp.headers.get("x-cyberian-shape", "")
202
+ try:
203
+ rows, cols = (int(p.strip()) for p in shape_h.split(","))
204
+ except Exception as exc:
205
+ raise ApiError(f"unexpected x-cyberian-shape header: {shape_h!r}") from exc
206
+ return np.frombuffer(resp.content, dtype=np.float32).reshape(rows, cols)
207
+
208
+ def get_receipt(self, receipt_id: str) -> dict[str, Any]:
209
+ """GET /receipts/:id — full Merkle receipt JSON."""
210
+ return self._request("GET", f"/receipts/{receipt_id}")
211
+
212
+ def verify_receipt(self, receipt_id: str) -> dict[str, Any]:
213
+ """GET /receipts/:id/verify — coordinator re-verifies the receipt's
214
+ self-referential hash and the input/output Merkle roots from
215
+ stored chunk commitments. Returns
216
+ {valid, checks: {receipt_hash, input_root, output_root, proof_root}, errors}.
217
+ """
218
+ return self._request("GET", f"/receipts/{receipt_id}/verify")
219
+
220
+ def wait_for_completion(
221
+ self,
222
+ job_id: str,
223
+ *,
224
+ poll_interval_sec: float = 2.0,
225
+ timeout_sec: float = 600.0,
226
+ ) -> dict[str, Any]:
227
+ """Poll GET /jobs/:id until SETTLED, FAILED, or REFUNDED, or timeout.
228
+
229
+ Raises JobFailedError on FAILED. Returns the final job dict.
230
+ """
231
+ deadline = time.monotonic() + timeout_sec
232
+ terminal = {"SETTLED", "FAILED", "REFUNDED"}
233
+ while True:
234
+ job = self.get_job(job_id)
235
+ if job["status"] in terminal:
236
+ if job["status"] == "FAILED":
237
+ raise JobFailedError(job_id, body=job)
238
+ return job
239
+ if time.monotonic() > deadline:
240
+ raise CyberianError(
241
+ f"Job {job_id} did not reach a terminal state in {timeout_sec}s "
242
+ f"(last status: {job['status']})"
243
+ )
244
+ time.sleep(poll_interval_sec)
245
+
246
+ def submit_and_wait(
247
+ self,
248
+ *,
249
+ spec_yaml: str | Path,
250
+ input_texts: list[str],
251
+ poll_interval_sec: float = 2.0,
252
+ timeout_sec: float = 600.0,
253
+ verify_receipt: bool = True,
254
+ ) -> JobResult:
255
+ """One-shot helper: submit_job → wait_for_completion → get_receipt
256
+ → get_job_output → optionally verify_receipt. Most customers will
257
+ only ever call this method.
258
+ """
259
+ job = self.submit_job(spec_yaml=spec_yaml, input_texts=input_texts)
260
+ final = self.wait_for_completion(
261
+ job["id"],
262
+ poll_interval_sec=poll_interval_sec,
263
+ timeout_sec=timeout_sec,
264
+ )
265
+ receipt = self.get_receipt(final["receipt_id"])
266
+ embeddings = self.get_job_output(final["id"])
267
+ verification = self.verify_receipt(final["receipt_id"]) if verify_receipt else None
268
+ if verification and not verification.get("valid", False):
269
+ raise VerificationFailedError(
270
+ receipt_id=final["receipt_id"],
271
+ checks=verification.get("checks", {}),
272
+ errors=verification.get("errors", []),
273
+ )
274
+ return JobResult(
275
+ job=final,
276
+ receipt=receipt,
277
+ embeddings=embeddings,
278
+ verification=verification,
279
+ )
280
+
281
+ # ─── VaaS ────────────────────────────────────────────────────────────
282
+
283
+ def verify_outputs(
284
+ self,
285
+ *,
286
+ spec_yaml: str | Path,
287
+ input_texts: list[str],
288
+ claimed_output_commitment: str,
289
+ ) -> dict[str, Any]:
290
+ """POST /verify — Verification as a Service. You ran inference on
291
+ your own infrastructure; we re-execute on ours and confirm your
292
+ SHA-256 commitment matches.
293
+
294
+ Phase 2 supports single-chunk only — input_texts.length must fit
295
+ in one chunk per the spec's chunk_size.
296
+
297
+ Returns the receipt on success (HTTP 200). Raises
298
+ VerificationFailedError on commitment mismatch (HTTP 422).
299
+ """
300
+ spec_text = _read_spec(spec_yaml)
301
+ try:
302
+ return self._request(
303
+ "POST",
304
+ "/verify",
305
+ json={
306
+ "spec_yaml": spec_text,
307
+ "input_texts": input_texts,
308
+ "claimed_output_commitment": claimed_output_commitment,
309
+ },
310
+ )
311
+ except ApiError as exc:
312
+ if exc.status_code == 422:
313
+ raise VerificationFailedError(
314
+ receipt_id="(no receipt issued)",
315
+ checks={"output_match": False},
316
+ errors=[exc.body.get("reason") if isinstance(exc.body, dict) else str(exc.body)],
317
+ ) from exc
318
+ raise
319
+
320
+ # ─── lifecycle ───────────────────────────────────────────────────────
321
+
322
+ def close(self) -> None:
323
+ if self._owns_http:
324
+ self._http.close()
325
+
326
+ def __enter__(self) -> "Client":
327
+ return self
328
+
329
+ def __exit__(self, *_: object) -> None:
330
+ self.close()
331
+
332
+
333
+ def _read_spec(spec: str | Path) -> str:
334
+ """Accept either a YAML string or a path to a YAML file."""
335
+ if isinstance(spec, Path):
336
+ return spec.read_text()
337
+ if isinstance(spec, str):
338
+ # Heuristic: treat as a path if it looks file-y AND the file exists.
339
+ # Otherwise treat as inline YAML.
340
+ if "\n" not in spec and len(spec) < 4096:
341
+ try:
342
+ p = Path(spec)
343
+ if p.exists() and p.is_file():
344
+ return p.read_text()
345
+ except (OSError, ValueError):
346
+ pass
347
+ return spec
348
+ raise TypeError(f"spec_yaml must be str or Path, got {type(spec).__name__}")
@@ -0,0 +1,91 @@
1
+ """
2
+ Exception types raised by the Cyberian Systems client.
3
+
4
+ Catch hierarchy (most-to-least specific):
5
+
6
+ CyberianError base
7
+ ├── AuthError 401, 403
8
+ ├── TrialExpiredError 402 trial_expired / quota_exceeded
9
+ ├── QuotaError 402 quota / 413 request_too_large
10
+ ├── RateLimitError 429 (any flavor) — has .retry_after_sec
11
+ ├── ServiceBusyError 503 — has .retry_after_sec
12
+ ├── JobFailedError job reached FAILED status during wait
13
+ ├── VerificationFailedError /verify returned valid=False
14
+ └── ApiError any other 4xx/5xx
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any
19
+
20
+
21
+ class CyberianError(Exception):
22
+ """Base class for all Cyberian client errors."""
23
+
24
+ def __init__(self, message: str, *, status_code: int | None = None, body: Any = None):
25
+ super().__init__(message)
26
+ self.status_code = status_code
27
+ self.body = body
28
+
29
+
30
+ class AuthError(CyberianError):
31
+ """The Bearer key was missing, malformed, invalid, or revoked."""
32
+
33
+
34
+ class TrialExpiredError(CyberianError):
35
+ """The account's trial has ended without an upgrade. Email upgrade@cyberiansystems.ai."""
36
+
37
+
38
+ class QuotaError(CyberianError):
39
+ """A period or per-request quota was exceeded."""
40
+
41
+
42
+ class RateLimitError(CyberianError):
43
+ """The per-minute or daily rate limit was hit. Retry after `retry_after_sec`."""
44
+
45
+ def __init__(
46
+ self,
47
+ message: str,
48
+ *,
49
+ retry_after_sec: int = 60,
50
+ status_code: int | None = None,
51
+ body: Any = None,
52
+ ):
53
+ super().__init__(message, status_code=status_code, body=body)
54
+ self.retry_after_sec = retry_after_sec
55
+
56
+
57
+ class ServiceBusyError(CyberianError):
58
+ """The platform is at capacity. Retry after `retry_after_sec`."""
59
+
60
+ def __init__(
61
+ self,
62
+ message: str,
63
+ *,
64
+ retry_after_sec: int = 30,
65
+ status_code: int | None = None,
66
+ body: Any = None,
67
+ ):
68
+ super().__init__(message, status_code=status_code, body=body)
69
+ self.retry_after_sec = retry_after_sec
70
+
71
+
72
+ class JobFailedError(CyberianError):
73
+ """A job reached FAILED status during wait_for_completion."""
74
+
75
+ def __init__(self, job_id: str, body: Any = None):
76
+ super().__init__(f"Job {job_id} ended in FAILED state", body=body)
77
+ self.job_id = job_id
78
+
79
+
80
+ class VerificationFailedError(CyberianError):
81
+ """The receipt's /verify call returned valid=False."""
82
+
83
+ def __init__(self, receipt_id: str, checks: dict[str, bool], errors: list[str]):
84
+ super().__init__(f"Receipt {receipt_id} failed verification: {errors}")
85
+ self.receipt_id = receipt_id
86
+ self.checks = checks
87
+ self.errors = errors
88
+
89
+
90
+ class ApiError(CyberianError):
91
+ """Any other unexpected HTTP error from the API."""
@@ -0,0 +1,179 @@
1
+ Metadata-Version: 2.4
2
+ Name: cyberian-client
3
+ Version: 0.1.0
4
+ Summary: Python client for the Cyberian Systems verified-inference API
5
+ Author-email: Cyberian Systems <philippe@cyberiansystems.ai>
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://cyberiansystems.ai
8
+ Project-URL: Documentation, https://cyberiansystems.ai/docs
9
+ Project-URL: Support, https://cyberiansystems.ai/docs
10
+ Keywords: ai,ml,verified-inference,merkle,embeddings,cyberian
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: Other/Proprietary License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: httpx<1.0,>=0.27.0
24
+ Requires-Dist: numpy<3.0,>=1.26.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
28
+
29
+ # cyberian-client
30
+
31
+ Python client for the [Cyberian Systems](https://cyberiansystems.ai) verified-inference API.
32
+
33
+ Each call returns embeddings + a cryptographic receipt that proves an independent prover
34
+ re-executed a sample of your batch and got bitwise-identical results. The receipt is
35
+ self-verifying: any third party can validate it without access to the platform.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install cyberian-client
41
+ ```
42
+
43
+ Requires Python 3.9+. Pulls in `httpx` and `numpy`.
44
+
45
+ ## Get an API key
46
+
47
+ [https://cyberiansystems.ai/signup](https://cyberiansystems.ai/signup) — 14-day free
48
+ trial, no card required. The plaintext key is shown once at signup; save it. We store
49
+ only its SHA-256.
50
+
51
+ ## Quickstart — one shot
52
+
53
+ ```python
54
+ from cyberian import Client
55
+
56
+ client = Client(api_key="cyb_trial_…") # or set CYBERIAN_API_KEY in env and pass it in
57
+
58
+ result = client.submit_and_wait(
59
+ spec_yaml=open("spec.yaml").read(),
60
+ input_texts=[
61
+ "The property title search revealed no encumbrances.",
62
+ "Quarterly compliance attestation per SOX 404.",
63
+ ],
64
+ )
65
+
66
+ print("receipt_hash:", result.receipt["receipt_hash"])
67
+ print("verified: ", result.verification["valid"]) # True
68
+ print("shape: ", result.embeddings.shape) # (2, 384) for BGE-small
69
+ print("first vec: ", result.embeddings[0, :8])
70
+ ```
71
+
72
+ ## Step-by-step — when you need finer control
73
+
74
+ ```python
75
+ job = client.submit_job(spec_yaml="spec.yaml", input_texts=["a", "b", "c"])
76
+
77
+ # Poll yourself, or use wait_for_completion:
78
+ final = client.wait_for_completion(job["id"], poll_interval_sec=2.0, timeout_sec=600)
79
+
80
+ receipt = client.get_receipt(final["receipt_id"])
81
+ verification = client.verify_receipt(final["receipt_id"])
82
+ embeddings = client.get_job_output(final["id"]) # numpy ndarray, float32
83
+ ```
84
+
85
+ ## Verification as a Service (VaaS)
86
+
87
+ You ran inference on your own infrastructure. Cyberian re-runs it and certifies the
88
+ SHA-256 commitment of your output matches. Useful for compliance — you keep the
89
+ inference, we provide independent auditable verification.
90
+
91
+ ```python
92
+ import hashlib
93
+
94
+ # Your in-house inference output:
95
+ my_embeddings_bytes = my_pipeline.run(["text"]).astype("float32").tobytes()
96
+ my_commitment = hashlib.sha256(my_embeddings_bytes).hexdigest()
97
+
98
+ receipt = client.verify_outputs(
99
+ spec_yaml="spec.yaml",
100
+ input_texts=["text"],
101
+ claimed_output_commitment=my_commitment,
102
+ )
103
+ # Raises VerificationFailedError if our prover's re-execution doesn't match yours.
104
+ ```
105
+
106
+ Phase 2: VaaS is single-chunk only — `len(input_texts) <= spec.chunking.chunk_size`.
107
+
108
+ ## Account management
109
+
110
+ ```python
111
+ info = client.get_account()
112
+ print(info["tier"], info["effective_tier"], info["subscription_status"])
113
+ print(info["chunks_consumed_period"], "/", info["limits"]["chunks_per_period"])
114
+
115
+ # Mint an additional key (old one stays valid until you revoke it)
116
+ new_key = client.rotate_key()
117
+
118
+ # Revoke a specific key by its 12-char key_id (the prefix shown in /account)
119
+ client.revoke_key(key_id="a1b2c3d4e5f6")
120
+ ```
121
+
122
+ ## Error handling
123
+
124
+ Typed exceptions; catch the most specific one you care about.
125
+
126
+ ```python
127
+ from cyberian import (
128
+ Client,
129
+ AuthError, # 401, 403
130
+ TrialExpiredError, # 402 — email upgrade@cyberiansystems.ai
131
+ QuotaError, # 402 / 413
132
+ RateLimitError, # 429 — has .retry_after_sec
133
+ ServiceBusyError, # 503 — has .retry_after_sec
134
+ JobFailedError, # job ended in FAILED state during wait_for_completion
135
+ VerificationFailedError, # /verify returned valid=False
136
+ ApiError, # any other 4xx/5xx
137
+ CyberianError, # base of everything above
138
+ )
139
+
140
+ try:
141
+ result = client.submit_and_wait(spec_yaml=…, input_texts=[…])
142
+ except RateLimitError as exc:
143
+ time.sleep(exc.retry_after_sec)
144
+ result = client.submit_and_wait(spec_yaml=…, input_texts=[…])
145
+ except TrialExpiredError:
146
+ print("Email upgrade@cyberiansystems.ai for an extension or enterprise tier.")
147
+ raise
148
+ ```
149
+
150
+ ## Trial limits (Phase 2)
151
+
152
+ | | trial (default) | free (post-trial) |
153
+ |---|---|---|
154
+ | Period | 14 days | 30 days |
155
+ | Chunks per period | 400 | 100 |
156
+ | Chunks per day | 100 | 25 |
157
+ | Requests per minute | 60 | 10 |
158
+ | Max chunks per request | 100 | 50 |
159
+ | Max input texts | 1000 | 500 |
160
+
161
+ A "chunk" is one ONNX inference unit, sized by your CES `chunking.chunk_size`. With
162
+ `chunk_size: 1` each input text is its own chunk; with `chunk_size: 100` a 1000-text
163
+ batch is 10 chunks. Counting chunks (not requests) means the quota tracks actual
164
+ compute consumption.
165
+
166
+ ## Documentation
167
+
168
+ - API + SDK reference: <https://cyberiansystems.ai/docs>
169
+ - Trial terms: <https://cyberiansystems.ai/terms>
170
+ - Privacy: <https://cyberiansystems.ai/privacy>
171
+ - Issues / support: support@cyberiansystems.ai
172
+ - Upgrade / enterprise inquiries: upgrade@cyberiansystems.ai
173
+
174
+ ## Patents
175
+
176
+ The verification mechanism (Canonical Execution Specification, Merkle receipts,
177
+ sample-and-replay verification with game-theoretic deterrence, the four-tuple binding)
178
+ is covered by a U.S. provisional patent. Use of this client does not grant any patent
179
+ license beyond what's needed to call the API as documented.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/cyberian/__init__.py
4
+ src/cyberian/client.py
5
+ src/cyberian/exceptions.py
6
+ src/cyberian_client.egg-info/PKG-INFO
7
+ src/cyberian_client.egg-info/SOURCES.txt
8
+ src/cyberian_client.egg-info/dependency_links.txt
9
+ src/cyberian_client.egg-info/requires.txt
10
+ src/cyberian_client.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1,6 @@
1
+ httpx<1.0,>=0.27.0
2
+ numpy<3.0,>=1.26.0
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ pytest-asyncio>=0.23
@@ -0,0 +1,197 @@
1
+ """
2
+ Smoke tests for the Cyberian Client.
3
+
4
+ Uses httpx.MockTransport to fake the API. We're not testing the
5
+ coordinator's logic here (the TS test suite owns that) — just that
6
+ the client constructs the right requests and parses the right
7
+ responses.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ import httpx
14
+ import numpy as np
15
+ import pytest
16
+
17
+ from cyberian import (
18
+ ApiError,
19
+ AuthError,
20
+ Client,
21
+ JobFailedError,
22
+ QuotaError,
23
+ RateLimitError,
24
+ ServiceBusyError,
25
+ TrialExpiredError,
26
+ VerificationFailedError,
27
+ )
28
+
29
+
30
+ def make_client(handler):
31
+ transport = httpx.MockTransport(handler)
32
+ http = httpx.Client(
33
+ base_url="https://api.test",
34
+ transport=transport,
35
+ headers={"Authorization": "Bearer cyb_test_" + "a" * 32, "Content-Type": "application/json"},
36
+ )
37
+ return Client(api_key="cyb_test_" + "a" * 32, base_url="https://api.test", http_client=http)
38
+
39
+
40
+ def test_client_rejects_non_cyb_key():
41
+ with pytest.raises(AuthError):
42
+ Client(api_key="not-a-cyberian-key")
43
+
44
+
45
+ def test_health():
46
+ def handler(req):
47
+ assert req.url.path == "/health"
48
+ return httpx.Response(200, json={"ok": True, "service": "coordinator"})
49
+
50
+ c = make_client(handler)
51
+ assert c.health() == {"ok": True, "service": "coordinator"}
52
+
53
+
54
+ def test_get_account():
55
+ def handler(req):
56
+ assert req.url.path == "/account"
57
+ assert req.headers["authorization"].startswith("Bearer cyb_test_")
58
+ return httpx.Response(200, json={"tier": "free", "subscription_status": "trial"})
59
+
60
+ c = make_client(handler)
61
+ info = c.get_account()
62
+ assert info["tier"] == "free"
63
+
64
+
65
+ def test_submit_job_serializes_body():
66
+ captured = {}
67
+
68
+ def handler(req):
69
+ captured["body"] = json.loads(req.content)
70
+ return httpx.Response(201, json={"id": "abc-123", "status": "EXECUTING"})
71
+
72
+ c = make_client(handler)
73
+ out = c.submit_job(spec_yaml="version: 1.0", input_texts=["a", "b"])
74
+ assert out["id"] == "abc-123"
75
+ assert captured["body"] == {"spec_yaml": "version: 1.0", "input_texts": ["a", "b"]}
76
+
77
+
78
+ def test_get_job_output_decodes_to_ndarray():
79
+ # Pretend the server returned 6 floats = 3 rows × 2 cols.
80
+ raw = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=np.float32).tobytes()
81
+
82
+ def handler(req):
83
+ assert req.url.path == "/jobs/job-99/output"
84
+ return httpx.Response(
85
+ 200,
86
+ content=raw,
87
+ headers={
88
+ "x-cyberian-shape": "3,2",
89
+ "x-cyberian-dtype": "float32",
90
+ "content-type": "application/octet-stream",
91
+ },
92
+ )
93
+
94
+ c = make_client(handler)
95
+ arr = c.get_job_output("job-99")
96
+ assert arr.shape == (3, 2)
97
+ assert arr.dtype == np.float32
98
+ assert arr[1, 0] == 3.0
99
+
100
+
101
+ def test_wait_for_completion_terminates_on_settled():
102
+ seq = iter(["EXECUTING", "PROVING", "SETTLED"])
103
+
104
+ def handler(req):
105
+ return httpx.Response(200, json={"id": "j", "status": next(seq), "receipt_id": "r"})
106
+
107
+ c = make_client(handler)
108
+ final = c.wait_for_completion("j", poll_interval_sec=0.001, timeout_sec=2.0)
109
+ assert final["status"] == "SETTLED"
110
+
111
+
112
+ def test_wait_for_completion_raises_on_failed():
113
+ def handler(req):
114
+ return httpx.Response(200, json={"id": "j", "status": "FAILED"})
115
+
116
+ c = make_client(handler)
117
+ with pytest.raises(JobFailedError):
118
+ c.wait_for_completion("j", poll_interval_sec=0.001, timeout_sec=2.0)
119
+
120
+
121
+ def test_402_trial_expired_raises_typed():
122
+ def handler(req):
123
+ return httpx.Response(
124
+ 402,
125
+ json={"error": "trial_expired", "message": "Your trial has ended"},
126
+ )
127
+
128
+ c = make_client(handler)
129
+ with pytest.raises(TrialExpiredError):
130
+ c.submit_job(spec_yaml="x", input_texts=["a"])
131
+
132
+
133
+ def test_402_quota_raises_quota_error():
134
+ def handler(req):
135
+ return httpx.Response(402, json={"error": "quota_exceeded", "message": "out of chunks"})
136
+
137
+ c = make_client(handler)
138
+ with pytest.raises(QuotaError):
139
+ c.submit_job(spec_yaml="x", input_texts=["a"])
140
+
141
+
142
+ def test_413_oversize_raises_quota_error():
143
+ def handler(req):
144
+ return httpx.Response(413, json={"error": "request_too_large"})
145
+
146
+ c = make_client(handler)
147
+ with pytest.raises(QuotaError):
148
+ c.submit_job(spec_yaml="x", input_texts=["a"])
149
+
150
+
151
+ def test_429_rate_limited_carries_retry_after():
152
+ def handler(req):
153
+ return httpx.Response(
154
+ 429,
155
+ headers={"retry-after": "42"},
156
+ json={"error": "rate_limited"},
157
+ )
158
+
159
+ c = make_client(handler)
160
+ with pytest.raises(RateLimitError) as ei:
161
+ c.submit_job(spec_yaml="x", input_texts=["a"])
162
+ assert ei.value.retry_after_sec == 42
163
+
164
+
165
+ def test_503_busy_carries_retry_after():
166
+ def handler(req):
167
+ return httpx.Response(
168
+ 503,
169
+ headers={"retry-after": "20"},
170
+ json={"error": "service_busy"},
171
+ )
172
+
173
+ c = make_client(handler)
174
+ with pytest.raises(ServiceBusyError) as ei:
175
+ c.submit_job(spec_yaml="x", input_texts=["a"])
176
+ assert ei.value.retry_after_sec == 20
177
+
178
+
179
+ def test_verify_outputs_422_raises_verification_failed():
180
+ def handler(req):
181
+ return httpx.Response(
182
+ 422,
183
+ json={"error": "verification_failed", "reason": "commitments differ"},
184
+ )
185
+
186
+ c = make_client(handler)
187
+ with pytest.raises(VerificationFailedError):
188
+ c.verify_outputs(spec_yaml="x", input_texts=["a"], claimed_output_commitment="0" * 64)
189
+
190
+
191
+ def test_unknown_error_falls_through_to_apierror():
192
+ def handler(req):
193
+ return httpx.Response(418, json={"error": "i_am_a_teapot"})
194
+
195
+ c = make_client(handler)
196
+ with pytest.raises(ApiError):
197
+ c.submit_job(spec_yaml="x", input_texts=["a"])