typesafe-ai-sdk 0.2.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.6.0): 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.2"                                                 # async (bring your own Tokio runtime)
# typesafe-ai-sdk = { version = "0.2", features = ["blocking"] }        # sync client
# typesafe-ai-sdk = { version = "0.2", features = ["reqwest-client"] }  # bring your own reqwest::Client

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.

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()?;

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 a ten-step track from "what is a noul" to confidence gating.
  • :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.
  • :json shows the exact request body, :last the raw response, and :rust the same session as a program written against this SDK.
  • 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.

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, and the same request as Rust. Ctrl-S applies the page to the session, Ctrl-G applies and sends it, Alt-↑/↓ moves lines so questions and levels can be reordered. 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.

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)

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

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
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

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.

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 (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