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
[]
= "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
# typesafe-ai-sdk = { version = "0.2", 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 ;
async
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:
See examples/README.md for the whole list.
State
state is anything Serialize: a string, json!({...}), or your own struct.
client.system_one.await?;
Structured instructions and rubrics
Instructions, option descriptions and score levels accept any JSON value:
use ;
new;
new.when_true;
from_labels;
Typed choices
let dept: Dept = res.choice.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 ;
let t: Triage = client.ask.await?; // or client.ask::<Triage>(state)
if t.department.confidence > 0.5
- 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),ChoiceAnswerorString; the last two list their options aslabels = ["a", "b"]. Noul and score fields may bef64when the probability or the score is all you need. - Instructions left out of the attribute are read from the field's doc comment.
client.asktakes the same per-call options assystem_one; the blocking client has it too.Triage::questions()andTriage::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, withfield_pathnaming 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
.model
.timeout
.retry
.header
.extra_body // shallow-merged last
.await?;
Authentication and SDK-identification headers cannot be overridden.
Models
for m in client.models.list.await?.models
Blocking
let client = from_env?;
let res = client.system_one.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:
&&
: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
:lessonwalks an eleven-step track from "what is a noul" to what a call costs.:sketchopens the whole request as one page of text (below).:buildopens 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: thestateis an array of{who, said}, which is why a page can carry one andjev evalcan score one.:jsonshows the exact request body,:lastthe raw response, and:rustthe same session as a program written against this SDK.:costestimates 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.00is dollars per million tokens, input then output, andJEV_PRICE=0.20/1.00sets the same at startup. Without rates it counts tokens and stops there; a live answer is priced from theusagethe API reports.- Without
TYPESAFE_API_KEYit 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
:saveruns from a script: with a subcommandjevopens no terminal at all, sojev run triage.jevsends the page and prints the answers,jev run --jsonhands the raw body tojq, andjev json,jev cost,jev rustandjev checkprint 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 bylabel = descriptionlines (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-2pins 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 TYPESAFE_REPLAY=tests/cassettes
#
- 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 anyextra_bodyfields. Change any of them and it is a different recording.typesafe::cassette::keycomputes 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 reportmeta.attempts == 0and no headers;models().list()is not recorded, and a replaying client refuses it withConfig. - Setting both is a
Configerror. Only System One calls that succeed and decode are recorded. - The files are what
jev eval --cachekeeps — 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-msandRetry-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 Duration;
use RetryPolicy;
default
.max_retries
.backoff
.budget
.retry_if;
Errors
match client.system_one.await
| 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
tracingat WARN) and remain inresponse.raw. - Unknown response fields are ignored.
Question::Raw(json!({...}))sends a hand-built question;extra_bodyadds 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 withnouls()/choices()/scores(); Score maps are keyed byu32. - Typed answer maps keep server order;
response.rawusesserde_json::Mapordering. ResponseMetaexposes status, headers and the number of attempts.- No
TYPESAFE_LOG_LEVEL; configure yourtracingsubscriber instead.
Development
Releasing
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, 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