typesafe-ai-sdk 0.4.0

Rust client for the TypeSafe AI System One API (Noul, Choice and Score questions)
Documentation

typesafe-ai-sdk

Rust client for the TypeSafe AI System One API: send a state plus named, typed questions and get typed answers back.

Question Answer
Noul probability of "yes" (0–1)
Choice selected label, per-label probabilities, confidence
Score probability-weighted level, legend, per-level probabilities, confidence

Behaviour mirrors the official Python SDK (typesafe-sdk 0.7.1): the same environment variables, defaults, retry semantics, error classification and forward-compatible response decoding.

Unofficial. Not affiliated with TypeSafe AI.

Install

[dependencies]
typesafe-ai-sdk = "0.4"                                                 # async (bring your own Tokio runtime)
# typesafe-ai-sdk = { version = "0.4", features = ["blocking"] }        # sync client
# typesafe-ai-sdk = { version = "0.4", features = ["reqwest-client"] }  # bring your own reqwest::Client
# typesafe-ai-sdk = { version = "0.4", features = ["derive"] }          # #[derive(Rubric)]

The library is imported as typesafe. MSRV: Rust 1.88. TLS is rustls; HTTPS_PROXY-style environment variables are honoured.

Quick start

use typesafe::{Choice, Client, Noul, Questions, Score};

#[tokio::main]
async fn main() -> typesafe::Result<()> {
    let client = Client::from_env()?; // TYPESAFE_API_KEY

    let res = client
        .system_one(
            "I've been trying to connect my Stripe account for 3 days. Please help ASAP.",
            Questions::new()
                .with("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"))
                .with("frustration", Score::new("How frustrated the customer appears",
                    ["Calm", "Frustrated but civil", "Very angry"]))
                .with("is_urgent", Noul::new("The message conveys urgency")),
        )
        .await?;

    let dept = res.choice("department").unwrap();
    if dept.confidence > 0.5 {
        println!("route to {}", dept.choice);
    }
    println!("{:.2}", res.score("frustration").unwrap().score);
    println!("{}", res.noul("is_urgent").unwrap().is_yes(0.8));
    Ok(())
}

cargo run --example triage runs the same flow against the live API.

Examples

examples/ holds a program per topic — routing, moderation, batching, retries, error handling, the blocking client, and a fully configured one. Start with examples/errors.rs, which needs neither a key nor a network:

cargo run --example errors
cargo run --example triage           # needs TYPESAFE_API_KEY

See examples/README.md for the whole list.

State

state is anything Serialize: a string, json!({...}), or your own struct.

#[derive(serde::Serialize)]
struct Ticket<'a> { subject: &'a str, messages: Vec<&'a str> }

client.system_one(Ticket { subject: "Payouts", messages: vec![""] }, questions).await?;

Structured instructions and rubrics

Instructions, option descriptions and score levels accept any JSON value:

use typesafe::{Choice, Noul, Score, json};
Score::new(json!({"task": "rate tone", "ignore": ["signatures"]}),
           [json!({"level": "neutral"}), json!("hostile")]);
Noul::new("Is this a refund request?").when_true("Explicit ask for money back");
Choice::from_labels("Sentiment", ["positive", "neutral", "negative"]);

Typed choices

#[derive(Debug)]
enum Dept { Billing, Technical }
impl std::str::FromStr for Dept { /**/ }

let dept: Dept = res.choice("department").unwrap().parse()?;

#[derive(RubricChoice)] (below) writes that FromStr for you, and the options with it.

Rubrics as types

With the derive feature, a struct is the rubric: each field is a question, named after the field, and the answers come back into it. A misspelled name or an answer read as the wrong type is a compile error rather than a None at runtime.

use typesafe::{ChoiceOf, NoulAnswer, Rubric, RubricChoice, ScoreAnswer};

