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
or in Cargo.toml:
[]
= "0.1"
The library is imported as typesafe_system_one. The minimum supported Rust
version is 1.88, set by the dependency tree. (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 client is async only — there is no blocking client. Run it from an async runtime such as tokio:
[]
= { = "1", = ["macros", "rt-multi-thread"] }
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
use ;
async
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.