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](https://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

```toml
[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

```rust,no_run
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.

```rust,ignore
#[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:

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

```rust,ignore
#[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:

```rust,ignore
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

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

### Blocking

```rust,ignore
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:

```sh
cargo install jev-repl && jev     # from crates.io
just repl                         # from this checkout (or: cargo run -p jev-repl)
```

```text
: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:

```text
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/`](jev-repl) as a separate workspace member and is published as its
own crate, [`jev-repl`](https://crates.io/crates/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`.

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

```rust,ignore
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

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

```sh
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 `version` — `v0.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`](.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