1//! The Jev API over HTTPS for a native process: `jev-client`'s policy on 2//! this crate's transport (`transport.rs`: HTTP/2 over pure-Rust TLS on 3//! tokio) and runtime, and the shared spend ledger in front of both. 4//! 5//! WHAT to ask is `jev-protocol`'s; how to retry is `jev-client`'s. This is 6//! only how the bytes get there, and what they cost. The browser build cannot 7//! reach the API directly anyway (`api.typesafe.ai` refuses browser 8//! origins), so a web page gets a relay and never links this. 9//! 10//! **Every request is checked against [`ledger::Ledger`] before it is sent.** 11//! This account's credit is finite, so a client that could be built and used 12//! without a shared, on-disk record of what has already been spent would make 13//! overspending a matter of which binary you happened to run - see `ledger`'s 14//! own doc comment for why the record has to be a file and not a number kept 15//! in memory. `Jev::new`/`Jev::from_env` are the only ways to get a client, 16//! and both build the ledger check into it, so there is no path to `ask` that 17//! skips it. There is no self-imposed lifetime cap here (removed 2026-09-22, 18//! the user's own ruling) - the only hard stop is the vendor's own 19//! out-of-credit answer, which is a fact about the account, not a guess. 20 21pub mod ledger; 22mod runtime; 23mod transport; 24 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}; 34 35/// The environment variable the key arrives in. On this machine it reaches a 36/// process through `op-env-run` and nowhere else - never a dotfile, never a 37/// command line: `op-env-run -- <command>` (jev-http's README). 38pub const KEY: &str = "TYPESAFE_API_KEY"; 39 40/// What a response's headers said about how much of the vendor's own rate 41/// limit is left, when they said anything at all. Digest §10: as of 42/// 2026-09-20 the vendor documents no such headers on a successful response - 43/// only `Retry-After`/`retry-after-ms` on a `429`/`529` - so every field here 44/// is commonly `None`. It stays a typed, if usually-empty, snapshot rather 45/// than nothing at all so that the day the vendor adds one, this starts 46/// reporting it with no code changed at every call site. 47#[derive( 48 Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema, 49)] 50pub struct RateLimit { 51 pub limit: Option<u64>, 52 pub remaining: Option<u64>, 53 /// Seconds until the window resets, exactly as the header gave it - not 54 /// resolved to a clock time, since that would claim a precision about 55 /// when the header was actually read that nothing here can back up. 56 pub reset_seconds: Option<f64>, 57} 58 59/// A client, and one to reuse: it holds the connection, and a warm 60/// connection is worth hundreds of milliseconds per decision. 61pub struct Jev { 62 client: Client<HttpsTransport, TokioRuntime>, 63 /// Which binary this is, for the ledger's records - `native`, `player`, 64 /// `jevprobe`, ... 65 binary: String, 66 ledger: Ledger, 67 guards: Guards, 68 /// The most recent parsed [`RateLimit`], kept for [`Jev::last_rate_limit`] 69 /// so a caller (the `budget` MCP tool, in particular) does not have to 70 /// wait for the next request to see what the last one learned. 71 last_rate_limit: Mutex<Option<RateLimit>>, 72} 73 74/// What one answered request cost in time as well as tokens. 75#[derive(Debug)] 76pub struct Answered { 77 /// The answers, verified against the questions asked. 78 pub response: Response, 79 /// The whole round trip, retries and waits included. 80 pub took: Duration, 81 /// How many times the request had to be sent. 1 is the happy path. 82 pub attempts: u32, 83 /// The response exactly as it arrived. Kept so that a field the protocol 84 /// does not know about is visible rather than silently dropped by the 85 /// parse - which is how the digest gets corrected. 86 pub body: String, 87 /// Every response header, name and value, with anything that looks like 88 /// a credential redacted. `apps/jevprobe --headers` exists to print this. 89 pub headers: Vec<(String, String)>, 90 /// `x-typesafe-request-id`, if the response carried one (digest §1). 91 pub request_id: Option<String>, 92 pub rate_limit: Option<RateLimit>, 93} 94 95/// Why an ask produced no answer. The first two never touch the network. 96#[derive(Debug)] 97pub enum Failure { 98 /// A shared guard that clears itself refused: the same request would be 99 /// admitted after `retry_in`. The caller goes on without an answer. 100 Throttled { why: String, retry_in: Duration }, 101 /// Nothing is admitted until a human acts: the vendor said the account is 102 /// out of credit, or the ledger cannot be read or written (a guard that 103 /// cannot see the spend fails closed). 104 Budget(String), 105 /// The request went out and failed, as `jev-client` judged it after its 106 /// retries. 107 Client(ClientError), 108} 109 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 { 125 /// Read the key from the environment and build a client for the API, its 126 /// ledger opened from `$XDG_STATE_HOME` (see `ledger::Ledger::open`). 127 /// `None` when the key is not there, which is not an error: the app runs 128 /// 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 } 137 138 /// Build a client against an already-open [`Ledger`]. Every real caller 139 /// wants [`Self::from_env`]; this is the constructor that makes the 140 /// dependency explicit rather than reached for internally, so a test can 141 /// hand it a ledger rooted in a private directory instead of the real 142 /// `$XDG_STATE_HOME/jev` - and so there is no `Jev` value anywhere that 143 /// 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 } 182 183 /// The same client under other guards than the environment's. 184 #[must_use] 185 pub fn with_guards(mut self, guards: Guards) -> Self { 186 self.guards = guards; 187 self 188 } 189 190 pub fn model(&self) -> &ModelId { 191 self.client.model() 192 } 193 194 pub fn ledger(&self) -> &Ledger { 195 &self.ledger 196 } 197 198 pub fn guards(&self) -> Guards { 199 self.guards 200 } 201 202 /// What the shared guards say right now - every process's questions, not 203 /// only this one's. 204 pub fn status(&self) -> Result<Status, String> { 205 self.ledger.status(ledger::now(), self.guards) 206 } 207 208 /// The most recent [`RateLimit`] this client has seen, if any response 209 /// ever carried one. 210 pub fn last_rate_limit(&self) -> Option<RateLimit> { 211 *self.last_rate_limit.lock().unwrap_or_else(PoisonError::into_inner) 212 } 213 214 /// Ask, under `jev-client`'s retry policy - but first, have the shared 215 /// ledger admit it ([`Ledger::admit`]: question bucket, hour breaker, out 216 /// of credit - there is no lifetime cap, per the user's own ruling, 217 /// 2026-09-22), before a single byte goes over the network. A refusal 218 /// that clears itself is [`Failure::Throttled`], one that does not (out 219 /// of credit) is [`Failure::Budget`]; neither is retried here. 220 /// 221 /// `why` is recorded against the spend in the ledger's `reason`. It is a 222 /// required argument, not an option, so that every line of the ledger can 223 /// say why the money went: the breaker trip of 2026-09-20 had to be 224 /// 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} 305 306/// Any of the conventional rate-limit header spellings, read opportunistically. 307/// Digest §10: none of these are documented anywhere in the vendor's own 308/// reference as of 2026-09-20, so this is expected to return `None` on every 309/// real response; it stays general rather than named after one convention so 310/// that if the vendor starts sending any of them, this starts reporting it 311/// 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} 328 329/// Every header name and value, redacting anything that could be a 330/// credential by name - a bearer token echoed back, a cookie, anything with 331/// "key"/"token"/"secret" in it. `apps/jevprobe --headers` is the one caller 332/// that prints this; `Answered` always carries it so that mode needs no 333/// 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} 354 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}