typesafe-ai-sdk 0.4.0

Rust client for the TypeSafe AI System One API (Noul, Choice and Score questions)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# 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.7.1): 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.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

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

## Examples

[`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`](examples/errors.rs), which needs neither a key nor a network:

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

See [`examples/README.md`](examples/README.md) for the whole list.

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

`#[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.

```rust,ignore
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 (`Billing``billing`); 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`](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:

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

```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, 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`](jev-repl/README.md#in-a-coding-agent) has the table of what goes where.

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

```sh
TYPESAFE_RECORD=tests/cassettes cargo test    # live: each successful response is kept
TYPESAFE_REPLAY=tests/cassettes cargo test    # offline: no network, no API key
```

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

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

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