lib.rsannotatedlib.rssource404 lines · 18.2 KB · raw
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}