typesafe-api

September 20, 2026 · View on GitHub

CI crates.io docs.rs MSRV

A Rust client for the TypeSafe System One API.

System One models answer typed questions about a state and hand back structured values — a probability, a chosen option, a position on a rubric — with no text to parse. Rust already speaks in enums and structs, so this SDK closes the loop: describe a judgement once as a type, and that type is both the request you send and the answer you get back.

cargo add typesafe-api

Three ways to ask

Each layer is the one below it with more types. Nothing is hidden: drop a level at any point and you still see exactly what goes on the wire.

1. Questions and answers by name

Closest to the HTTP API, and the right choice when questions are built at runtime.

use typesafe_api::{Choice, Client, Noul, Score, questions};

let client = Client::from_env()?;

let answers = client
    .evaluate(
        "Hi, my Stripe integration has been failing for 3 days. I am losing sales.",
        questions! {
            "is_urgent" => Noul::new("The message conveys urgency or time-sensitivity"),
            "department" => Choice::new("Which team should handle this?")
                .option("billing", "Payment or subscription issues")
                .option("technical", "Bugs or integration problems")
                .option("sales", "Pricing or account questions"),
            "frustration" => Score::new("How frustrated the customer appears")
                .levels(["Calm, just stating facts", "Frustrated but civil", "Very angry"]),
        },
    )
    .await?;

if answers.noul("is_urgent")?.is_yes(0.9) {
    page_the_on_call();
}

The state is your own data. Every call takes impl Serialize, so a domain struct goes straight through:

#[derive(Serialize)]
struct Ticket<'a> {
    message: &'a str,
    plan: &'a str,
    open_orders: &'a [Order],
}

let answers = client.evaluate(&ticket, questions).await?;

2. Options and levels as enums

A Choice is a closed set of named outcomes, which is what an enum is. The derive writes the criteria from your doc comments and reads the answer back into a variant.

use typesafe_api::Options;

#[derive(Options, Debug)]
enum Department {
    /// Payment or subscription issues
    Billing,
    /// Bugs or integration problems
    Technical,
    /// A request that fits none of the above
    Other,
}

let department = answers.choice_as::<Department>("department")?;

match department.value {
    Department::Technical => assign(ticket, Team::Engineering),
    Department::Billing => assign(ticket, Team::Finance),
    Department::Other => triage_by_hand(ticket),
}

for (team, share) in department.runners_up(0.25) {
    notify(ticket, team);
}

3. The whole evaluation as one struct

The struct declares the questions and receives the answers. One definition, one request, one typed result.

use typesafe_api::{ChoiceOf, Evaluation, Levels, NoulAnswer, ScoreOf};

#[derive(Levels)]
enum Frustration {
    /// Calm, just stating facts
    Calm,
    /// Frustrated but civil
    Frustrated,
    /// Very angry, strong language or threatening to leave
    VeryAngry,
}

#[derive(Evaluation)]
struct Triage {
    /// The message conveys urgency or time-sensitivity
    is_urgent: NoulAnswer,

    /// Which team should handle this?
    department: ChoiceOf<Department>,

    /// How frustrated the customer appears
    frustration: ScoreOf<Frustration>,
}

let triage: Triage = client.evaluate_as(&ticket).await?;

// Normalize before weighting: a three-level and a four-level scale are not
// comparable until both sit on 0..=1.
let priority = Composite::new()
    .weigh(0.6, triage.frustration.normalized())
    .weigh(0.4, triage.is_urgent.noul)
    .sum();

Every question in one request is evaluated independently and in parallel against the same state, so adding questions costs tokens rather than latency. Ask everything your code might need and ignore the answers it does not use.

Choosing a question type

Your questionPrimitiveWhat comes back
Is this true?Noulone probability, 0 to 1
Which one of these?Choicethe pick, every probability, confidence
Where on this scale?Scorea weighted position, every probability, confidence

A Noul carries no separate confidence: with two outcomes, the single value already describes the distribution.

Without a runtime

The blocking client shares every type with the asynchronous one.

use typesafe_api::{blocking::Client, Noul, questions};

let client = Client::from_env()?;
let answers = client
    .evaluate(&ticket, questions! { "billing" => Noul::new("Is this about billing?") })
    .send()?;

Over a corpus

let results = client
    .evaluate_many(tickets, Triage::questions())
    .concurrency(16)
    .collect_all()   // keeps failures rather than discarding the batch
    .await;

Configuration

use std::time::Duration;
use typesafe_api::{Client, RetryPolicy};

let client = Client::builder()
    .model("jev-1.13.0")                    // pin a version rather than an alias
    .timeout(Duration::from_secs(5))        // per attempt
    .deadline(Duration::from_secs(20))      // whole call, retries included
    .retry(RetryPolicy::default().max_retries(4))
    .header("x-team", "payments")
    .http_client(my_reqwest_client)         // your own pool, proxy, or transport
    .build()?;

Explicit values win over environment variables, which win over defaults.

VariableSetsDefault
TYPESAFE_API_KEYthe API keyrequired
TYPESAFE_BASE_URLAPI roothttps://api.typesafe.ai
TYPESAFE_DEFAULT_MODELdefault modeljev-latest

Any option can be overridden for a single call:

client.evaluate(&state, questions)
    .model("jev-preview")
    .timeout(Duration::from_secs(2))
    .retry(RetryPolicy::none())
    .extra_field("beam_width", 4)   // a field the API has and this crate does not
    .await?;

Nothing is hidden

Every convenience is built from a public layer you can reach:

let request = client.request(&state, questions)?;   // exactly what will be sent
println!("{}", serde_json::to_string_pretty(&request)?);

let raw: serde_json::Value = client.send_raw(&request).await?;   // untouched body
let response = client.send(&request).await?;
let typed: Triage = response.extract()?;

Answer kinds this crate does not model are kept as Answer::Unknown with their payload intact, so a newer API stays readable without an upgrade.

Errors

Failures say what happened, where, and whether trying again could help.

match client.evaluate(&state, questions).await {
    Ok(answers) => route(answers),
    Err(error) if error.is_retryable() => defer(job),
    Err(error) => tracing::error!(request_id = error.request_id(), %error, "evaluation failed"),
}

429, 529, and 5xx are retried with exponential backoff and jitter, honouring Retry-After when the server sends one. Requests are checked locally first, so a malformed body comes back as a list of paths rather than a 422:

request rejected before sending (2 problem(s))
  - questions.tone.criteria: a choice must offer at least one option
  - questions.severity.criteria: 12 levels exceeds the limit of 10

The API key is held in a SecretString and marked sensitive on the header, so it cannot reach Debug output or a log line.

Features

FeatureDefaultWhat it adds
rustls-tlsyesTLS via rustls
native-tlsnoTLS via the platform stack
blockingnoblocking::Client, for code without a runtime
derivenothe Evaluation, Options, and Levels derive macros
streamnoevaluate_many, bounded fan-out over many states
preserve-ordernoauthored key order inside every nested JSON object

The traits the derives target are always available, so Options and Levels can be written by hand when a description needs more than a doc comment.

Documentation

Contributing

See CONTRIBUTING.md. Issues and pull requests are welcome.

Licence

Dual-licensed under MIT or Apache-2.0, at your option.

This is a community project. It is not affiliated with or endorsed by TypeSafe.