quantufai 0.1.0__py3-none-any.whl

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.
quantufai/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """quantufai — the official QuantufAI Python SDK.
2
+
3
+ Quantum compute a program can PRICE and READ, but never SPEND: quotes before
4
+ any spend, job status/results with receipts and error bars verbatim, signed
5
+ receipt verification, circuit export, and a free sandbox simulator. There is
6
+ no dispatch or approval method — spending stays with a human in the dashboard
7
+ (see ``quantufai.client`` for the full contract).
8
+
9
+ This package intentionally replaces the repo's former SDK stubs, which called
10
+ endpoints that did not exist and fabricated job statuses from pattern-matched
11
+ text. Everything here calls the real, mounted, scope-gated API — and what the
12
+ API refuses, this SDK reports as a typed ``REFUSED``, never a made-up answer.
13
+ """
14
+ from ._version import __version__
15
+ from .client import FREE_SIMULATOR_PROVIDER, Client, ReceiptVerification
16
+ from .types import (
17
+ FAILED,
18
+ QUEUED,
19
+ REFUSED,
20
+ RUNNING,
21
+ SUCCEEDED,
22
+ CircuitExport,
23
+ GovernedReceipt,
24
+ JobResult,
25
+ JobStatus,
26
+ Me,
27
+ PlatformError,
28
+ Quote,
29
+ QuoteResponse,
30
+ ReceiptBundle,
31
+ Refusal,
32
+ ResultExport,
33
+ SandboxRun,
34
+ TransportError,
35
+ is_terminal,
36
+ normalize_state,
37
+ )
38
+ from .verify import ChainCheck, OfflineVerification, verify_receipt_offline
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "Client",
43
+ "FREE_SIMULATOR_PROVIDER",
44
+ "ReceiptVerification",
45
+ "Refusal",
46
+ "REFUSED",
47
+ "QUEUED",
48
+ "RUNNING",
49
+ "SUCCEEDED",
50
+ "FAILED",
51
+ "Quote",
52
+ "QuoteResponse",
53
+ "JobStatus",
54
+ "JobResult",
55
+ "ReceiptBundle",
56
+ "GovernedReceipt",
57
+ "CircuitExport",
58
+ "ResultExport",
59
+ "SandboxRun",
60
+ "Me",
61
+ "TransportError",
62
+ "PlatformError",
63
+ "normalize_state",
64
+ "is_terminal",
65
+ "OfflineVerification",
66
+ "ChainCheck",
67
+ "verify_receipt_offline",
68
+ ]
quantufai/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
quantufai/canonical.py ADDED
@@ -0,0 +1,100 @@
1
+ """Canonical JSON — a byte-exact Python port of the platform's stableStringify.
2
+
3
+ Receipt signatures cover ``stableStringify(receipt minus signature fields)``:
4
+ deterministic JSON with lexicographically sorted object keys, serialized the
5
+ way JavaScript's ``JSON.stringify`` serializes scalars. Verification in Python
6
+ therefore needs the SAME bytes — including JavaScript's number formatting
7
+ (``1e21`` -> ``"1e+21"``, ``1e-7`` -> ``"1e-7"``, ``1.0`` -> ``"1"``), which
8
+ differs from Python's ``json.dumps`` in the corners.
9
+
10
+ Parity is pinned by shared test vectors (tests/fixtures/canonical_vectors.json)
11
+ asserted against both this module and the Node implementation.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ from typing import Any
18
+
19
+
20
+ def _js_number(value: float) -> str:
21
+ """Format a float exactly as ECMAScript Number::toString(10) would.
22
+
23
+ JSON.stringify emits non-finite numbers as ``null``; integral values
24
+ without a fractional part; exponential notation only for |exponent|
25
+ outside [-6, 21); exponents unpadded with an explicit sign.
26
+ """
27
+ if math.isnan(value) or math.isinf(value):
28
+ return "null" # JSON.stringify(NaN) === "null"
29
+ if value == 0:
30
+ return "0"
31
+ # Shortest round-trip decimal digits (repr is shortest in Python >= 3.1,
32
+ # same guarantee V8 uses).
33
+ rep = repr(abs(value))
34
+ if "e" in rep or "E" in rep:
35
+ mantissa, _, exp_part = rep.lower().partition("e")
36
+ exp10 = int(exp_part)
37
+ else:
38
+ mantissa, exp10 = rep, 0
39
+ int_part, _, frac_part = mantissa.partition(".")
40
+ digits = (int_part + frac_part).lstrip("0")
41
+ # Decimal point position: value = 0.<digits> * 10**point
42
+ point = len(int_part.lstrip("0")) + exp10 if int_part.strip("0") else (
43
+ exp10 - (len(frac_part) - len(frac_part.lstrip("0")))
44
+ )
45
+ digits = digits.rstrip("0") or "0"
46
+ sign = "-" if value < 0 else ""
47
+
48
+ k = len(digits)
49
+ if k <= point <= 21: # integral, printed in full
50
+ return sign + digits + "0" * (point - k)
51
+ if 0 < point <= 21: # decimal point inside the digits
52
+ return sign + digits[:point] + "." + digits[point:]
53
+ if -6 < point <= 0: # small: leading zeros after "0."
54
+ return sign + "0." + "0" * (-point) + digits
55
+ # Exponential: d[.ddd]e±e (exponent = point - 1, unpadded, signed)
56
+ exponent = point - 1
57
+ exp_str = ("+" if exponent >= 0 else "-") + str(abs(exponent))
58
+ if k == 1:
59
+ return sign + digits + "e" + exp_str
60
+ return sign + digits[0] + "." + digits[1:] + "e" + exp_str
61
+
62
+
63
+ def stable_stringify(value: Any) -> str:
64
+ """Deterministic JSON with sorted object keys — parity with the platform."""
65
+ if value is None:
66
+ return "null"
67
+ if value is True:
68
+ return "true"
69
+ if value is False:
70
+ return "false"
71
+ if isinstance(value, str):
72
+ # ensure_ascii=False matches JSON.stringify (raw UTF-8, control chars
73
+ # escaped); Python and JS agree on the mandatory escape set.
74
+ return json.dumps(value, ensure_ascii=False)
75
+ if isinstance(value, int):
76
+ return str(value)
77
+ if isinstance(value, float):
78
+ # JS has one number type: 1.0 serializes as "1" (handled in _js_number).
79
+ return _js_number(value)
80
+ if isinstance(value, (list, tuple)):
81
+ return "[" + ",".join(stable_stringify(v) for v in value) + "]"
82
+ if isinstance(value, dict):
83
+ # JavaScript's Array.prototype.sort() compares strings by UTF-16 code
84
+ # units; utf-16-be byte order reproduces that exactly (receipt keys are
85
+ # ASCII in practice, but parity should not depend on practice).
86
+ items = sorted(
87
+ ((str(k), v) for k, v in value.items()),
88
+ key=lambda kv: kv[0].encode("utf-16-be"),
89
+ )
90
+ return "{" + ",".join(
91
+ json.dumps(k, ensure_ascii=False) + ":" + stable_stringify(v) for k, v in items
92
+ ) + "}"
93
+ raise TypeError(f"stable_stringify: unsupported type {type(value).__name__}")
94
+
95
+
96
+ def unsigned_canonical(receipt: dict) -> str:
97
+ """The exact bytes both platform signatures cover: the receipt minus its
98
+ ``signature`` and ``publicSignature`` fields."""
99
+ clone = {k: v for k, v in receipt.items() if k not in ("signature", "publicSignature")}
100
+ return stable_stringify(clone)
quantufai/client.py ADDED
@@ -0,0 +1,409 @@
1
+ """The QuantufAI client — quantum compute you can price and read, never spend.
2
+
3
+ THE DESIGN, STATED LOUDLY (this is not a missing feature)
4
+ =========================================================
5
+ This SDK has NO dispatch method, NO approval method, NO billing method.
6
+ A program holding an API key can:
7
+
8
+ - ask what a run would cost (``quote`` — estimates only, nothing reserved,
9
+ charged, or dispatched),
10
+ - follow runs it owns (``job_status``/``job_result``/``wait``),
11
+ - fetch and verify signed receipts (``receipt``/``governed_receipt``/
12
+ ``verify_receipt``),
13
+ - take the exact circuit home (``export_circuit``/``export_result``),
14
+ - execute on the FREE local simulator (``sandbox_simulate`` — $0 by
15
+ construction; the request pins the free simulator on every call).
16
+
17
+ Spending money on quantum hardware requires a human approving a signed quote
18
+ in the QuantufAI dashboard. That is enforced SERVER-side (quote-before-spend;
19
+ scoped keys cannot approve), and this SDK's surface simply omits spending on
20
+ top of it. An AI agent driving this client cannot buy anything.
21
+
22
+ Every deliberate platform refusal is returned as a typed
23
+ :class:`~quantufai.types.Refusal` (status ``REFUSED``) — codes, messages, and
24
+ details verbatim. Exceptions are reserved for transport failures and
25
+ unexpected 5xx answers.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import time
32
+ import urllib.error
33
+ import urllib.parse
34
+ import urllib.request
35
+ from dataclasses import dataclass, field
36
+ from typing import Any, Dict, List, Optional, Union
37
+
38
+ from ._version import __version__
39
+ from .types import (
40
+ CircuitExport,
41
+ GovernedReceipt,
42
+ JobResult,
43
+ JobStatus,
44
+ Me,
45
+ PlatformError,
46
+ QuoteResponse,
47
+ ReceiptBundle,
48
+ Refusal,
49
+ ResultExport,
50
+ SandboxRun,
51
+ TransportError,
52
+ )
53
+ from .verify import OfflineVerification, verify_receipt_offline
54
+
55
+ #: The one provider the SDK will ever execute on: the platform's free local
56
+ #: statevector simulator. ``sandbox_simulate`` pins this on EVERY request, so
57
+ #: even a key that carries the paid ``runs:execute`` scope cannot reach
58
+ #: billable hardware through this SDK. (Sandbox keys are additionally clamped
59
+ #: server-side; naming any other provider gets a typed refusal, never a
60
+ #: silent reroute.)
61
+ FREE_SIMULATOR_PROVIDER = "classical"
62
+
63
+ _ENV_API_KEY = "QUANTUFAI_API_KEY"
64
+ _ENV_BASE_URL = "QUANTUFAI_BASE_URL"
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ReceiptVerification:
69
+ """Combined offline + platform verification report.
70
+
71
+ ``offline`` is a pure function of the pasted bytes (Ed25519 tier).
72
+ ``platform`` is the platform's own check of its HMAC server attestation
73
+ (requires the key's owner to own the receipt), or ``None`` when skipped,
74
+ or a :class:`Refusal` when the platform declined to answer.
75
+ """
76
+
77
+ offline: OfflineVerification
78
+ platform: Optional[Union[Dict[str, Any], Refusal]] = None
79
+ notes: List[str] = field(default_factory=list)
80
+
81
+ @property
82
+ def verified(self) -> bool:
83
+ """True when ANY tier produced a positive verdict. Check the tiers
84
+ individually when you need to know WHICH guarantee you got."""
85
+ if self.offline.verified:
86
+ return True
87
+ return isinstance(self.platform, dict) and self.platform.get("valid") is True
88
+
89
+
90
+ class Client:
91
+ """Key-authenticated QuantufAI API client (see module docstring for the
92
+ can/can't-do contract — the short version: price and read, never spend).
93
+
94
+ Args:
95
+ api_key: a ``qfai_sk_...`` API key (default: ``$QUANTUFAI_API_KEY``).
96
+ base_url: the platform origin, e.g. ``https://<your-host>``
97
+ (default: ``$QUANTUFAI_BASE_URL``). Required — the SDK does not
98
+ guess a host.
99
+ timeout: per-request timeout in seconds.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ api_key: Optional[str] = None,
105
+ base_url: Optional[str] = None,
106
+ timeout: float = 30.0,
107
+ ):
108
+ self.api_key = api_key or os.environ.get(_ENV_API_KEY) or ""
109
+ raw_base = base_url or os.environ.get(_ENV_BASE_URL) or ""
110
+ if not self.api_key:
111
+ raise ValueError(
112
+ f"An API key is required (pass api_key= or set ${_ENV_API_KEY}). "
113
+ "Mint one in the QuantufAI dashboard — a sandbox-tier key runs the "
114
+ "free simulator with no card."
115
+ )
116
+ if not raw_base:
117
+ raise ValueError(
118
+ f"A base URL is required (pass base_url= or set ${_ENV_BASE_URL})."
119
+ )
120
+ self.base_url = raw_base.rstrip("/")
121
+ self.timeout = timeout
122
+
123
+ # ------------------------------------------------------------------
124
+ # Transport
125
+ # ------------------------------------------------------------------
126
+
127
+ def _headers(self) -> Dict[str, str]:
128
+ return {
129
+ "Authorization": f"Bearer {self.api_key}",
130
+ "Content-Type": "application/json",
131
+ "Accept": "application/json",
132
+ "User-Agent": f"quantufai-python/{__version__}",
133
+ }
134
+
135
+ @staticmethod
136
+ def _parse_error(status: int, body: Any) -> Refusal:
137
+ """Shape the platform's two error envelopes into one typed Refusal:
138
+ ``{error: {code, message, details}}`` and ``{error: "code", ...}``."""
139
+ if isinstance(body, dict):
140
+ err = body.get("error")
141
+ if isinstance(err, dict):
142
+ details = err.get("details")
143
+ return Refusal(
144
+ code=str(err.get("code", "unknown")),
145
+ message=str(err.get("message", err.get("code", ""))),
146
+ http_status=status,
147
+ details=details if isinstance(details, dict) else {},
148
+ )
149
+ if isinstance(err, str):
150
+ rest = {k: v for k, v in body.items() if k not in ("error", "message")}
151
+ return Refusal(
152
+ code=err,
153
+ message=str(body.get("message", err)),
154
+ http_status=status,
155
+ details=rest,
156
+ )
157
+ return Refusal(
158
+ code=f"http_{status}",
159
+ message=str(body)[:500] if body is not None else f"HTTP {status}",
160
+ http_status=status,
161
+ details={},
162
+ )
163
+
164
+ def _request(
165
+ self,
166
+ method: str,
167
+ path: str,
168
+ *,
169
+ body: Optional[Dict[str, Any]] = None,
170
+ query: Optional[Dict[str, str]] = None,
171
+ ) -> Union[Any, Refusal]:
172
+ """Perform one request. 2xx -> parsed JSON (or raw text for non-JSON
173
+ bodies); 4xx -> :class:`Refusal`; 5xx -> :class:`PlatformError`;
174
+ network trouble -> :class:`TransportError`."""
175
+ url = self.base_url + path
176
+ if query:
177
+ url += "?" + urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
178
+ data = json.dumps(body).encode("utf-8") if body is not None else None
179
+ req = urllib.request.Request(url, data=data, headers=self._headers(), method=method)
180
+ try:
181
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
182
+ raw = resp.read()
183
+ content_type = resp.headers.get("Content-Type", "")
184
+ if "json" in content_type:
185
+ return json.loads(raw.decode("utf-8")) if raw else {}
186
+ return raw.decode("utf-8")
187
+ except urllib.error.HTTPError as err:
188
+ raw = err.read()
189
+ try:
190
+ parsed = json.loads(raw.decode("utf-8")) if raw else None
191
+ except (ValueError, UnicodeDecodeError):
192
+ parsed = raw.decode("utf-8", "replace") if raw else None
193
+ if 400 <= err.code < 500:
194
+ return self._parse_error(err.code, parsed)
195
+ refusal_shaped = self._parse_error(err.code, parsed)
196
+ raise PlatformError(err.code, refusal_shaped.code, refusal_shaped.message, parsed)
197
+ except urllib.error.URLError as err:
198
+ raise TransportError(f"{method} {url}: {err.reason}") from err
199
+ except TimeoutError as err:
200
+ raise TransportError(f"{method} {url}: timed out after {self.timeout}s") from err
201
+
202
+ # ------------------------------------------------------------------
203
+ # Quote before spend (quotes:read)
204
+ # ------------------------------------------------------------------
205
+
206
+ def quote(
207
+ self,
208
+ circuit: str,
209
+ *,
210
+ shots: int = 1000,
211
+ qubits: Optional[int] = None,
212
+ max_cost_usd: Optional[float] = None,
213
+ max_latency_ms: Optional[int] = None,
214
+ min_fidelity: Optional[float] = None,
215
+ ) -> Union[QuoteResponse, Refusal]:
216
+ """Ask what this run WOULD cost, per provider. A quote never reserves,
217
+ charges, or dispatches anything — and neither can any other method
218
+ here. Requires the ``quotes:read`` scope."""
219
+ intent: Dict[str, Any] = {"type": "circuit", "shots": shots}
220
+ if max_cost_usd is not None:
221
+ intent["maxCostUsd"] = max_cost_usd
222
+ if max_latency_ms is not None:
223
+ intent["maxLatencyMs"] = max_latency_ms
224
+ if min_fidelity is not None:
225
+ intent["minFidelity"] = min_fidelity
226
+ params: Dict[str, Any] = {"shots": shots}
227
+ if qubits is not None:
228
+ params["qubits"] = qubits
229
+ raw = self._request(
230
+ "POST", "/api/v1/quotes",
231
+ body={"intent": intent, "payload": {"circuit": circuit, "params": params}},
232
+ )
233
+ if isinstance(raw, Refusal):
234
+ return raw
235
+ return QuoteResponse.from_raw(raw)
236
+
237
+ # ------------------------------------------------------------------
238
+ # Follow your runs (jobs:read)
239
+ # ------------------------------------------------------------------
240
+
241
+ def job_status(self, job_id: str) -> Union[JobStatus, Refusal]:
242
+ """"Is it done yet?" — status poll. Requires ``jobs:read``. You see
243
+ only your own runs; someone else's job answers ``job_not_found``."""
244
+ raw = self._request("GET", f"/api/v1/jobs/{urllib.parse.quote(job_id)}")
245
+ return raw if isinstance(raw, Refusal) else JobStatus.from_raw(raw)
246
+
247
+ def job_result(self, job_id: str) -> Union[JobResult, Refusal]:
248
+ """Status + counts + operational ledger. Still-running hardware jobs
249
+ are refreshed from the provider at read time. Requires ``jobs:read``."""
250
+ raw = self._request("GET", f"/api/v1/jobs/{urllib.parse.quote(job_id)}/result")
251
+ return raw if isinstance(raw, Refusal) else JobResult.from_raw(raw)
252
+
253
+ def wait(
254
+ self,
255
+ job_id: str,
256
+ *,
257
+ timeout: float = 600.0,
258
+ poll_interval: float = 2.0,
259
+ ) -> Union[JobResult, Refusal]:
260
+ """Poll ``job_result`` until the run reaches a terminal state.
261
+
262
+ Raises :class:`TransportError` when ``timeout`` elapses first (the
263
+ run itself is NOT cancelled — this SDK cannot mutate runs)."""
264
+ deadline = time.monotonic() + timeout
265
+ while True:
266
+ result = self.job_result(job_id)
267
+ if isinstance(result, Refusal) or result.done:
268
+ return result
269
+ if time.monotonic() >= deadline:
270
+ raise TransportError(
271
+ f"wait({job_id!r}): still {result.state} after {timeout}s "
272
+ "(the run keeps going; poll again or raise the timeout)"
273
+ )
274
+ time.sleep(poll_interval)
275
+
276
+ # ------------------------------------------------------------------
277
+ # Receipts (jobs:read)
278
+ # ------------------------------------------------------------------
279
+
280
+ def receipt(self, job_id: str) -> Union[ReceiptBundle, Refusal]:
281
+ """The run's ledger receipts + artifacts summary. Requires ``jobs:read``."""
282
+ raw = self._request("GET", f"/api/v1/jobs/{urllib.parse.quote(job_id)}/receipt")
283
+ return raw if isinstance(raw, Refusal) else ReceiptBundle.from_raw(raw)
284
+
285
+ def governed_receipt(self, job_id: str) -> Union[GovernedReceipt, Refusal]:
286
+ """The signed, downloadable Governed Run Receipt — the tamper-evident
287
+ artifact the dashboard serves, byte-for-byte. Requires ``jobs:read``."""
288
+ raw = self._request(
289
+ "GET", f"/api/v1/jobs/{urllib.parse.quote(job_id)}/governed-receipt"
290
+ )
291
+ return raw if isinstance(raw, Refusal) else GovernedReceipt(data=raw)
292
+
293
+ def verify_receipt(
294
+ self,
295
+ receipt: Union[Dict[str, Any], GovernedReceipt],
296
+ *,
297
+ public_key: Optional[str] = None,
298
+ offline_only: bool = False,
299
+ ) -> ReceiptVerification:
300
+ """Verify a receipt you hold. Two independent tiers, reported honestly:
301
+
302
+ - OFFLINE (Ed25519 ``publicSignature``): pure function of the pasted
303
+ bytes + QuantufAI's published public key. Needs ``public_key`` and
304
+ the ``quantufai[verify]`` extra.
305
+ - PLATFORM (HMAC server attestation): asks the platform to confirm
306
+ the signature is one IT issued (``POST /api/v1/jobs/verify-receipt``,
307
+ ``jobs:read``; the receipt must be your own).
308
+
309
+ Neither tier is silently substituted for the other."""
310
+ data = receipt.data if isinstance(receipt, GovernedReceipt) else receipt
311
+ offline = verify_receipt_offline(data, public_key=public_key)
312
+ platform: Optional[Union[Dict[str, Any], Refusal]] = None
313
+ notes: List[str] = []
314
+ if not offline_only:
315
+ answer = self._request(
316
+ "POST", "/api/v1/jobs/verify-receipt", body={"receipt": data}
317
+ )
318
+ platform = answer
319
+ if isinstance(answer, Refusal):
320
+ notes.append(
321
+ f"platform check refused ({answer.code}): {answer.message}"
322
+ )
323
+ return ReceiptVerification(offline=offline, platform=platform, notes=notes)
324
+
325
+ # ------------------------------------------------------------------
326
+ # Take the circuit home (results:export)
327
+ # ------------------------------------------------------------------
328
+
329
+ def export_circuit(
330
+ self,
331
+ job_id: str,
332
+ *,
333
+ format: str,
334
+ which: str,
335
+ ) -> Union[CircuitExport, Refusal]:
336
+ """Export the exact stored circuit of a run as ready-to-run source
337
+ (``qiskit``/``cirq``/``braket``/``pytket``/``qasm``). ``which`` is
338
+ REQUIRED — ``"original"`` (as authored) or ``"as-dispatched"`` (the
339
+ exact program handed to the executor); the platform refuses to guess.
340
+ Untranslatable circuits get a typed refusal, never a lossy guess.
341
+ Requires ``results:export``."""
342
+ raw = self._request(
343
+ "GET",
344
+ f"/api/v1/jobs/{urllib.parse.quote(job_id)}/circuit",
345
+ query={"format": format, "which": which, "envelope": "json"},
346
+ )
347
+ return raw if isinstance(raw, Refusal) else CircuitExport.from_raw(raw)
348
+
349
+ def export_result(
350
+ self, job_id: str, *, format: str = "json"
351
+ ) -> Union[ResultExport, Refusal]:
352
+ """Download the run's result artifact (the Jobs-page "Download
353
+ Result"). Incomplete runs answer a typed refusal. Requires
354
+ ``results:export``."""
355
+ raw = self._request(
356
+ "GET",
357
+ f"/api/v1/jobs/{urllib.parse.quote(job_id)}/export",
358
+ query={"format": format},
359
+ )
360
+ if isinstance(raw, Refusal):
361
+ return raw
362
+ content = raw if isinstance(raw, str) else json.dumps(raw)
363
+ return ResultExport(content=content, format=format)
364
+
365
+ # ------------------------------------------------------------------
366
+ # Sandbox execution (runs:simulate) — the ONLY execution here, $0
367
+ # ------------------------------------------------------------------
368
+
369
+ def sandbox_simulate(
370
+ self,
371
+ circuit: str,
372
+ *,
373
+ shots: int = 1000,
374
+ qubits: Optional[int] = None,
375
+ ) -> Union[SandboxRun, Refusal]:
376
+ """Run the circuit on the FREE local statevector simulator.
377
+
378
+ The request pins ``preferredProviders=["classical"]`` — the free
379
+ simulator — on EVERY call, so no key, whatever its scopes, can reach
380
+ billable hardware through this method (zero eligible providers is a
381
+ typed failure server-side, never a silent reroute). Sandbox-tier keys
382
+ (``runs:simulate``) are additionally clamped and daily-quota'd by the
383
+ platform. Real statevector physics, real counts, $0."""
384
+ params: Dict[str, Any] = {"shots": shots}
385
+ if qubits is not None:
386
+ params["qubits"] = qubits
387
+ raw = self._request(
388
+ "POST",
389
+ "/api/v1/execute",
390
+ body={
391
+ "intent": {"type": "circuit", "shots": shots},
392
+ "payload": {"circuit": circuit, "params": params},
393
+ # THE CLAMP. Do not remove, do not parameterize: this constant
394
+ # is what makes the SDK's "can never spend" claim true even
395
+ # for keys that carry runs:execute.
396
+ "preferredProviders": [FREE_SIMULATOR_PROVIDER],
397
+ },
398
+ )
399
+ return raw if isinstance(raw, Refusal) else SandboxRun.from_raw(raw)
400
+
401
+ # ------------------------------------------------------------------
402
+ # Account awareness (account:read)
403
+ # ------------------------------------------------------------------
404
+
405
+ def me(self) -> Union[Me, Refusal]:
406
+ """Who is this key? Tier, credits, granted scopes, rate limits.
407
+ Requires ``account:read``."""
408
+ raw = self._request("GET", "/api/v1/me")
409
+ return raw if isinstance(raw, Refusal) else Me.from_raw(raw)