Expand description
Unofficial async Rust client for the TypeSafe AI System One API (Jev).
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. This crate is a thin, well-behaved async client for it.
§Quick start
use typesafe_system_one::{Client, Noul, Choice, Score, SystemOneRequest};
let client = Client::from_env()?;
let response = client
.system_one(
SystemOneRequest::new("I was charged twice.")
.question("billing", Noul::new("Is this about billing?"))
.question(
"tone",
Choice::new("What is the tone?")
.option("calm", "A neutral or polite message")
.option("angry", "An upset or hostile message"),
)
.question(
"urgency",
Score::new("How urgent is this?", ["Can wait", "Needs attention today"]),
),
)
.await?;
println!("model: {}", response.model);
println!("billing noul: {:?}", response.noul("billing").map(|a| a.noul));
if let Some(choice) = response.choice("tone") {
if choice.confidence >= 0.7 {
println!("tone: {}", choice.choice);
}
}The client is async only: every method returns a future, and there is no
blocking variant. Run it from an async runtime such as
tokio.
§Overview
Client— construct withClient::builderorClient::from_env.SystemOneRequest— build a request withNoul,Choice, andScorequestions.SystemOneResponse— typed answers plus usage and request metadata.RetryPolicy— retries, backoff, and retry-after handling.Error— everything that can go wrong.
See README.md for a longer guide, and the repository’s SPEC.md for the
authoritative behavior contract both clients in this repo implement.
This crate is unofficial and not affiliated with TypeSafe.
§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:
cargo add typesafe-system-one
cargo add tokio --features macros,rt-multi-threadwhich gives you, in Cargo.toml:
[dependencies]
typesafe-system-one = "0.1"
tokio = { version = "1", features = ["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 typesafe_system_one::{Choice, Client, Noul, Score, SystemOneRequest};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:
typesafe-system-one = { version = "0.1", features = ["native-tls"] }§Quick start
A complete program. Put it in src/main.rs:
use typesafe_system_one::{Choice, Client, Error, Noul, Score, SystemOneRequest};
#[tokio::main]
async fn main() -> Result<(), Error> {
let client = Client::from_env()?; // reads TYPESAFE_API_KEY
let response = client
.system_one(
SystemOneRequest::new("Help! My payouts have been failing for 3 days.")
.question("is_urgent", Noul::new("Does this convey urgency?"))
.question(
"department",
Choice::new("Which team should handle this?")
.option("billing", "Payments, invoicing, refunds")
.option("technical", "Bugs, outages, integrations")
.option("sales", "Pricing, upgrades, new accounts"),
)
.question(
"frustration",
Score::new(
"How frustrated is the customer?",
["Calm", "Frustrated", "Very angry"],
),
),
)
.await?;
// A successful response always has an answer for every question asked,
// so these lookups only fail on a typo in the question ID.
let urgent = response.noul("is_urgent").expect("asked is_urgent");
let department = response.choice("department").expect("asked department");
let frustration = response.score("frustration").expect("asked frustration");
println!("urgent: P(yes) = {:.2}", urgent.noul);
println!(
"department: {} (confidence {:.2})",
department.choice, department.confidence
);
println!("frustration: {:.2} on a 0–2 scale", frustration.score);
println!(
"answered by {} (request {})",
response.model,
response.request_id.as_deref().unwrap_or("-")
);
Ok(())
}Starting from an empty directory:
cargo new triage && cd triage
cargo add typesafe-system-one
cargo add tokio --features macros,rt-multi-thread
# replace src/main.rs with the program above
export TYPESAFE_API_KEY=...
cargo runMore 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.
let client = Client::builder()
.api_key("sk-live-...") // else TYPESAFE_API_KEY
.base_url("https://api.typesafe.ai") // else TYPESAFE_BASE_URL
.default_model("jev-1.13.0") // else TYPESAFE_DEFAULT_MODEL, else "jev-latest"
.timeout(Duration::from_secs(10)) // per attempt; must be > 0
.retry_policy(RetryPolicy { max_retries: 3, ..Default::default() })
.default_header("X-Agent-Client", "my-app") // gateway attribution
.log_level(LogLevel::Info) // else TYPESAFE_LOG_LEVEL, else off
.build()?;§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.
let request = SystemOneRequest::new("I was charged twice.")
.model("jev-1.13.0") // optional per-request override
.question("billing", Noul::new("Is this about billing?"))
.question(
"urgent",
Noul::new("Is this urgent?")
// optional yes/no criteria; None omits that side
.criteria(Some("Time-sensitive"), None::<&str>),
)
.question(
"tone",
Choice::new("What is the tone?")
.option("calm", "Neutral or polite")
.option("angry", "Upset or hostile")
.bare_option("excited"), // null description: interpreted by name
)
.question(
"dept",
Choice::from_options("Which team?", ["billing", "tech"]), // all-null descriptions
)
.question(
"frustration",
Score::new("How frustrated?", ["Calm", "Frustrated", "Very angry"]),
);§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:
let request = SystemOneRequest::new(json!({
"subject": "Charged twice",
"body": "Please refund the duplicate charge.",
}))
.question("billing", Noul::new(json!({
"task": "Identify billing problems.",
"include": ["duplicates", "refunds"],
})))
.question(
"urgency",
Score::new(json!({"task": "Rate urgency.", "scale": "calendar"}), [
json!({"label": "Low", "hint": "no deadline"}),
json!({"label": "High", "hint": "today"}),
]),
);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:
let response = client
.system_one(
SystemOneRequest::new("I was charged twice.").question(
"tone",
Choice::new("What is the tone?")
.option("calm", "Neutral or polite")
.option("angry", "Upset or hostile"),
),
)
.await?;
let route = match response.choice("tone") {
Some(choice) if choice.confidence >= 0.7 => choice.choice.clone(),
_ => "human-triage".to_owned(), // low confidence: don't trust it
};§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.
match &error {
Error::Api(api) if api.kind == typesafe_system_one::ApiErrorKind::RateLimit => {
eprintln!("rate limited; retry after {:?}", api.retry_after);
}
Error::Timeout { timeout } => eprintln!("timed out after {timeout:?}"),
_ => eprintln!("{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:
let response = client
.system_one_with(
SystemOneRequest::new("text").question("a", Noul::new("q")),
RequestOptions::new()
.timeout(Duration::from_secs(30))
.retry_policy(RetryPolicy { max_retries: 5, ..Default::default() }),
)
.await?;- 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):
// OpenRouter
let openrouter = Client::builder()
.api_key("sk-or-...")
.base_url("https://openrouter.ai/api")
.default_model("~typesafe/jev-latest")
.default_header("X-Agent-Client", "my-app")
.build()?;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.
Structs§
- ApiError
- A request failed because the HTTP response carried an error status.
- Choice
- A question that selects one option from choices the caller defines.
- Choice
Answer - The answer to a choice question.
- Client
- An async client for the TypeSafe System One API.
- Client
Builder - A builder for
Client. - Model
Metadata - Metadata describing one available model or alias.
- Noul
- A yes/no question, answered with the probability of yes.
- Noul
Answer - The answer to a yes/no question: the probability of yes (0 to 1).
- Noul
Criteria - Optional descriptions of the yes and no outcomes of a noul question.
- Request
Options - Per-call options that override the client’s settings for one call.
- Retry
Policy - Configuration for retry behavior.
- Score
- A question that rates the content against an ordered rubric.
- Score
Answer - The answer to a score question: a probability-weighted average of the rubric levels.
- System
OneRequest - A request to answer questions about some content.
- System
OneResponse - The response to a
SystemOneRequest. - Usage
- Token usage for a request. Both fields default to 0 when the server omits them, because some gateways omit usage.
Enums§
- Answer
- Any answer, discriminated by its
typefield. - ApiError
Kind - The kind of API error, derived from the HTTP status code.
- Error
- Every error this crate can return.
- LogLevel
- The logging verbosity of the client.
- Question
- Any of the three question kinds, discriminated on the wire by
type.
Constants§
- DEFAULT_
BASE_ URL - The base URL used when neither the builder nor
TYPESAFE_BASE_URLset one. - DEFAULT_
MODEL - The model used when neither the builder nor
TYPESAFE_DEFAULT_MODELset one. - DEFAULT_
TIMEOUT - The default per-attempt timeout (covers connect through reading the full body).
- REQUEST_
ID_ HEADER - The
x-typesafe-request-idresponse header, exposed on responses and errors.