Skip to content

About

Rust client for the TypeSafe System One API (Jev)

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

12 Commits

Folders and files

Repository files navigation

kunobi-decision

Crates.io Docs.rs CI License MSRV

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.

Install

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"] }

Providers

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.

Migration from kunobi-jev

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.

Quick start

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.

Typed labels

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.

Testing your code

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);

Configuration

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.

Without an async runtime

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.

Credentials

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 Authorization header, marked sensitive, over https. Plain http:// is refused except for loopback hosts, including when it comes from TYPESAFE_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 Authorization when a redirect leaves the original scheme, host or port.
  • Never prints credentials: Debug shows ***, and logs show only the scheme and the last four characters.
  • Ignores Authorization set 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.

Retries and errors

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. ApiError carries the status, kind(), the parsed body, the request ID and retry_after().
  • Connection and Timeout: the request failed in transit. is_connection() is true for both.
  • InvalidRequest and Config: 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.

When the API grows

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.

Logging

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.

Development

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_body

License

Apache-2.0. This crate ports the TypeSafe JavaScript SDK (MIT); see NOTICE.

About

Rust client for the TypeSafe System One API (Jev)

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages