typesafe-rs 0.1.0

Rust client for TypeSafe's System One API
Documentation

typesafe-rs

Rust client for TypeSafe System One.

Crates.io Docs.rs CI MSRV License

Evaluate a state against named questions (noul, choice, score) and get one typed answer per question.

This is a community SDK. It is not an official TypeSafe product. Env vars, defaults, retries, identification headers, and error kinds match the official Python SDK and @typesafe-ai/sdk.

Install

[dependencies]
typesafe-rs = "0.1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
# optional: blocking client
typesafe-rs = { version = "0.1", features = ["blocking"] }
# tests: in-process mock of POST /v1/systemone and GET /v1/models
[dev-dependencies]
typesafe-rs-mock = "0.1"

MSRV is 1.85 (edition 2024). TLS is rustls by default (native-tls is available).

Quick start

export TYPESAFE_API_KEY=...
use typesafe_rs::{questions, Client, Question};

#[tokio::main]
async fn main() -> Result<(), typesafe_rs::Error> {
    let client = Client::from_env()?;

    let response = client
        .system_one(
            "Help! My payouts have been failing for 3 days.",
            questions! {
                "urgent" => Question::noul("Does this convey urgency?")
                    .when_true("Explicitly time-sensitive")
                    .when_false("No time pressure"),
                "team" => Question::choice("Which team should handle this?")
                    .option("billing", "Payments, invoicing, refunds")
                    .option("technical", "Bugs, outages, integrations")
                    .option("sales", "Pricing, upgrades, new accounts"),
                "frustration" => Question::score("How frustrated is the customer?")
                    .level("Calm")
                    .level("Frustrated")
                    .level("Very angry"),
            },
        )
        .await?;

    println!("urgent      = {:?}", response.noul("urgent"));
    println!("team        = {:?}", response.choice("team").map(|c| c.choice));
    println!("frustration = {:?}", response.score("frustration").map(|s| s.score));
    Ok(())
}

state can be a string, a JSON object, or an array. Keys you pick on the question map come back on answers.

List models:

let models = client.models().list().await?;

Call client.warm_up().await? once at process start if you want the first real request to reuse a pooled connection.

Configuration

Client::from_env() and Client::new resolve fields as code, then env, then default.

Setting Env 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, including the body
use std::time::Duration;
use typesafe_rs::{ClientConfig, RetryPolicy};

let client = ClientConfig::new()
    .api_key("sk-...")
    .default_model("jev-latest")
    .timeout(Duration::from_secs(15))
    .retry(RetryPolicy::conservative())
    .build()?;

Per-call overrides: CallOptions (timeout, retry, headers, model) with system_one_with. Authorization, Accept, User-Agent, X-TypeSafe-SDK, X-TypeSafe-Runtime, and X-TypeSafe-Retry-Count cannot be overridden.

Every request sends:

  • Authorization: Bearer <key>
  • Accept: application/json
  • User-Agent: typesafe-rs/<version>
  • X-TypeSafe-SDK: typesafe-rs/<version>
  • X-TypeSafe-Runtime: rust/<rustc>; <os>-<arch>
  • X-TypeSafe-Retry-Count: <n> on retries only (n starts at 1)

API keys are redacted in Debug. Error display never includes the key or the request body.

Retries

Default policy matches the official SDKs:

Property Value
Max retries 2 (3 attempts total)
Retry on HTTP 408, 429, all 5xx; connection errors; timeouts
Backoff 500 ms initial, 5 s cap, 25% jitter
Server delay retry-after-ms, then Retry-After (seconds or HTTP date), capped at 60 s

Presets:

  • RetryPolicy::default(): official behaviour
  • RetryPolicy::none(): no retries
  • RetryPolicy::conservative(): 408/429 and pre-send connection errors only (no 5xx, no timeouts)

Dropping the future cancels further attempts. Retrying 5xx on POST /v1/systemone can duplicate billable work; use conservative() if that matters more than completing the call.

Errors

match client.system_one(state, questions).await {
    Ok(response) => { let _ = response.noul("urgent"); }
    Err(err) => {
        eprintln!("{} (request id {:?})", err, err.request_id());
        if let Some(status) = err.status() {
            eprintln!("HTTP {status}");
        }
    }
}
Variant When
MissingApiKey No key in config or TYPESAFE_API_KEY
InvalidRequest Client-side validation (empty questions, Choice with fewer than 2 options, Score with fewer than 2 levels, …) before any network call
Connection DNS, TLS, or connect failure
Timeout Attempt exceeded the timeout
Api Non-2xx after retries are exhausted (401Authentication, 429RateLimit, 5xxInternalServer, …)
Decode Body is not JSON of the expected type
UnexpectedShape JSON parsed but is missing a documented field (for example GET /v1/models without models)

Unknown answer type values deserialize as Answer::Unknown instead of failing.

Testing

typesafe-rs-mock is an in-process HTTP server. Tests talk to a real Client over loopback, not a stub of the SDK.

use typesafe_rs::{questions, ClientConfig, Question};
use typesafe_rs_mock::{noul, MockServer};

let mock = MockServer::start().await;
mock.on_system_one().respond(serde_json::json!({
    "urgent": noul(0.97),
}));

let client = ClientConfig::new()
    .api_key("test")
    .base_url(mock.url())
    .build()?;

let response = client
    .system_one(
        "Help! My payouts have been failing for 3 days.",
        questions! { "urgent" => Question::noul("Does this convey urgency?") },
    )
    .await?;

assert_eq!(response.noul("urgent"), Some(0.97));

Script a 429 then a success, including retry-after-ms:

mock.on_system_one()
    .respond_status(429)
    .header("retry-after-ms", "120")
    .times(1);
mock.on_system_one().respond(answers).times(1);

mock.journal() records method, path, headers, JSON body, and timestamps. Use it to assert X-TypeSafe-Retry-Count and attempt order.

Run the bundled example (no live key):

cargo run -p typesafe-rs --example quickstart

Blocking client

typesafe-rs = { version = "0.1", features = ["blocking"] }
use typesafe_rs::{questions, BlockingClient, Question};

let client = BlockingClient::from_env()?;
let response = client.system_one(
    "Help! My payouts have been failing for 3 days.",
    questions! { "urgent" => Question::noul("Does this convey urgency?") },
)?;

Do not construct BlockingClient inside an existing Tokio runtime (block_on will panic).

Crates

Crate Role
typesafe-rs Async Client, optional BlockingClient, wire types, retry, errors. #![forbid(unsafe_code)]
typesafe-rs-mock In-process mock for tests

Backend is implemented for Client so callers can depend on the trait rather than the HTTP type.

Feature flags

Feature Default Notes
rustls yes TLS via rustls (platform verifier)
native-tls no Platform TLS instead
tracing yes typesafe.request span and retry_scheduled events
blocking no BlockingClient

Conformance

JSON fixtures in conformance/fixtures/ drive the real client against the real mock. Coverage includes 429 + retry-after-ms, non-retryable 400, env precedence, unknown answer types, missing models array, and retry-count headers.

cargo test -p typesafe-rs --test conformance

Development

cargo test --workspace --all-features
cargo fmt
cargo clippy --all-targets --all-features -- -D warnings
cargo run -p typesafe-rs --example triage

See CONTRIBUTING.md. Behaviour marked [parity] in SPEC.md must match the official Python and TypeScript SDKs.

License

Licensed under either of Apache License, Version 2.0 or MIT at your option.