guideme (Rust)
guideme is a Rust library for TypeSafe Jev, the TypeSafe model that
gives judgments. It is for Rust programs that make a decision about text or data, for example
where to send a support ticket. You send a question and your state (the data to judge, for
example a ticket). guideme returns the answer as a normal Rust value. A yes/no question gives a
bool. A choice gives a variant of your own enum, and a score gives one of your own ordered
levels.
Install
guideme needs Rust 1.98 or newer. The crate includes #[derive(Choice)] and
#[derive(Levels)], so do not add guideme-derive yourself. Set the API key in the
TYPESAFE_API_KEY environment variable, or give it to Guide::builder().api_key(..).
Quick start
use ;
async
async
Each variant of Department is an option: one possible answer of the choice. The doc comment
on each variant is its rubric: the text that tells the model what the option means. The
variant name in snake_case is the key of the option on the wire (in the HTTP request).
#[guide(rubric = "…")] replaces the doc comment, and #[guide(key = "…")] replaces the key.
The compiler makes sure that the match handles every option.
When your program starts, build one guide, as main does here. Then share it in the whole
program. A Guide is cheap to clone, and the clones share one HTTP client.
Questions
A yes/no question is what TypeSafe calls a noul, and noul is its type on the wire. A choice
has one of your options as its answer. A score has one level of your ordered scale as its answer.
The instructions are the text of the question, the first argument of each constructor.
| Constructor | Asks | Plain answer | Detailed answer (.detail()) |
|---|---|---|---|
noul("…") |
a yes/no question | bool |
Verdict::{Yes, No, Unsure}(p) |
choose::<C>("…"), C derives Choice |
a choice over the variants of C |
C |
Ranked<C>: confidence and all probabilities |
score::<L>("…"), L derives Levels |
a score over the levels of L |
L, the most probable level |
Scored<L>: the expected value, the level, confidence and distribution |
choose_among("…", options) |
a choice over (key, rubric) pairs that you give at run time |
Key, the option key |
Ranked<Key> |
score_levels("…", levels) |
a score over level rubrics that you give at run time | Rank, the level position from 0 |
Scored<Rank> |
A Choice enum also needs Clone, PartialEq and Eq. A Levels enum also needs Clone,
PartialEq, Eq, PartialOrd and Ord. The levels of a score go from low to high in
declaration order, so you can compare them:
use ;
async
A yes/no question can carry .criteria("what yes means", "what no means"). The instructions are
a string or a serde_json::Value. With a Value, a question can name fields of a structured
state, as the TypeSafe docs describe.
The state can be a text literal, a String, a serde_json::Value, or a reference to any type
that implements Serialize. An owned struct is not accepted, so pass a reference. If the state
does not convert to JSON (a map with keys that are not strings, for example), ask returns
Error::Config.
guide.models() returns the models that your account can use.
When the model is not sure
The model does not answer with a plain yes or an option. For a yes/no question it gives p, the
probability of yes. For a choice or a score it gives a confidence. A threshold (yes_above,
no_below, min_confidence) is the limit that turns this number into an answer. Unsure means
that the number does not reach the thresholds. The policy is the set of thresholds.
| Layer | How to set it |
|---|---|
| question | .yes_above(p) and .no_below(p) on a yes/no question, .min_confidence(c) on a choice or a score, .with(Policy) on any question |
| guide | Guide::builder().policy(..), or guide.with_policy(..)? for a copy with this policy merged over the policy of the guide |
| defaults | yes_above 0.5, no_below 0.5, min_confidence 0.0 |
A threshold on the question wins over the guide, and the guide wins over the defaults.
- Yes/no question:
p >= yes_aboveis yes,p <= no_belowis no, and a value between the two is unsure. With the defaults, no answer is unsure. - Choice and score:
confidence < min_confidenceis unsure. With the default, no answer is unsure. - A threshold outside 0..1, or a
no_belowthat is more thanyes_above, isError::Config.
guideme resolves an unsure answer with the unsure ladder. The first step that applies wins:
- The value from
.or(value)on the question. - The fallback: the variant marked
#[guide(fallback)]. Only a#[derive(Choice)]enum can declare one. For a score or achoose_amongchoice, use.or(..). Error::Unsure, with the question id, the probability or confidence, and the threshold that it did not reach.
.detail() skips the ladder and gives you the detailed answer, so it never fails on an unsure
answer. It also removes an .or(..) that you set before it. The policy setters are const fn,
so a policy can be a constant:
use ;
const CAUTIOUS: Policy = new.yes_above.no_below;
async
Examples and counterexamples
A description alone can leave two similar options to chance. An example is an input that belongs to an option. A counterexample is an input that does not.
use Choice;
The rubric of Billing goes on the wire as one string:
Payments, invoicing, refunds
Examples: My card was charged twice; Where is my refund?
Not this option: The dashboard is down
exampleandcounterexamplecan repeat. They combine withrubric,keyandfallback.keyandfallbackare for a choice only.- A rubric with no examples and no counterexamples goes on the wire unchanged, byte for byte.
- A level can have examples, but not a counterexample. An example on a level says that such an input scores at that level.
- An input cannot be an example of two options or of two levels.
- An input cannot be an example and a counterexample of one option.
- An example or a counterexample cannot be empty, appear twice, contain a line break, or be on a variant with no rubric.
- An input can be an example of one option and a counterexample of another. This separates two options that are easy to confuse.
The derives refuse a broken rule at compile time. .criteria, choose_among and
score_levels apply the same rules at the time of ask, not in the constructor. A broken rule
then gives Error::Config. Each of the three takes a Rubric or a plain
description (anything that converts to String) as a rubric:
use ;
async
async
All the rubrics in one choose_among list have the same type. If every option in the list has
None as its rubric, the compiler cannot find that type, so write it once:
[("a", None::<&str>), ("b", None)].
Several questions in one request
A tuple of up to 8 questions is also a question. So is a Vec or a BTreeMap of questions,
and they can nest. The answer has the same shape. It comes from one request, in one
span. Each question keeps its own policy.
use BTreeMap;
use ;
async
The question ids are q0, q1 and so on, in the order that guideme reads the shape. Tuple and
Vec items keep their order, and BTreeMap entries go in key order. The ids appear on the wire, in
errors and in events. A batch is atomic. If guideme cannot resolve one answer, the whole call
fails. Put .or(..) or .detail() on each question that can be unsure.
The receipt
ask_with_receipt sends the same request and records the same span as ask. It returns a
Receipt: the answer, the versioned model that answered, and the token usage. If you asked for
an alias, the model is still a version such as jev-1.13.0. Input tokens are the billed ones.
async
Errors
Every function that can fail returns Result<_, guideme::Error>. error.kind() gives the
error kind, a short stable name that is also the error.type of a failed span. Error is
#[non_exhaustive], so a match on it needs a _ arm.
| Variant | Error kind | When |
|---|---|---|
Auth |
auth |
401: the API key is missing or not valid |
Invalid { detail } |
invalid |
422, with the response body |
RateLimited { retry_after } |
rate_limited |
429 after the last retry, or a retry-after of more than 30 s |
Overloaded { retry_after } |
overloaded |
529 after the last retry, or a retry-after of more than 30 s |
Transport(..) |
transport |
connection, TLS, timeout, or a failure to read the body |
UnexpectedStatus { status, body } |
unexpected_status |
a status that the contract does not define |
Protocol { detail } |
protocol |
the response breaks the contract: a body that does not decode, the wrong answer kind, an option or level not in the rubric, a probability outside 0..1, a missing answer |
Unsure { question, value, threshold } |
unsure |
the answer is unsure and nothing on the unsure ladder caught it |
Config { detail } |
config |
bad thresholds, no API key, an empty batch, a state that does not convert to JSON, a rubric that breaks a rule, duplicate option keys, a wrong number of options (1 to 255) or levels (2 to 10), a base URL with no host or with credentials, a setting that an injected client already carries |
Retries and timeouts
guideme retries 429, 529, and a request that did not reach a server. A refused or reset
connection and a failed TLS handshake are failures of this kind. GET /v1/models gets the same
retries as POST /v1/systemone, so a 429 on a models() call at startup does not stop your
program. guideme does not retry a timeout of any phase, or a failure to read the body.
docs/contract.md gives the reason.
The wait before a retry is backoff × 2^attempt, with a maximum of 30 s, plus up to 250 ms of
jitter. If the API sends a retry-after in whole seconds, guideme waits that long instead. If
the retry-after is more than 30 s, the call fails at once, and the error carries that
duration. timeout is one deadline for each attempt, so the longest call takes about
(max_retries + 1) × timeout, plus the waits.
If you give guideme your own reqwest::Client, that client classifies its errors. guideme still
retries what reqwest reports as a failure to connect. So a connect_timeout on your client
makes a connect timeout a retry. guideme itself never sets a connect_timeout.
Testing your code
Point the guide at a mock server. Then your control flow runs without a network or an API key.
use ;
use ;
use ;
const URGENT: &str = r#"{
"model": "jev-1.13.0",
"answers": { "q0": { "type": "noul", "noul": 0.95 } },
"usage": { "input_tokens": 307, "output_tokens": 20 }
}"#;
async
Add wiremock = "0.6" to your [dev-dependencies]. guideme does not add it for you. In the
mock body, use the question ids from Several questions in one
request. Use server.received_requests() to make sure that
guideme sent the correct request.
To replace the transport, give guideme your own reqwest::Client. Do this for a proxy, a
client certificate, or a connection pool that the rest of your program shares:
use Duration;
use ;
use Guide;
Set the timeout on the reqwest client. Client::builder(..).timeout(..) beside .http(..) is
Error::Config. The Client carries the API key, the base URL, the retries, the backoff and the
timeout. If you also set one of these on Guide::builder(), build returns Error::Config with
its name. For this reason, Guide::builder().from_env() does not combine with client(..).
Observability
guideme emits tracing spans and events. It installs no subscriber, so you see nothing until
you add one. Add tracing-subscriber, then install the smallest subscriber:
fmt.with_env_filter.init;
Each ask is one guideme.ask span, and each HTTP attempt under it is one more span. A
models() call has only the HTTP span. Each question gives one guideme.answer event.
guideme records the state only with record_state(true), and never records the API key.
docs/observability.md has every field, and console and OTLP setups.
Configuration
Each setting is a method on Guide::builder(). The second column shows what from_env()
reads.
| Setting | Environment variable | Default | Meaning |
|---|---|---|---|
api_key(key) |
TYPESAFE_API_KEY |
none | required, unless from_env or client gives the key |
base_url(url) |
TYPESAFE_BASE_URL |
https://api.typesafe.ai |
the API origin |
model(Model) |
GUIDEME_MODEL |
jev-latest |
the model or alias |
policy(Policy) |
the defaults above | the policy for every question of this guide | |
max_retries(n) |
3 | retries for 429, 529 and a failure to connect |
|
backoff(d) |
500 ms | the base of the exponential wait | |
timeout(d) |
30 s | the deadline for each attempt | |
record_state(on) |
false |
record the state on the span | |
client(Client) |
none | a guideme::api::Client with your own transport |
Guide::from_env()? reads the variables and builds the guide. It needs TYPESAFE_API_KEY.
Guide::builder().from_env()? reads them onto a builder that you continue to configure:
Guide::builder().from_env()?.policy(CAUTIOUS).build()?. build makes sure that the policy is
valid, so a bad policy fails at startup.
Lower layers
guideme::apiis the exact wire mirror ofPOST /v1/systemoneandGET /v1/models, plusClientfor callers who build requests themselves.guideme::api::reqwestis thereqwestthat guideme links. Use it, and yourClienttype always matches. A majorreqwestupdate is a breaking change for guideme.guideme::policy::resolve(&Answer, Thresholds) -> Outcomeis the pure decision function.docs/design.mdrecords the design decisions, the measurements behind them, and the sharp edges.
Other SDKs
Each guideme SDK is written from scratch in its own language. Each one passes the same 42
golden policy vectors under spec/ and renders rubrics the same way, as
docs/contract.md states. So the same probability or confidence and the
same thresholds give the same answer in each SDK.
| Language | Package | Repository |
|---|---|---|
| Rust | guideme |
this repository |
| Python | guideme |
guideme-python |
| TypeScript | @guideme/sdk (not yet on npm) |
guideme-typescript |
Development
Tools come from mise. Run mise install, then mise run hooks once.
mise run check runs the full gate. CONTRIBUTING.md is the short guide.
AGENTS.md has the full contributor rules, the commands and the live tests.
Report a vulnerability privately, as SECURITY.md describes.
License
MIT or Apache-2.0, as you choose.