#[derive(Rubric)]
struct Triage {
    #[noul("The message conveys urgency", yes = "A deadline", no = "Routine")]
    is_urgent: NoulAnswer,
    #[choice("Which team should handle this")]
    department: ChoiceOf<Department>,
    #[score("How frustrated", levels = ["Calm", "Frustrated but civil", "Very angry"])]
    frustration: ScoreAnswer,
}

#[derive(Debug, RubricChoice)]
enum Department {
    #[option("Payment or subscription issues")]
    Billing,
    /// Bugs or integration problems
    Technical,
}

let t: Triage = client.ask(state).await?;              // or client.ask::<Triage>(state)
if t.department.confidence() > 0.5 {
    println!("route to {:?}", *t.department);
}
  • A variant's label is its name in snake_case (Billingbilling); its description is #[option("…")] or else its doc comment. #[rubric(rename = "…")] renames a field's question or a variant's label.
  • A choice field may be the enum itself, ChoiceOf<E> (the enum plus the distribution), ChoiceAnswer or String; the last two list their options as labels = ["a", "b"]. Noul and score fields may be f64 when the probability or the score is all you need.
  • Instructions left out of the attribute are read from the field's doc comment.
  • client.ask takes the same per-call options as system_one; the blocking client has it too. Triage::questions() and Triage::from_response(&res) do the two halves by hand.
  • An answer the struct cannot hold — missing, of another type, or a label the enum lacks — is Error::ResponseValidation, with field_path naming it (answers.department.choice).

The trait is typesafe::Rubric whether or not the feature is on; the feature only adds the derive, from the typesafe-derive proc-macro crate. cargo run --example derive --features derive runs it against the API.

Per-call options

Requests implement IntoFuture, so you can .await them directly or configure them first:

client.system_one(state, questions)
    .model("jev-latest")
    .timeout(Duration::from_secs(3))
    .retry(RetryPolicy::none())
    .header(HeaderName::from_static("x-tenant"), HeaderValue::from_static("acme"))
    .extra_body("some_new_field", json!(true))   // shallow-merged last
    .await?;

Authentication and SDK-identification headers cannot be overridden.

Models

for m in client.models().list().await?.models {
    println!("{} ({})", m.name, m.release_date);
}

Blocking

let client = typesafe::blocking::Client::from_env()?;
let res = client.system_one("text", questions).send()?;

The blocking client owns a private current-thread runtime; don't call it from inside async code.

Learn it interactively

jev is a terminal REPL for shaping questions before you write any code:

cargo install jev-repl && jev     # from crates.io
just repl                         # from this checkout (or: cargo run -p jev-repl)
:preset triage                                   # a ready-made session to poke at
:state The payout failed again, third time.      # bare text works too
:noul is_urgent The message conveys urgency | yes: A deadline | no: Routine
:choice department Which team | billing=Payments | technical=Bugs
:score frustration How frustrated | Calm | Annoyed | Furious
<Enter>                                          # send; answers come back with their distributions
  • :lesson walks an eleven-step track from "what is a noul" to what a call costs.
  • :sketch opens the whole request as one page of text (below).
  • :build opens a form for composing a question, with the JSON it will send rendered as you type.
  • :turn <who>: <text> grows the state into a conversation instead of replacing it, so the same fixed questions can be re-read after every reply. Nothing new goes on the wire: the state is an array of {who, said}, which is why a page can carry one and jev eval can score one.
  • :json shows the exact request body, :last the raw response, and :rust the same session as a program written against this SDK.
  • :cost estimates what a call spends before it is sent — tokens per question for the request and for the answer it asks for — and prices them at rates you give it: :cost 0.20/1.00 is dollars per million tokens, input then output, and JEV_PRICE=0.20/1.00 sets the same at startup. Without rates it counts tokens and stops there; a live answer is priced from the usage the API reports.
  • Without TYPESAFE_API_KEY it starts in mock mode: answers are simulated locally (deterministic, not predictive) so the shapes can be learned offline. :key <api-key> switches to live calls.
  • A session saved with :save runs from a script: with a subcommand jev opens no terminal at all, so jev run triage.jev sends the page and prints the answers, jev run --json hands the raw body to jq, and jev json, jev cost, jev rust and jev check print the body, the token table, the code and the parse. Exit status is 0 when it worked, 1 when the call or the file did not, 2 when the command line did not parse.

