positronic-wire
The client side of the transports a Positronic inference session runs over: websockets, gRPC, and a
partner's own protocol. It carries the facts both ends of a wire share. It is one distribution,
installable on its own. The core has no third-party dependencies; the grpc and websocket extras
install grpcio and websockets independently.
Alpha, under rapid development. Names and behaviour change without notice, and nothing here is covered by a backwards-compatibility guarantee. Pin the exact version you tested against.
uv add "positronic-wire[websocket,grpc]==0.12.0"
uv add "positronic-wire[websocket,grpc] @ git+https://github.com/Positronic-Robotics/positronic@<tag or commit>#subdirectory=wire"
For an unpublished revision, use the Git installation pinned to a tag or commit.
positronic_wire never imports positronic or positronic_model_server. Both packages depend on
it: the server transports in positronic_model_server import the shared wire definitions, and
positronic.offboard.client.InferenceClient dials through the client wires.
What a wire is
A wire is one transport that carries a protocol's frames as opaque bytes. It has two ends. The
client end dials a session and probes a server; the server end accepts sessions and refuses an
unauthorized peer. Everything specific to a transport — the library, its exception types, its
status codes, its metadata keys, the path a probe asks for, the URL scheme it writes — lives inside
that transport's wire classes. Code outside a wire speaks to every transport through one interface
and reads one answer. A wire over TLS is a member of its own, so no caller holds a secure flag,
and nothing anywhere reads a transport off a URL scheme: a caller names the wire.
WebSocket and gRPC carry the session frames used by positronic_model_server and the legacy
positronic.offboard server. Both servers use transports from positronic_model_server.
roboarena carries a partner's own protocol. The partner serves it, and this package holds the
client end alone.
The package boundary
| Module | Holds |
|---|---|
positronic_wire.wire |
The routes (API_PATH, SESSION_PATH, KEEPALIVE_PATH), ALIVE_SECONDS, the key the keepalive answer carries, MAX_MESSAGE_BYTES, the addresses HostPortAddress(host, port, path, query) and UnixSocketAddress(uds, path, query) under the abstract SessionAddress, the type variable AddressT over them, netloc, bracket_ipv6(host), Refusal, ConnectRefused, PeerDisconnected, KeepaliveUnsupported, and the abstract ClientWire and ClientConnection |
positronic_wire.websocket |
WebsocketClientWire, WebsocketTlsClientWire, WebsocketUnixClientWire, WebsocketClientConnection, refusal_of(raised), which reads a failed handshake as a Refusal, and connected_socket(host, port, timeout), a TCP connect whose timeout covers the name lookup and every address |
positronic_wire.grpc |
GrpcClientWire, GrpcTlsClientWire, GrpcClientConnection, target(host, port), and the calls both ends agree on: SERVICE, METHOD, METHOD_PATH, KEEPALIVE_METHOD, KEEPALIVE_METHOD_PATH, PROBE_PATH, SESSION_PATH_HEADER, SESSION_QUERY_HEADER, MESSAGE_SIZE_OPTIONS, PING_EVERY_MS |
positronic_wire.roboarena |
RoboarenaClientWire, RoboarenaClientConnection, RoboarenaAddress, and TextAnswer, which a text frame raises. The handshake carries the headers the caller gives, and none where it gives none |
positronic_wire.registry |
CLIENT_WIRES, every installed member by its NAME, and client_wire(name) |
positronic_model_server owns the server transports:
server_wire.Wire, server_wire.ServerConnection, websocket_wire.WebsocketWire and
grpc_wire.GrpcWire. It also owns ModelServer, shared message definitions and value serialization.
The WebSocket server uses Uvicorn directly; the gRPC server uses grpcio's grpc.aio API.
Each server transport has its own optional installation extra.
positronic.offboard owns InferenceClient, the legacy PolicyServer, and v1/v2 robot-command
encoding. positronic-wire does not depend on either server package.
The client interface
A caller selects a wire by NAME: websocket, websocket_tls, websocket_unix, grpc, grpc_tls,
roboarena. A wire dials an address of its ADDRESS type, and DEFAULT_PORT names the port a URL
leaves out on the members that carry one.
session_url(address)— the session as this wire names it, for a log and for an error. The websocket members writews://orwss://,websocket_unixwritesws+unix://, and the gRPC members writehost:port, androboarenawrites the root it dials. What a wire dials is its own:websocket,websocket_tlsandroboarenadial this spelling, and the others dial a socket or a target instead.dial(address, headers, open_timeout)— a client's end of one session. It raisesConnectRefusedwhen the session does not open, whatever refused it. Therefusalon the exception says what the caller does next:COLD(a backend still starting) andSILENT(nothing answered) retry,FORBIDDENretries a few times,FINALsurfaces at once.probe(address, headers, open_timeout)— whether a server answers at the address, without opening a session. It carries the sameheadersasdial, so an edge that authenticates on them lets the probe through to the server behind it: the probe wakes what a session would reach.Nonewhen a server answers; aRefusalsays why none did, in the termsdialuses,FORBIDDENamong them for a credential the edge refused. The websocket wire asks the host's root for an upgrade, which the server refuses with 403 and nothing else answers 403 there. The gRPC wire callsPROBE_PATH, which a server that is up answersUNIMPLEMENTED. The roboarena wire opens the root and reads the frame the server announces itself with.keepalive(address, headers, timeout)— resets the server's idle timer outside a session, and returns the seconds the server stays alive after the call, orNonefor a server with no idle timeout.timeoutbounds the whole call. The websocket members sendPOSTtoKEEPALIVE_PATH, and the gRPC members callKEEPALIVE_METHOD_PATH.keepaliveraisesKeepaliveUnsupportedwhere the server serves sessions but not the call, andConnectRefusedotherwise, in the termsprobeuses: a 200 that does not carry the keepalive answer isFINAL.roboarenaalways raisesKeepaliveUnsupported, and dials nothing.
registry.client_wire(name) selects an installed transport. CLIENT_WIRES includes only families
whose libraries are installed; requesting an unavailable transport raises with installation guidance.
Use [websocket] for WebSocket and Roboarena, [grpc] for gRPC, or [websocket,grpc] for all wires.
Importing the core registry without either extra produces an empty registry. Broken imports from an
installed transport surface as errors.
Each wire declares the address it dials, and takes no other. ClientWire.ADDRESS names that
type, and every verb above takes it. websocket, websocket_tls, grpc and grpc_tls take a
HostPortAddress — host, port, path (SESSION_PATH), query, as written.
websocket_unix takes a UnixSocketAddress — uds, path, query. roboarena takes a
RoboarenaAddress — host and port. A record that names an endpoint carries the wire's name and
that wire's fields, never a URL, and no address carries a field a wire ignores.
uds is an absolute path to a Unix socket a server on the same machine bound, dialled instead of
the network. The address refuses a relative path when it is built, because a relative one names a
different socket to each caller. It names no host and no port, because a socket has neither: the
handshake carries localhost as a stand-in the server never resolves. A socket is same-machine by
construction, so no TLS member sits beside it. An absent path is SILENT: the client cannot tell a
misspelt path from a socket nobody has bound yet, so it retries either to its deadline. A refusal
from a socket a server is restarting on is SILENT too. A path holding something that is not a
socket, and a refused permission, are FINAL, because no retry reaches them. A handshake that timed
out or was reset reached the socket, so the server rather than the path was not ready, and it reads
SILENT as it does on a port.
roboarena is a partner's own protocol: msgpack frames on a websocket at the bare root of a port the
partner publishes. The server closes any other path, and it routes on a key inside each frame, so the
address names no route and no query. It publishes no default port either, so every address states one.
The server announces its configuration as the first frame of every connection: dial leaves that frame
for the caller's codec, and probe reads it and closes. A server that upgrades the connection and
announces nothing is a backend still starting, so it reads COLD. The server reports a failure in a
text frame and serves nothing more on that connection, so recv and probe raise TextAnswer, which
carries the text: a retry does not change it. The protocol names no URL scheme and the port is plain,
so no TLS member sits beside this wire; a partner who terminates TLS in front of it refuses a handshake
in the terms the websocket members already read.
Typing carries the split: a wire handed the other wire's address is a type error at the call site.
registry.client_wire(name) answers by name and cannot, so InferenceClient checks ADDRESS once,
before it dials, and names both in the refusal.
Whether a policy server is up
keepalive answers it, and probe answers it where a server serves no keepalive call. Each one's
timeout is one wall-clock deadline over the name lookup, every address the name resolves to, and every
read, so a server the caller did not write cannot hold the call past it. Both open their own socket, so
they ignore a proxy the environment names, where dial goes through it.
| What came back | Means |
|---|---|
keepalive returns |
a policy server admitted the headers |
KeepaliveUnsupported, then probe returns None |
a server answers and serves no keepalive call |
FORBIDDEN |
an answer that refused the credential |
COLD |
an answer that asks for a retry: a 5xx, a 429 |
FINAL |
an answer no retry changes: any other status, a 200 without the keepalive answer, a host with no address |
SILENT |
no answer: no connection, or none before the timeout or the close |
COLD and SILENT mean the server is not up yet. roboarena raises KeepaliveUnsupported without a
dial, and its probe raises TextAnswer where the server announces a failure. The gRPC members read
SILENT off the details gRPC writes for a connect nothing answered, and off DEADLINE_EXCEEDED.
The libraries cap what a call reads: websockets reads at most 128 headers of 8 KiB and 1 MiB of a
refused upgrade's body, and http.client at most 100 headers of 64 KiB. The websocket members read at
most 16 KiB of a keepalive body, and the roboarena member one frame of at most MAX_MESSAGE_BYTES.
What each consumer pays
| Consumer | Needs | Installs |
|---|---|---|
A rig client, and positronic itself |
Every verb, the session protocol, the policy stack | positronic, which pins positronic-wire exactly |
| A coordinator that probes an endpoint and warms it | registry.client_wire, probe, PROBE_PATH, the routes |
positronic-wire[websocket,grpc], or just the required transport extra |
| A service that validates an endpoint record | registry.CLIENT_WIRES |
positronic-wire with the transport extras it accepts |
| A host that runs a policy server and waits for it before it sends sessions | keepalive, probe |
positronic-wire[websocket,grpc], or just the required transport extra |
| A native model server | The shared server and its transports | positronic-model-server[websocket,grpc], or just the required transport extra |
| A legacy policy server | positronic.offboard.server.PolicyServer |
positronic |
A consumer whose lockfile already carries grpcio (through a cloud SDK) and websockets (through
uvicorn) adds no third-party package when it adds this one.
What the wire keeps out of its consumers
A transport-specific dial disappears from a consumer once it speaks to the wire. A consumer that
opens a websockets.connect of its own to wake an endpoint has to skip every gRPC endpoint, because
the same call cannot reach one; probe reaches both. A consumer that classifies a failed dial by
matching library exception names over the raised type's bases has to list grpc.RpcError,
websockets.* and httpx.*; dial raises one ConnectRefused for all of them, so isinstance
answers.
A copied fact disappears too. The gRPC probe path, the session route and the keepalive route are each
one symbol here, and the set of wires is registry.CLIENT_WIRES. A literal spelled a second time
in another repository drifts the day either side edits it, and nothing reports the drift; an
imported symbol cannot.
A scheme table disappears with them. A record that names an endpoint carries the wire's name and the fields that wire's address declares, so no reader of the record derives a transport, a TLS setting or a default port from the spelling of a URL.
The port a server serves a wire on, and the server flag that names it, stay literals where they are
spelled: they are a deployment's configuration, not wire facts. A test in the consumer that installs
positronic pins the flag name against the server's own signature.
Versioning and skew
positronic pins positronic-wire==<version> exactly, as it pins positronic-platform-client.
utilities/check_workspace_version_bump.py refuses a change under wire/ that reuses the version,
and one that leaves the root's pin behind. The release workflow publishes wire/ to the index
before positronic, with skip-existing, so an unchanged wire is a no-op and a changed one is on
the index before the release that depends on it.
A consumer outside this repository pins the wire the way it pins the client: by the index once the
wire is published there, and until then by git subdirectory at a commit
(positronic-wire @ git+…positronic@<sha>#subdirectory=wire). A consumer that also installs
positronic at a commit names the same commit for both, so the wire the git install provides is
the version positronic requires.
Two deployments of one consumer can hold different wire versions: a rig runs a pinned console on
its own schedule while a coordinator runs its checkout. The wire keeps that skew readable instead
of guarding it with a version floor. Each side reports the names its registry lists, and the side
that hands an endpoint record across refuses a wire name the other side's registry lacks. A
transport added to the wire is then one entry in registry.CLIENT_WIRES, and no consumer has to
raise a constant naming the version that first dialled it.
Adopting the wire in a consumer
A consumer moves onto the wire in this order, each step green on its own:
- Depend on
positronic-wirewith the required transport extras. Import the routes, the probe path and the registry; delete the local copies, the tables keyed by scheme, and every read of a URL scheme. An endpoint record names its wire, then that wire's address — the fieldsClientWire.ADDRESSdeclares, which differ per wire. Report the registry's names in the deploy handshake and retire any per-transport version floor. - Replace the transport-specific dial with
probe, and the exception-name match withisinstance(raised, (wire.ConnectRefused, wire.PeerDisconnected, TimeoutError)). - Move the consumer's own transports into their own
ClientWiresubclasses, each declaring the address it dials, registered by name beside the wire's own. - Deploy the pinned side at the commit that carries the wire, then the checkout side.
What is not shared
- Server transports live in
positronic-model-serverand use Uvicorn orgrpc.aio. - Model message definitions and value serialization live in
positronic-model-server. The policy stack and robotics codecs live inpositronic. Neither is a dependency ofpositronic-wire. - A transport this package does not implement. A consumer that owns one writes it as a
ClientWireof its own.
Metadata
Release files for positronic-wire 0.12.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| positronic_wire-0.12.0.tar.gz | 32.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| positronic_wire-0.12.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 71.0 kB
Release files / positronic_wire-0.12.0.tar.gz
| Download URL | positronic_wire-0.12.0.tar.gz |
|---|---|
| Size | 32.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6a13a592931778e7be6f0dabaecbdf3f14af28450b78501e0b0e642476e7743f
|
|
BLAKE2b-256 checksum How to use checksums |
c17ca1a10ade5e1be7a56f6c6a0dcff2bc87a963222cf3f3b42797fff1382c4d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Release files / positronic_wire-0.12.0-py3-none-any.whl
| Download URL | positronic_wire-0.12.0-py3-none-any.whl |
|---|---|
| Size | 38.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a481aafd0b934f9f561960249443c11ea1917cc97cbab666f7d1098c8d8e364a
|
|
BLAKE2b-256 checksum How to use checksums |
bb7d8c9e38b5e4c1d578097fe91a4ada06123e5b0b17597b0ac82cc601eb83f3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|