The Jev API over HTTPS for a native process: jev-client's policy on
this crate's transport (transport.rs: HTTP/2 over pure-Rust TLS on
tokio) and runtime, and the shared spend ledger in front of both.
WHAT to ask is jev-protocol's; how to retry is jev-client's. This is
only how the bytes get there, and what they cost. The browser build cannot
reach the API directly anyway (api.typesafe.ai refuses browser
origins), so a web page gets a relay and never links this.
Every request is checked against [ledger::Ledger] before it is sent.
This account's credit is finite, so a client that could be built and used
without a shared, on-disk record of what has already been spent would make
overspending a matter of which binary you happened to run - see ledger's
own doc comment for why the record has to be a file and not a number kept
in memory. Jev::new/Jev::from_env are the only ways to get a client,
and both build the ledger check into it, so there is no path to ask that
skips it. There is no self-imposed lifetime cap here (removed 2026-09-22,
the user's own ruling) - the only hard stop is the vendor's own
out-of-credit answer, which is a fact about the account, not a guess.
25use std::sync::{Arc, Mutex, PoisonError}; 26use std::time::{Duration, Instant}; 27 28use jev_client::{Client, ClientError}; 29use jev_protocol::{ApiErrorKind, Json, ModelId, Questions, Response, worst_case_dollars}; 30 31pub use ledger::{Entry, Guards, Hold, Ledger, NoCredit, Refusal, Status, Window}; 32pub use runtime::TokioRuntime; 33pub use transport::{ENDPOINT, Endpoint, HttpsTransport};
The environment variable the key arrives in. On this machine it reaches a
process through op-env-run and nowhere else - never a dotfile, never a
command line: op-env-run -- <command> (jev-http's README).
38pub const KEY: &str = "TYPESAFE_API_KEY";
What a response's headers said about how much of the vendor's own rate
limit is left, when they said anything at all. Digest §10: as of
2026-09-20 the vendor documents no such headers on a successful response -
only Retry-After/retry-after-ms on a 429/529 - so every field here
is commonly None. It stays a typed, if usually-empty, snapshot rather
than nothing at all so that the day the vendor adds one, this starts
reporting it with no code changed at every call site.
Seconds until the window resets, exactly as the header gave it - not resolved to a clock time, since that would claim a precision about when the header was actually read that nothing here can back up.
A client, and one to reuse: it holds the connection, and a warm connection is worth hundreds of milliseconds per decision.
Which binary this is, for the ledger's records - native, player,
jevprobe, ...
The most recent parsed [RateLimit], kept for [Jev::last_rate_limit]
so a caller (the budget MCP tool, in particular) does not have to
wait for the next request to see what the last one learned.
What one answered request cost in time as well as tokens.
The answers, verified against the questions asked.
78 pub response: Response,
The whole round trip, retries and waits included.
80 pub took: Duration,
How many times the request had to be sent. 1 is the happy path.
82 pub attempts: u32,
The response exactly as it arrived. Kept so that a field the protocol does not know about is visible rather than silently dropped by the parse - which is how the digest gets corrected.
86 pub body: String,
Every response header, name and value, with anything that looks like
a credential redacted. apps/jevprobe --headers exists to print this.
89 pub headers: Vec<(String, String)>,
x-typesafe-request-id, if the response carried one (digest §1).
Why an ask produced no answer. The first two never touch the network.
A shared guard that clears itself refused: the same request would be
admitted after retry_in. The caller goes on without an answer.
100 Throttled { why: String, retry_in: Duration },
Nothing is admitted until a human acts: the vendor said the account is out of credit, or the ledger cannot be read or written (a guard that cannot see the spend fails closed).
104 Budget(String),
The request went out and failed, as jev-client judged it after its
retries.
110impl std::fmt::Display for Failure { 111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 112 match self { 113 Self::Throttled { why, retry_in } => { 114 write!(f, "not sent, throttled: {why}; clears in {:.0}s", retry_in.as_secs_f64()) 115 } 116 Self::Budget(why) => write!(f, "not sent: {why}"), 117 Self::Client(e) => e.fmt(f), 118 } 119 } 120} 121 122impl std::error::Error for Failure {} 123 124impl Jev {
Read the key from the environment and build a client for the API, its
ledger opened from $XDG_STATE_HOME (see ledger::Ledger::open).
None when the key is not there, which is not an error: the app runs
without it and says on screen that it cannot play.
129 pub fn from_env(binary: impl Into<String>, model: ModelId) -> Option<Result<Self, String>> { 130 let key = std::env::var(KEY).ok().filter(|key| !key.trim().is_empty())?; 131 Some( 132 Ledger::open() 133 .map_err(|e| format!("opening the spend ledger: {e}")) 134 .and_then(|ledger| Self::new(key.trim(), binary, ledger, model, Endpoint::api())), 135 ) 136 }
Build a client against an already-open [Ledger]. Every real caller
wants [Self::from_env]; this is the constructor that makes the
dependency explicit rather than reached for internally, so a test can
hand it a ledger rooted in a private directory instead of the real
$XDG_STATE_HOME/jev - and so there is no Jev value anywhere that
was not required, at construction, to say where its spend is kept.
144 pub fn new( 145 key: &str, 146 binary: impl Into<String>, 147 ledger: Ledger, 148 model: ModelId, 149 endpoint: Endpoint, 150 ) -> Result<Self, String> { 151 // The trust anchors are compiled in rather than read from the host, so 152 // what this trusts is the same on every machine and in a sandbox with 153 // no /etc/ssl at all. The provider is RustCrypto: pure Rust, no ring, 154 // no aws-lc, nothing to cross-compile for the web build later. 155 let mut roots = rustls::RootCertStore::empty(); 156 roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); 157 if let Some(path) = endpoint.ca_file() { 158 use rustls::pki_types::CertificateDer; 159 use rustls::pki_types::pem::PemObject; 160 let shown = path.display(); 161 for cert in CertificateDer::pem_file_iter(path).map_err(|e| format!("CA file {shown}: {e}"))? { 162 let cert = cert.map_err(|e| format!("CA file {shown}: {e}"))?; 163 roots.add(cert).map_err(|e| format!("CA file {shown}: {e}"))?; 164 } 165 } 166 let mut tls = rustls::ClientConfig::builder_with_provider(Arc::new(rustls_rustcrypto::provider())) 167 .with_safe_default_protocol_versions() 168 .map_err(|e| format!("rustls protocol versions: {e}"))? 169 .with_root_certificates(roots) 170 .with_no_client_auth(); 171 tls.alpn_protocols = vec![b"h2".to_vec()]; 172 let transport = HttpsTransport::new(endpoint, Arc::new(tls)); 173 let client = Client::new(transport, TokioRuntime, model, key).map_err(|e| e.to_string())?; 174 Ok(Self { 175 client, 176 binary: binary.into(), 177 ledger, 178 guards: Guards::from_env(), 179 last_rate_limit: Mutex::new(None), 180 }) 181 }
The same client under other guards than the environment's.
What the shared guards say right now - every process's questions, not only this one's.
The most recent [RateLimit] this client has seen, if any response
ever carried one.
Ask, under jev-client's retry policy - but first, have the shared
ledger admit it ([Ledger::admit]: question bucket, hour breaker, out
of credit - there is no lifetime cap, per the user's own ruling,
2026-09-22), before a single byte goes over the network. A refusal
that clears itself is [Failure::Throttled], one that does not (out
of credit) is [Failure::Budget]; neither is retried here.
why is recorded against the spend in the ledger's reason. It is a
required argument, not an option, so that every line of the ledger can
say why the money went: the breaker trip of 2026-09-20 had to be
reconstructed from token counts because no line said.
225 pub async fn ask(&self, state: &Json, questions: &Questions, why: &str) -> Result<Answered, Failure> { 226 let worst_case = worst_case_dollars(self.client.model(), state, questions); 227 let hold = self.ledger.admit(ledger::now(), &self.guards, worst_case, &self.binary, why).map_err( 228 |refusal| match refusal { 229 Refusal::Throttled { why, retry_in } => Failure::Throttled { 230 why, 231 retry_in: Duration::try_from_secs_f64(retry_in).unwrap_or(Duration::MAX), 232 }, 233 other => Failure::Budget(other.to_string()), 234 }, 235 )?; 236 237 let started = Instant::now(); 238 match self.client.ask(state, questions).await { 239 Ok(answered) => { 240 let rate_limit = parse_rate_limit(&answered.headers); 241 if let Some(rate_limit) = rate_limit { 242 *self.last_rate_limit.lock().unwrap_or_else(PoisonError::into_inner) = Some(rate_limit); 243 if let Err(e) = self.ledger.note_rate_limit(&rate_limit) { 244 eprintln!("jev ledger: could not record a rate-limit snapshot: {e}"); 245 } 246 } 247 let usage = answered.response.usage(); 248 let cost = usage.dollars(); 249 let entry = Entry { 250 time: ledger::now(), 251 binary: self.binary.clone(), 252 input_tokens: usage.input_tokens, 253 output_tokens: usage.output_tokens, 254 cost_usd: cost, 255 request_id: answered.request_id.clone(), 256 estimated: false, 257 reason: Some(why.to_owned()), 258 hold: None, 259 }; 260 if let Err(e) = self.ledger.settle(&hold, entry) { 261 // The request already happened and was paid for 262 // whether or not this write succeeds; refusing to 263 // return the answer would not undo that, it would 264 // just also lose the decision. The hold stays 265 // unsettled, so the ledger goes on counting its 266 // worst case - more than it cost. Loudly logged, 267 // since a ledger that cannot be written to is a 268 // problem worth a human's attention on its own. 269 eprintln!("jev ledger: could not record a ${cost:.6} spend: {e}"); 270 } 271 Ok(Answered { 272 took: started.elapsed(), 273 attempts: answered.attempts, 274 body: String::from_utf8_lossy(&answered.body).into_owned(), 275 headers: collect_headers(&answered.headers), 276 request_id: answered.request_id, 277 rate_limit, 278 response: answered.response, 279 }) 280 } 281 Err(error) => { 282 if let ClientError::Api { error: api, .. } = &error 283 && api.kind == ApiErrorKind::OutOfCredit 284 && let Err(e) = self.ledger.mark_out_of_credit("the vendor reports no credit remaining") 285 { 286 eprintln!("jev ledger: could not record the vendor's out-of-credit answer: {e}"); 287 } 288 // `release` books the hold at nothing, which is true only 289 // when nothing can have been billed: the request was never 290 // built, or its one attempt was refused outright. After an 291 // interrupted or timed-out attempt the cost is unknown (the 292 // vendor reports usage only on success), so the hold stays 293 // and goes on counting its worst case, as a crashed 294 // process's does. 295 if error.nothing_billed() 296 && let Err(e) = self.ledger.release(&hold, ledger::now(), &error.to_string()) 297 { 298 eprintln!("jev ledger: could not release an unanswered hold: {e}"); 299 } 300 Err(Failure::Client(error)) 301 } 302 } 303 } 304}
Any of the conventional rate-limit header spellings, read opportunistically.
Digest §10: none of these are documented anywhere in the vendor's own
reference as of 2026-09-20, so this is expected to return None on every
real response; it stays general rather than named after one convention so
that if the vendor starts sending any of them, this starts reporting it
with no code changed anywhere that calls ask.
312fn parse_rate_limit(headers: &http::HeaderMap) -> Option<RateLimit> { 313 let text = |name: &str| headers.get(name)?.to_str().ok().map(str::to_owned); 314 let as_u64 = |name: &str| text(name)?.trim().parse::<u64>().ok(); 315 let as_f64 = |name: &str| text(name)?.trim().parse::<f64>().ok(); 316 let limit = as_u64("x-ratelimit-limit-requests") 317 .or_else(|| as_u64("x-ratelimit-limit")) 318 .or_else(|| as_u64("ratelimit-limit")); 319 let remaining = as_u64("x-ratelimit-remaining-requests") 320 .or_else(|| as_u64("x-ratelimit-remaining")) 321 .or_else(|| as_u64("ratelimit-remaining")); 322 let reset_seconds = as_f64("x-ratelimit-reset-requests") 323 .or_else(|| as_f64("x-ratelimit-reset")) 324 .or_else(|| as_f64("ratelimit-reset")); 325 (limit.is_some() || remaining.is_some() || reset_seconds.is_some()) 326 .then_some(RateLimit { limit, remaining, reset_seconds }) 327}
Every header name and value, redacting anything that could be a
credential by name - a bearer token echoed back, a cookie, anything with
"key"/"token"/"secret" in it. apps/jevprobe --headers is the one caller
that prints this; Answered always carries it so that mode needs no
separate request.
334fn collect_headers(headers: &http::HeaderMap) -> Vec<(String, String)> { 335 let sensitive = |name: &str| { 336 let lower = name.to_ascii_lowercase(); 337 ["authorization", "cookie", "token", "secret", "-key", "api-key"] 338 .iter() 339 .any(|word| lower.contains(word)) 340 }; 341 headers 342 .iter() 343 .map(|(name, value)| { 344 let name = name.as_str().to_owned(); 345 let value = if sensitive(&name) { 346 "«redacted»".to_owned() 347 } else { 348 value.to_str().unwrap_or("«non-utf8»").to_owned() 349 }; 350 (name, value) 351 }) 352 .collect() 353}
355#[cfg(test)] 356mod tests { 357 use super::*; 358 359 fn model() -> ModelId { 360 ModelId::pinned("jev-1.13.0").expect("a pinned model") 361 } 362 363 fn sandbox_ledger() -> Ledger { 364 let dir = std::env::temp_dir() 365 .join(format!("jev-http-test-{}", std::process::id())) 366 .join(ledger::now().to_string()); 367 Ledger::at(dir).expect("opening a sandbox ledger") 368 } 369 370 #[test] 371 fn a_missing_key_is_not_an_error() { 372 // Whatever this machine's environment happens to hold, an empty one 373 // means "no client", not "a broken client". 374 assert!( 375 Jev::new("", "test", sandbox_ledger(), model(), Endpoint::api()).is_ok(), 376 "an empty key still builds; the API refuses it" 377 ); 378 } 379 380 #[test] 381 fn rate_limit_headers_are_read_when_present_and_absent_otherwise() { 382 let empty = http::HeaderMap::new(); 383 assert_eq!(parse_rate_limit(&empty), None, "no header, no snapshot"); 384 385 let mut headers = http::HeaderMap::new(); 386 headers.insert("x-ratelimit-remaining-requests", "37".parse().expect("a header value")); 387 let rate_limit = parse_rate_limit(&headers).expect("a snapshot"); 388 assert_eq!(rate_limit.remaining, Some(37)); 389 assert_eq!(rate_limit.limit, None, "only what was actually sent is filled in"); 390 } 391 392 #[test] 393 fn a_bearer_token_echoed_back_would_be_redacted() { 394 let mut headers = http::HeaderMap::new(); 395 headers.insert("x-typesafe-request-id", "req_abc123".parse().expect("a header value")); 396 headers.insert("set-cookie", "session=deadbeef".parse().expect("a header value")); 397 let collected = collect_headers(&headers); 398 let value_of = |name: &str| { 399 collected.iter().find(|(n, _)| n == name).map(|(_, v)| v.clone()).unwrap() 400 }; 401 assert_eq!(value_of("x-typesafe-request-id"), "req_abc123"); 402 assert_eq!(value_of("set-cookie"), "«redacted»"); 403 } 404}