Skip to content

Hardware providers

A hardware provider connects the kernel to one vendor’s queue. Implement the ABC in kernel/hardware/base.py, register one instance in the manager, add a credential form entry in the frontend — the job tracker, persistence, and polling come for free.

MethodContract
connect(credentials) -> boolAuthenticate with the vendor SDK. Return False on bad credentials or missing SDK — don’t raise. Print a hint (it reaches the kernel log).
list_backends() -> list[BackendInfo]Return BackendInfo entries; [] when not connected. Swallow per-backend SDK errors and skip the entry.
submit_job(circuit_obj, backend, shots) -> JobHandleSubmit and return a JobHandle. See the failure convention below.
get_results(job) -> dict{"measurements": {...}, "status": "complete"} when done; {"status": "running", ...} while pending; {"error": ...} on failure or unknown id — dicts, not exceptions.
get_queue_position(job) -> intCurrent position; -1 when unknown.
cancel_job(job) -> boolOptional override. The inherited default no-ops and returns True — correct for synchronous providers (local simulator, NVIDIA).

The failure convention: return a failed handle, don’t raise

Section titled “The failure convention: return a failed handle, don’t raise”

The Azure provider is the model:

  • Raise (RuntimeError) only for misusesubmit_job called before connect. The manager raises the same way for unconnected providers , and the server turns the exception into the bare hardware error envelope.
  • Every provider-side failure returns a JobHandle with status="failed" and a human-readable error — target lookup failed, target not found, SDK submit exploded. The frontend then receives a structured hardware_job_submitted carrying the failed job instead of a string, and the job appears in the tracker with its error.
  • Distinguish failure messages by actionability: “target not found — check the name” (user can act) vs “submit failed: <SDK error>” (file a bug / retry).

One line in HardwareManager.__init__ — the dict key is the provider id used in every hardware_* message:

self._providers = {
"simulator": SimulatorProvider(),
...
"echo": EchoProvider(), # your provider
}

The constructor must be cheap and must not import the vendor SDK — providers are instantiated unconditionally at kernel start; import the SDK lazily inside connect() like every existing provider does.

What the manager does for you (JobStore interplay)

Section titled “What the manager does for you (JobStore interplay)”

You keep SDK job objects in memory; the manager owns everything durable:

  • On submit: registers the handle and immediately persists a JobRecord (id, provider, backend, status, shots, timestamps) to the file-backed JobStore, so a kernel crash between submit and first poll doesn’t orphan the job.
  • On polling: write-through of queue-position updates for queued/running jobs.
  • On restart: terminal jobs (complete/failed/cancelled) reload verbatim; non-terminal jobs come back as stale (your SDK handle is gone) and are never polled against your provider. You don’t handle rehydration at all.
  • On connect: credentials persist to the OS keyring and auto-reconnect on the next kernel start — silently, with failures logged not raised.

Credential wiring (and the key-name gotcha)

Section titled “Credential wiring (and the key-name gotcha)”

The kernel-side field names are the contract. Whatever keys your connect(credentials) reads must be exactly the keys the frontend sends.

  1. Define the keys your provider reads, e.g. credentials.get("token", "").
  2. Add an entry to PROVIDER_CONFIG in src/components/hardware/CredentialSetup.tsx:13 — label, description, and fields: [{ key, label, placeholder }]. The key of each field becomes the credentials-dict key sent to the kernel, and the dialog sends credentials straight to the kernel (never to localStorage).
  3. Add the provider id to the HardwareProviderType union in src/types/hardware.ts.

The smallest possible provider, modeled on kernel/hardware/simulator_provider.py: connect always succeeds, one fake backend, and submit_job completes synchronously, echoing the requested shot count into a fake counts dict. The file lives at docs-site/fixtures/examples/echo_provider.py and kernel/tests/test_docs_examples.py injects it into a real HardwareManager (the same injection pattern as kernel/tests/hardware/test_manager.py), connects, submits, and asserts the results round-trip — so this example cannot rot.

docs-site/fixtures/examples/echo_provider.py
"""Worked example for /docs/extending/hardware-providers/ — the smallest
possible HardwareProvider.
Modeled on kernel/hardware/simulator_provider.py: jobs complete
synchronously, and "results" just echo the requested shot count into a fake
counts dict. kernel/tests/test_docs_examples.py runs this file against a
real HardwareManager so the example cannot rot.
"""
import uuid
from datetime import datetime, timezone
from kernel.hardware.base import BackendInfo, HardwareProvider, JobHandle
ECHO_BACKEND = BackendInfo(
name="echo_1",
provider="echo",
qubit_count=8,
connectivity=[(i, i + 1) for i in range(7)],
queue_length=0,
average_error_rate=0.0,
gate_set=["H", "X", "CNOT", "Measure"],
status="online",
)
class EchoProvider(HardwareProvider):
def __init__(self):
self._results: dict[str, dict] = {}
def connect(self, credentials: dict) -> bool:
# Validate credentials and authenticate with the vendor SDK here.
# Return False (don't raise) when auth fails. Echo accepts anything.
return True
def list_backends(self) -> list[BackendInfo]:
# Return [] when not connected; never raise from here.
return [ECHO_BACKEND]
def submit_job(self, circuit_obj, backend: str, shots: int) -> JobHandle:
job_id = str(uuid.uuid4())
now = datetime.now(timezone.utc).isoformat()
# Synchronous "hardware": the job is complete before submit returns.
# A real provider returns status="queued" here and stashes the
# vendor SDK's job object so get_results / get_queue_position can
# poll it later. Provider-side failures must be RETURNED as a
# status="failed" handle with `error` set — never raised.
self._results[job_id] = {
"measurements": {"00": shots}, # echo the shot count back
"status": "complete",
}
return JobHandle(
id=job_id,
provider="echo",
backend=backend,
status="complete",
queue_position=None,
shots=shots,
submitted_at=now,
)
def get_results(self, job: JobHandle) -> dict:
# Unknown ids and provider failures come back as {"error": ...}
# dicts, not exceptions.
return self._results.get(job.id, {"error": f"Job {job.id} not found"})
def get_queue_position(self, job: JobHandle) -> int:
return 0
# cancel_job: the inherited default (no-op returning True) is correct
# for synchronous providers. Queue-backed providers override it.

What the test verifies:

  • connect_provider("echo", {})True; list_backends("echo") returns the one backend (and [] before connecting — manager-enforced).
  • submit_job returns a complete handle; the manager registered and persisted it (get_job_status works immediately).
  • get_results returns {"measurements": {"00": shots}, "status": "complete"}.
  • Submitting while unconnected raises RuntimeError — from the manager, per the convention above.

Provider tests live in kernel/tests/hardware/, whose conftest.py isolates the OS keyring and job store automatically. Mock the vendor SDK at the import boundary (see test_ionq_provider.py, test_azure_provider.py) — CI installs no vendor SDKs. Cover at minimum: connect success/failure, list_backends when disconnected, the failed-handle submit path, and the results dict shapes for complete/running/failed/unknown.