Rust client for typed decision models: TypeSafe Jev, Cloudflare Clef, Liquid d1, Vercel AI Gateway and OpenRouter. Send state and typed questions, and get answers your code can act on: a label, a score or a probability, each with the numbers behind it.
The client supports three question types:
| Builder | Asks | Answer |
|---|---|---|
noul |
a yes/no question | noul: probability of yes |
choice / choice_labels |
which label fits | choice, confidence, probabilities per label |
score |
where it falls on ordered levels | expected score, confidence, legend, probabilities |
A Choice takes up to 255 options and a Score between 2 and 10 levels; the client
refuses more before sending. ScoreAnswer::normalized() puts a score on 0 to 1 so
rubrics of different lengths can be combined, and ranked() orders a choice's
probabilities most likely first.
All questions in one call are answered against the same state in a single request, and that is the cheap way to use it: the state is charged once however many questions ride along. Measured against the live API, a 24k-token state with one question costs 24,288 input tokens and the same state with 200 questions costs 26,676, while latency stays flat. Asking one question per call re-sends the state every time.
This is an unofficial client, maintained by Kunobi. It is not affiliated with TypeSafe.
kunobi-decision = "0.3"Calls run on Tokio.
| Feature | Default | What it does |
|---|---|---|
rustls |
yes | TLS through rustls |
blocking |
no | blocking::Client, for callers with no async runtime |
native-tls |
no | TLS through the platform library (OpenSSL, Secure Transport, SChannel) |
testing |
no | testing::FakeSystemOne, a scripted client for tests |
To use the platform TLS library instead of rustls:
kunobi-decision = { version = "0.3", default-features = false, features = ["native-tls"] }Select the service with Client::builder().provider(...). Model IDs are separate:
| Provider | Default model | Key environment variable |
|---|---|---|
Provider::TypeSafe |
jev-latest |
TYPESAFE_API_KEY |
Provider::Cloudflare { account_id } |
clef |
CLOUDFLARE_API_TOKEN |
Provider::Liquid |
d1:free |
LIQUID_API_KEY |
Provider::Vercel |
typesafe-ai/jev |
AI_GATEWAY_API_KEY |
Provider::OpenRouter |
~typesafe/jev-latest |
OPENROUTER_API_KEY |
Provider::Compatible |
jev-latest |
KUNOBI_DECISION_API_KEY |
use kunobi_decision::{Client, Provider};
let liquid = Client::builder().provider(Provider::Liquid).build()?;
let gateway = Client::builder()
.provider(Provider::Vercel)
.default_model("liquid/d1")
.build()?;
# Ok::<(), kunobi_decision::Error>(())Cloudflare takes an account ID and calls the Workers AI account API. Use a token
with Workers AI Write permission. clef and clef-flash use the same typed
request and result; the client unwraps Cloudflare's response envelope.
Liquid calls /decisions/v1/systemone. Its docs do not specify a model catalog:
models().list() checks the configured model with a small inference request,
which consumes input tokens. Vercel uses its TypeSafe-compatible API and model
catalog under /typesafe/v1.
Each provider uses the same credential provider, retry policy, request budget,
and concurrency limit. The default HTTP client refuses redirects. Only TypeSafe
reads TYPESAFE_BASE_URL and TYPESAFE_DEFAULT_MODEL; another provider cannot
inherit a TypeSafe destination or model from the environment.
OpenRouter uses /api/v1/systemone for judgments. models().list() reads its
public catalog and returns every model advertising decisions output, with its
routable ID. Any model served through this API's state/questions/answers contract
can be selected through .default_model(...) or request.model(...); Jev is
the default. No vendor or model-name allowlist is used for OpenRouter. Use
client.check().await to verify the API key before listing models. The key
response is discarded; no inference is needed. OPENROUTER_BASE_URL and
OPENROUTER_DEFAULT_MODEL override its defaults; builder values take precedence.
Other services exposing the same System One contract can use
Provider::Compatible with an explicit base URL. Services with a different
request or response format require a provider adapter.
Provider contracts: Cloudflare, Liquid, Vercel, OpenRouter.
Change the dependency to kunobi-decision = "0.3" and imports to
kunobi_decision. Existing System One calls use TypeSafe unless you select
another provider. The compat/kunobi-jev crate re-exports these types and forwards
the existing Cargo features for callers that need to migrate later.
The repository is kunobi-ninja/kunobi-decision. Publish kunobi-decision
0.3.0 first using an API token, then configure its crates.io trusted publisher.
For both crates, use repository owner kunobi-ninja, repository name
kunobi-decision and workflow filename publish-crates.yaml. Update the existing
kunobi-jev publisher to the renamed repository before publishing its 0.3.0
compatibility crate. Earlier kunobi-jev releases remain available under their
original package name.
Set TYPESAFE_API_KEY, then:
use kunobi_decision::{Client, Entry, Questions, SystemOneRequest, choice, noul, score};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new()?;
let mut questions = Questions::new();
let department = questions.add(
"department",
choice("Which team should handle this?", [
("billing", "Payment or subscription issues"),
("technical", "Bugs or integration problems"),
("sales", "Pricing or account questions"),
]),
);
let frustration = questions.add(
"frustration",
score("How frustrated is the customer?", ["calm", "frustrated but civil", "very angry"]),
);
let urgent = questions.add("urgent", noul("Does the message convey urgency?"));
let ticket = Entry::try_from(json!({
"subject": "Stripe connection failing",
"body": "It keeps failing for 3 days and I'm losing sales. Please help ASAP."
}))?;
let result = client.system_one(SystemOneRequest::new(ticket, questions)).await?;
let department = result.answer(&department)?;
if department.confidence >= 0.5 {
println!("route to {}", department.choice);
} else {
println!("send to a human: {:?}", department.probabilities);
}
println!("frustration {:.2}", result.answer(&frustration)?.score);
println!("urgent {:.2}", result.answer(&urgent)?.noul);
Ok(())
}Questions::add returns an AnswerKey, so result.answer(&key) hands back the
answer type that matches the question. result.answers holds every answer by name
if you need to walk them.
State and descriptions are an Entry: text, a JSON object, a JSON array, or null.
Use Entry::from_serialize(&value) for your own structs. Object key order is kept.
Declare choice labels as an enum and match on it instead of on strings:
use kunobi_decision::{Client, Questions, SystemOneRequest, choice_of};
kunobi_decision::labels! {
pub enum Team {
Billing = "billing": "Payment or subscription issues",
Technical = "technical": "Bugs or integration problems",
Other = "other",
}
}
async fn route(client: &Client, ticket: &str) -> kunobi_decision::Result<Option<Team>> {
let mut questions = Questions::new();
let team = questions.add("team", choice_of::<Team>("Which team should handle this?"));
let result = client.system_one(SystemOneRequest::new(ticket, questions)).await?;
Ok(result.answer(&team)?.decide(0.6))
}decide(min_confidence) returns the label only when confidence reaches the threshold,
so unclear cases can go to a person. Scores and yes/no answers have decide and
is_yes helpers too. A label the enum doesn't know fails with Error::UnexpectedAnswer.
Take &dyn SystemOne (or impl SystemOne) instead of Client. With the testing
feature, FakeSystemOne answers from a script, checks each scripted answer against the
question, and records the requests it received:
[dev-dependencies]
kunobi-decision = { version = "0.3", features = ["testing"] }let jev = FakeSystemOne::new()
.noul("billing", 0.92)
.choice_of("team", Team::Billing, 0.81);Client::new() reads the environment. Client::builder() sets values in code,
which take precedence.
| Builder method | Environment | Default |
|---|---|---|
api_key or credential_provider |
TYPESAFE_API_KEY |
required |
base_url |
TYPESAFE_BASE_URL |
https://api.typesafe.ai; must be https |
default_model |
TYPESAFE_DEFAULT_MODEL |
jev-latest |
timeout |
5 s per attempt | |
total_timeout |
10 s for a whole call, retries included; None removes it |
|
max_concurrent_requests |
none; shared by clones of the client | |
retry |
RetryPolicy::default() |
|
default_header |
none | |
http_client |
a new reqwest::Client |
|
log_bodies |
off | |
allow_insecure_http |
off |
Per call, you can override the timeouts, the retry policy and headers:
let models = client
.models()
.list()
.timeout(Duration::from_secs(2))
.max_retries(0)
.with_response()
.await?;
println!("request {:?}", models.request_id);Nothing is sent until the call is awaited. Dropping the future cancels the request
and any pending retry. total_timeout bounds a whole call: attempts are shortened to
fit the time left, a retry is skipped when its backoff would end past the bound, and a
retry cut short by the bound returns the previous attempt's error. It defaults to 10 s, which is
two attempts and the backoff between them; pass None to remove it.
Both defaults are sized for how fast this model answers: measured against the live
API, a three-question request took 292 ms at the median and 728 ms at the worst of
twenty calls. A 429 whose Retry-After exceeds the budget is returned rather than
waited out, which is what an interactive caller wants. Batch work should raise the
budget, or remove it.
kunobi-decision = { version = "0.3", features = ["blocking"] }use kunobi_decision::blocking::Client;
let client = Client::new()?;
let models = client.models()?;The blocking client owns a runtime, so it refuses to run inside one: calling it from async code returns an error naming the async client instead of blocking that thread.
Servers and CLIs can use a TypeSafe API key from the environment or from your secret store:
let key: String = read_from_vault()?;
let client = Client::builder().api_key(key).build()?;Apps that run on user machines must not ship a TypeSafe API key. Anyone with the binary can extract it. Route calls through your own backend, which holds the key, and authenticate to it with a short-lived user token. A credential provider is asked for a token before every attempt, so refreshed tokens are picked up on retries:
let auth = Arc::new(kunobi_auth::client::AuthClient::new(config)?);
let client = Client::builder()
.base_url("https://jev.kunobi.example")
.credentials_fn(move || {
let auth = auth.clone();
async move { auth.token().await }
})
.build()?;Implement CredentialProvider directly when a closure isn't enough. The client does
not cache tokens; the provider should. A provider that fails or takes longer than
the per-attempt timeout fails the call with Error::Credentials, which is not
retried and never includes the token.
What the client does with credentials:
- Sends them only in the
Authorizationheader, marked sensitive, over https. Plainhttp://is refused except for loopback hosts, including when it comes fromTYPESAFE_BASE_URL.allow_insecure_http(true)lifts that for trusted private networks. - Holds an API key as a
SecretString, wiped on drop. The header value built for each request is a separate copy owned by the HTTP stack and is not wiped. - Drops
Authorizationwhen a redirect leaves the original scheme, host or port. - Never prints credentials:
Debugshows***, and logs show only the scheme and the last four characters. - Ignores
Authorizationset through default or per-call headers.
Environment variables are visible to child processes and to other processes of the same user, so prefer a secret store or a provider for anything beyond local development.
By default a call retries twice on 408, 429 and 5xx responses, connection errors
and timeouts, within the 10 s total_timeout. RetryPolicy::retry_if adds errors the
built-in rules decline; it only widens what is retried, so a predicate cannot switch
the defaults off. Backoff starts at 500 ms, doubles up to 5 s, and subtracts up to 25%
jitter. A retry-after-ms or Retry-After header of up to 60 s replaces the
backoff. Each retry sends X-TypeSafe-Retry-Count.
Errors are one kunobi_decision::Error enum:
Api: a non-2xx response.ApiErrorcarries the status,kind(), the parsed body, the request ID andretry_after().ConnectionandTimeout: the request failed in transit.is_connection()is true for both.InvalidRequestandConfig: rejected before sending, such as an empty question set, a score with fewer than two levels, a missing API key, or a plain-http URL.Credentials: the credential provider failed, timed out, or returned an unusable token.Decode: a 2xx body with an unexpected shape.UnexpectedAnswer:result.answer(&key)found no answer under that name, an answer of another type, or a label the key's enum doesn't know.
The crate does not have to be updated first. A question field it does not model goes
through noul(..).extra("weight", 2), a whole question type through
Question::raw(json!({"type": "rank", …})), and a request field through
SystemOneRequest::extra. An answer type it does not know arrives as
Answer::Unknown with its JSON intact, rather than being dropped.
Model names are free-form strings: GET /v1/models lists the aliases but versioned
IDs work whether or not they appear there, so the crate never validates them. Aliases
move when a release ships, so pin a versioned ID such as jev-1.13.0 if you have
tuned confidence thresholds, and read result.model to log which model answered.
Each call runs in a typesafe.request span with OpenTelemetry-style fields:
http.request.method, url.path, http.response.status_code,
http.request.resend_count, typesafe.request_id and error.type. Each attempt logs a
summary at info, such as
#3 POST /v1/systemone <- 200 in 212ms (request req_…), and redacted headers at
debug. Request and response bodies are not logged unless you call
log_bodies(true): they contain the state you send, which may include personal data.
mise install
cargo test
cargo clippy --all-targets -- -D warnings
mise run deny
TYPESAFE_API_KEY=... mise run e2e # live API tests
cargo run --example demo # needs TYPESAFE_API_KEY
cd fuzz && cargo +nightly fuzz run parse_error_bodyApache-2.0. This crate ports the TypeSafe JavaScript SDK (MIT); see NOTICE.