Sketch mode: the request as a page

Requests are rubrics, and rubrics are easier to write on paper than in a form. :sketch (or Ctrl-K) opens the session as one page of plain text; the type of each question is read off its punctuation, so there is nothing to select:

The payout failed again, third time this month. I'm done waiting.
---
is_urgent? The message conveys urgency or time-sensitivity
  yes: A deadline, a threat to leave, or "ASAP"
  no: Routine, no time pressure

department: Which team should handle this
  billing = Payment or subscription issues
  technical = Bugs or integration problems
  sales

frustration: How frustrated the customer appears
  Calm < Frustrated but civil < Very angry
  • Everything above the first --- line is the state (JSON if it parses as JSON).
  • name? asks yes/no (a noul); yes: / no: lines describe the outcomes.
  • name: followed by label = description lines (or bare labels) is a choice.
  • name: followed by levels joined with < is a score, lowest first.
  • name! {json} sends a hand-built question object; @model jev-2 pins the model; # comments.
  • Parts can share the first line: tone: Rate the reply | Warm < Neutral < Hostile.

While you type, a gutter says what each line became (noul, option, level, …) and marks the ones it could not place, the status line explains whatever the cursor is on, and the pane beside the page cycles (Ctrl-P) between the JSON that would be sent, simulated answers so the shape of the response is visible before anything is sent, the same request as Rust, and what the call would cost. Ctrl-S applies the page to the session, Ctrl-G applies and sends it, Alt-↑/↓ moves lines so questions and levels can be reordered, and Alt-←/→ cross a word (Alt-Backspace deletes one) wherever there is text to edit. Terminals spell Alt in several ways — a modified arrow, the Meta bit, an Esc prefix, or Alt-b/Alt-f — and all of them are read; Ctrl-←/→ works too. A page with problems is never applied; the cursor jumps to the first one instead.

The page is a file format too: :save triage.jev writes it, :open triage.jev reads it back, and :sketch show prints the current session in the notation.

jev is also an MCP server and a skill, for when the one writing the rubric is an agent: jev install registers both with Claude Code, the Codex CLI, OpenCode or pi, and jev mcp serves the same one-shot commands over JSON-RPC on stdin and stdout. jev-repl/README.md has the table of what goes where.

The REPL lives in jev-repl/ as a separate workspace member and is published as its own crate, jev-repl, so its TUI dependencies stay out of the library.

Configuration

Builder method Environment variable Default
api_key TYPESAFE_API_KEY required
base_url TYPESAFE_BASE_URL https://api.typesafe.ai
model TYPESAFE_DEFAULT_MODEL jev-latest
timeout 10 s per attempt
retry RetryPolicy::default()
http_client a fresh reqwest::Client (feature reqwest-client)
record TYPESAFE_RECORD off; a directory to record responses into
replay TYPESAFE_REPLAY off; a directory to replay responses from

Explicit values win; blank environment values are ignored. Header types come from the http crate, re-exported as typesafe::http.

Recording and replaying

Tests that call the API are slow, cost money and need a key. Record their answers once and replay them after that:

TYPESAFE_RECORD=tests/cassettes cargo test    # live: each successful response is kept
TYPESAFE_REPLAY=tests/cassettes cargo test    # offline: no network, no API key
# fn main() -> typesafe::Result<()> {
let client = typesafe::Client::builder().replay("tests/cassettes").build()?;
# Ok(()) }
  • A response is kept at <dir>/<key>.json, where the key is the SHA-256 of the exact request body: state, model, questions in order, and any extra_body fields. Change any of them and it is a different recording. typesafe::cassette::key computes it.
  • A request with no recording fails with Error::ReplayMiss { key, path }. A replaying client never falls back to the network, so a test cannot quietly start spending.
  • A recording that no longer decodes is ResponseValidation, like a bad live body. Replayed responses report meta.attempts == 0 and no headers; models().list() is not recorded, and a replaying client refuses it with Config.
  • Setting both is a Config error. Only System One calls that succeed and decode are recorded.
  • The files are what jev eval --cache keeps — the same key, and the body as compact JSON in server order — so an eval cache replays through the SDK and a recording serves as a cache.

