typesafe-system-one
Unofficial async Rust client for the TypeSafe AI System One API (Jev).
This crate is unofficial and not affiliated with TypeSafe.
System One answers noul (yes/no), choice (one-of-many), and score
(rubric rating) questions about arbitrary JSON content ("state") in a single
request, with calibrated probabilities and a confidence value you can gate on.
The behavior contract both clients in this repository implement is
SPEC.md; this README is a guide to the Rust crate.
Install
The client is async, so your program also needs an async runtime. It's built on tokio (via reqwest), so add both:
which gives you, in Cargo.toml:
[]
= "0.1"
= { = "1", = ["macros", "rt-multi-thread"] }
If you build structured (JSON) instructions or state with the json! macro,
also cargo add serde_json.
In code, the crate is typesafe_system_one:
use ;
You need an API key from the TypeSafe console.
Client::from_env() reads it from TYPESAFE_API_KEY, or pass it with
Client::builder().api_key(...).
The minimum supported Rust version is 1.88, set by the dependency tree.
There is no blocking client. (The crate lives in the
haileyok/typesafe-client
repository alongside a Go client. The typesafe-client name on crates.io
belongs to an unrelated project.)
The crate uses rustls for TLS. The native-tls cargo feature additionally
compiles in reqwest's system TLS backend. To use it, build your own
reqwest::Client configured for native TLS and pass it via
ClientBuilder::http_client:
= { = "0.1", = ["native-tls"] }
Quick start
A complete program. Put it in src/main.rs:
use ;
async
Starting from an empty directory:
&&
# replace src/main.rs with the program above
More complete programs are in examples/: quickstart.rs, and
triage.rs, which shows speculative fan-out and confidence-gated routing. The
API reference is on docs.rs.
Configuration
Construct with Client::builder or
Client::from_env(). Explicit options override environment variables, and
environment variables override defaults. Environment values are trimmed; a
blank value is ignored.
| Setting | Builder method | Env var | Default |
|---|---|---|---|
| API key (required) | .api_key(k) |
TYPESAFE_API_KEY |
none — construction fails |
| Base URL | .base_url(u) |
TYPESAFE_BASE_URL |
https://api.typesafe.ai (trailing / stripped) |
| Default model | .default_model(m) |
TYPESAFE_DEFAULT_MODEL |
jev-latest |
| Per-attempt timeout | .timeout(d) |
— | 10s (covers connect through reading the full body) |
| Log level | .log_level(l) |
TYPESAFE_LOG_LEVEL |
off (debug, info, warn, error, off) |
| Retry policy | .retry_policy(p) |
— | see Retries |
The API key is trimmed and validated at construction: empty keys, and keys
containing whitespace, control characters, or non-ASCII characters, are
rejected with a configuration error. The key never appears in Debug output
or in any error.
# use Duration;
# use ;
let client = builder
.api_key // else TYPESAFE_API_KEY
.base_url // else TYPESAFE_BASE_URL
.default_model // else TYPESAFE_DEFAULT_MODEL, else "jev-latest"
.timeout // per attempt; must be > 0
.retry_policy
.default_header // gateway attribution
.log_level // else TYPESAFE_LOG_LEVEL, else off
.build?;
# Ok::
Questions
A request carries a state (the content to evaluate — anything that
serializes to a JSON string, object, or array) and named questions. Question
IDs are the map keys: they are for your code only and are not sent to the
model; the response keys its answers by the same IDs.
# use ;
# use json;
let request = new
.model // optional per-request override
.question
.question
.question
.question
.question;
# let _ = request;
Ordered choice options
Choice options serialize in the order you supply them; Choice::option
appends, and Choice::from_options / Choice::from_pairs preserve iteration
order.
Advanced structure
instructions and criteria accept any impl Into<serde_json::Value>, so
structured instructions work:
# use ;
# use json;
let request = new
.question
.question;
# let _ = request;
Any Serialize type works as state via
SystemOneRequest::with_state_serialize(&my_struct)?.
Client-side validation
Before any network I/O, the client rejects: an empty question set; a choice
with no options; a score with fewer than two levels or any null level; and a
state that is not a JSON string, object, or array (numbers, booleans, and
null are rejected). Each failure is an Error::InvalidRequest naming the
offending question. Upper limits (255 choice options, 10 score levels) are
enforced server-side with a 422 and are not checked client-side.
Reading answers
Answers are keyed by your question IDs. response.noul(id), .choice(id),
and .score(id) return typed answers; response.nouls(), .choices(), and
.scores() iterate all of a kind. An answer whose type this client doesn't
know is kept losslessly as an Answer::Unknown { kind, raw } rather than
dropped or errored — forward compatibility for when the API adds kinds.
Score legend and probabilities use decimal level-index keys on the wire;
the client exposes them as integer keys (BTreeMap<u32, _>).
Confidence gating
Confidence (0 to 1) tells you when to route on an answer and when to fall back to a human. A common pattern:
# use ;
# async
Errors
Error is #[non_exhaustive]:
Config— missing or invalid configuration (never echoes the API key).InvalidRequest— client-side validation failed.Api— a non-2xx response.ApiErrorcarries the status, kind (ApiErrorKind), extracted message, parsed body, headers, request ID, endpoint, and parsed retry-after.Connection— no HTTP response (DNS, TLS, reset, body read failure).Timeout— the attempt exceeded the per-attempt timeout (a kind of connection error;is_connection()returns true for it).ResponseValidation— a 2xx body didn't match the schema, with a dotted field path likeanswers.tone.confidenceand the HTTP status. This also covers a response that omits an answer for a question you asked (answers.<id>) or answers it with the wrong type (answers.<id>.type). A successful response therefore always has an answer for every question, soresp.noul("id")returningNonenever silently means "no".
Helpers: is_timeout(), is_connection(), status(), request_id(),
as_api().
The API error Display format is exactly
"<METHOD> <URL>: <status> <message> (request_id=<id>)".
The message is extracted from the body (first match wins): non-empty string
body (truncated to 200 characters + …), error (string), error.message,
message, detail (string), detail.message, or detail[] as FastAPI
validation errors ("questions.urgency.score.criteria: Field required").
Otherwise the raw body truncated to 200 characters + …; an empty body gives
"status code (no body)". The API key never appears in any error.
# use Error;
#
Retries
The default policy matches both official SDKs: 2 retries after the first
attempt, 500ms initial backoff doubling to a 5s cap with 25% jitter, retries
on 408/429/5xx (and connection and timeout errors), honored
retry-after-ms/Retry-After capped at 60s, and a 30s total budget.
Every field is public on RetryPolicy. A per-call policy fully replaces the
client's policy for that call:
# use Duration;
# use ;
# async
- Delay for retry n (0-based): an honored retry-after is used exactly;
otherwise
min(initial * 2^n, max) * (1 - rand[0,1) * jitter). If initial or max is 0, the delay is 0. - Stop conditions: retries exhausted; the error isn't retryable; the next delay would reach or exceed the remaining total budget — in which case the last real error is returned (you see the 529, not an artificial timeout).
- A retry sets the
X-TypeSafe-Retry-Countheader (n ≥ 1).
Timeouts and cancellation
The timeout (default 10s) is per attempt and covers connecting through
reading the full response body. Timeouts are retried by default like
connection errors; exhaustion surfaces as Error::Timeout.
Cancellation is native Rust: drop the future. Nothing runs after the future is dropped, and no cancellation error is synthesized.
Gateways
Any base URL implementing the TypeSafe OpenAPI spec works. Add attribution
headers with .default_header(...) (SDK-owned headers always win):
# use Client;
#
Vercel AI Gateway works the same way with
https://ai-gateway.vercel.sh/typesafe and model typesafe-ai/jev.
Logging
Off unless configured (LogLevel or TYPESAFE_LOG_LEVEL).
- info: one line per attempt result (method, path, status, duration, request ID) and per scheduled retry (delay and reason).
- debug: also request/response headers and bodies.
Authorization, Proxy-Authorization, X-Api-Key, Api-Key, Cookie, and
Set-Cookie headers are redacted to ***. Bodies are not redacted —
configure debug accordingly. Logs are emitted through the
tracing crate; install a subscriber
(like tracing-subscriber) to see them.
Environment variables
| Variable | Meaning |
|---|---|
TYPESAFE_API_KEY |
API key (required if not passed to the builder) |
TYPESAFE_BASE_URL |
Base URL override |
TYPESAFE_DEFAULT_MODEL |
Default model override |
TYPESAFE_LOG_LEVEL |
debug / info / warn / error / off |
Examples
examples/quickstart.rs— one of each question kind.examples/triage.rs— speculative fan-out: choice + score + noul in one request, then route in code.
License
MIT.