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
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.
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?;
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 a ten-step track from "what is a noul" to confidence gating.: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.:jsonshows the exact request body,:lastthe raw response, and:rustthe same session as a program written against this SDK.- 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.
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, 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-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 |
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
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.
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, 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