Retries

RetryPolicy::default() matches the Python SDK:

  • 2 retries after the first attempt,
  • exponential backoff from 0.5 s to 5 s with 25 % subtractive jitter,
  • retries on 408, 429 and 500–599 (including TypeSafe's 529 Overloaded), connection errors and timeouts,
  • honours retry-after-ms and Retry-After (seconds or HTTP date),
  • a 30 s total budget per call: it stops before a wait that would exceed it,
  • retries send X-TypeSafe-Retry-Count.
use std::time::Duration;
use typesafe::RetryPolicy;
RetryPolicy::default()
    .max_retries(5)
    .backoff(Duration::from_millis(200), Duration::from_secs(2))
    .budget(Some(Duration::from_secs(10)))
    .retry_if(|e| e.status() == Some(409));

Errors

match client.system_one(state, questions).await {
    Err(typesafe::Error::Api(e)) if e.kind == ApiErrorKind::RateLimit => {
        eprintln!("rate limited, retry after {:?} (request {:?})", e.retry_after(), e.request_id());
    }
    Err(typesafe::Error::ResponseValidation(e)) => eprintln!("bad field {}", e.field_path),
    Err(e) => eprintln!("{e}"),
    Ok(res) => { /**/ }
}
Variant When
Config missing API key, invalid base URL, zero timeout, invalid retry policy, record and replay both set
InvalidRequest no questions, empty choice/score criteria, malformed raw question, unencodable state
Api non-2xx after retries; kind, message, body, request_id(), retry_after()
Connection no response (DNS, connect, reset, body read); HTTP client error in source()
Timeout an attempt exceeded its timeout
ResponseValidation 2xx body missing required data; field_path like answers.tone.confidence
ReplayMiss replaying, and this request was never recorded; key, path

Error messages from FastAPI-style validation bodies are flattened, e.g. questions.frustration.criteria: List should have at least 2 items.

Forward compatibility

  • Answer types this version does not know are skipped (logged via tracing at WARN) and remain in response.raw.
  • Unknown response fields are ignored.
  • Question::Raw(json!({...})) sends a hand-built question; extra_body adds top-level fields.

Logging

Uses tracing: INFO when a request is retried, DEBUG for each request/response line, TRACE for headers and bodies. Secret headers are redacted; bodies (including your state) are not.

Differences from the Python SDK

  • Answers are looked up with res.noul(name) / res.choice(name) / res.score(name) or iterated with nouls() / choices() / scores(); Score maps are keyed by u32.
  • Typed answer maps keep server order; response.raw uses serde_json::Map ordering.
  • ResponseMeta exposes status, headers and the number of attempts.
  • No TYPESAFE_LOG_LEVEL; configure your tracing subscriber instead.

Development

just          # fmt-check + clippy + tests, for the library and the REPL
just live     # smoke test against the real API (needs TYPESAFE_API_KEY)
just repl     # the learning REPL

Releasing

just publish-dry    # package and verify locally, no upload
just publish        # upload; needs a crates.io token (`cargo login`)

The REPL is released the same way with just publish-repl-dry / just publish-repl; it depends on a published library version, so publish the library first when both change. The derive macros (typesafe-derive) come before the library in the same way: just publish-derive.

Or let CI do it: push a tag matching the crate's versionv0.1.0 for the library, jev-v0.1.0 for the REPL, derive-v0.1.0 for the macros (git tag v0.1.0 && git push origin v0.1.0). That runs .github/workflows/release.yml, which re-runs fmt, clippy and the tests, checks the tag against that crate's manifest version, and publishes it with the CARGO_REGISTRY_TOKEN repository secret.

License

MIT