1# Chapter 20: jev-http — the native road, with a meter on it 2 3`jev-http` is how a native program (a command-line tool, a desktop app, a 4daemon) asks Jev. It fills in `jev-client`'s two ports for tokio: one 5`POST` to `https://api.typesafe.ai/v1/systemone` over HTTP/2 6([src/transport.rs](src/transport.rs), ported from postjevsql-pg's 7`https.rs` onto tokio) and a tokio clock. On top of that it puts `Jev`, a 8client that will not send a single byte until a shared, on-disk spend ledger 9has admitted the request. 10 11`jev-protocol` says what to ask and `jev-client` how to retry. This crate 12sends it, and keeps count of what it cost. 13 14## A meter on the money 15 16The account behind the API key has a few dollars of credit, ever, and 17several programs can spend it: jevsnes' `native`, `jevprobe` and `zbanks` 18apps, jevhooks' daemon, and lmjtfy's `tools/eval`. Each is its own process with its 19own memory. If each kept its own running total, two of them running at once 20would each believe the other had spent nothing. 21 22So the total lives in a file that every process reads and writes, under a 23lock. It works like a gas meter in a shared house: whoever turns on the 24stove, the same dial moves. 25 26```rust 27let model = jev_protocol::ModelId::pinned("jev-1.13.0")?; // never an alias 28let jev = jev_http::Jev::from_env("native", model).expect("TYPESAFE_API_KEY")?; // never a literal 29let mut questions = jev_protocol::Questions::new(); 30let raining = questions.noul("raining", jev_protocol::Noul::new(jev_protocol::Json::text("Is it raining?")))?; 31let answered = jev.ask(&state, &questions, "why this question").await?; 32println!("{} in {:?}", answered.response.get(raining).noul, answered.took); 33``` 34 35`"native"` is which binary is asking (`native`, `jevprobe`, `zbanks`, 36`jevhooks`, `lmjtfy-eval`, ...), recorded against every spend in the 37ledger. The third 38argument to `ask` is why: it is required, so every line of the ledger says 39why the money went. (The breaker trip of 2026-09-20 had to be reconstructed 40from token counts, because no line said.) 41 42Keep the `Jev` and reuse it. It holds the HTTP/2 connection, and a cold TLS 43handshake costs more time than the answer does. 44 45```mermaid 46sequenceDiagram 47 participant A as Your program 48 participant J as Jev::ask 49 participant L as Ledger (spend.jsonl, flock) 50 participant C as jev_client::Client 51 participant T as api.typesafe.ai 52 A->>J: ask(state, questions, why) 53 J->>L: admit(now, guards, worst case) 54 alt a guard refuses 55 L-->>J: Refusal 56 J-->>A: Failure::Throttled or Failure::Budget (nothing sent) 57 else admitted 58 L-->>J: Hold (written under the same lock) 59 J->>C: ask(state, questions) 60 C->>T: POST /v1/systemone (retries inside) 61 T-->>C: answer or error 62 alt answered 63 J->>L: settle(hold, real cost) 64 J-->>A: Answered 65 else failed 66 J->>L: release(hold) only if nothing_billed 67 J-->>A: Failure::Client 68 end 69 end 70``` 71 72## The lifetime ledger and its guards 73 74[src/ledger.rs](src/ledger.rs) is an on-disk, `flock`-guarded, append-only 75log of every request any binary has ever made, at 76`$XDG_STATE_HOME/jev/spend.jsonl` (or `~/.local/state/jev/spend.jsonl`). 77`Jev::ask` has it admit every single request before a byte is sent, 78whichever process is asking. `Ledger::admit` reads the file, checks the 79guards and appends a **hold** for the admitted request, all under one lock, 80so two processes asking at the same instant are serialised and the second 81sees the first one's question. The real cost is appended against the hold 82when the answer arrives (`settle`), or the hold is released unanswered 83(`release`). `Jev::new` takes a `Ledger` as a required argument: there is no 84client that skips this. 85 86| Guard | Default | When it refuses | How it clears | 87| --- | --- | --- | --- | 88| Question bucket | `JEV_MAX_QUESTIONS_PER_MINUTE` = 30 | 30 questions admitted in the last 60 s, by all processes together | **By itself.** Each question's token comes back 60 s after it was spent; the ledger's own recent lines are the bucket. `Failure::Throttled`, with the time until the next token. | 89| Hour breaker | `JEV_MAX_DOLLARS_PER_HOUR` = $0.25 | the last hour's spend plus this request's worst case is over the limit | **By itself**, once enough of that spend is over an hour old. `Failure::Throttled`. | 90| Out of credit | none | the vendor answered "no credit" (an inferred shape: jevsnes `research/jev-api-digest.md` §10) | Only when the account is topped up: `Ledger::credit_restored`. `Failure::Budget`. | 91 92A ledger that cannot be read or written refuses too (`Failure::Budget`): a 93guard that cannot see the spend fails closed. 94 95Nothing a caller is refused by a throttle is retried here. The caller goes 96on without an answer (in jevsnes, the `decisions` crate's `Engine` falls 97back to that decision's own upstream pick) and does not ask again until the 98time it was told. 99 100A request's worst case is `jev_protocol::worst_case_dollars`: its length in 101characters, capped at 64,000 tokens, priced for all three attempts the retry 102policy may make. A hold counts that much until it is settled. After an 103interrupted or timed-out attempt the real cost is unknown (the vendor reports 104usage only on success), so the hold stays and goes on counting its worst 105case. A process killed mid-request leaves its hold unsettled the same way: a 106few hundredths of a cent, counted forever, the conservative direction. 107 108`Jev::status` / `Ledger::status` say all of this as one `Status` value, 109which jevsnes' window shows in its Bot panel and its MCP `budget` tool 110returns. `Status::line` shows what has been spent, plainly, with no cap and 111no denominator. 112 113### There is no lifetime cap 114 115An earlier version of the ledger refused once `lifetime_usd` crossed a 116self-imposed `JEV_LIFETIME_CAP_USD` ($4.00, never above an 117`ABSOLUTE_LIFETIME_CEILING_USD` of $4.50). It was removed on 2026-09-22, the 118owner's own ruling on seeing it ("not this random $4 number"). A 119made-up ceiling on the account's own money was never this project's number 120to invent. What stays: the two RATE guards above, which bound how FAST money 121can go out, not how much, ever; and the vendor's own out-of-credit answer, 122which is a fact about the account rather than a guess, and the only hard stop 123on total spend. 124 125### Why the rate guards heal themselves 126 127Until 2026-09-21 a trip of either breaker wrote `paused.json`, which blocked 128every process until a human removed it. That day an agent tripped the 129question breaker (two headless runs and the window asking together) and 130"cleared" it by deleting the file. A guard that needs a human to delete a 131file will have its file deleted. 132 133Now there is no file: the bucket is the ledger, and it refills as the window 134slides. The hour breaker reopens by itself too, on purpose. It is a RATE 135guard, not a budget, so a latch a human must reset would add nothing but a 136file somebody deletes. The only thing that stops questions for good is the 137vendor's own out-of-credit answer (the one flag on disk, 138`out-of-credit.json`), which no amount of waiting clears either, because it 139is a fact about the account, not a rate. 140 141> **Aside.** An empty hour once printed as "$-0.0000". Summing no `f64`s in 142> Rust gives negative zero, and `-0.0 == 0.0` is true, so an ordinary 143> equality test could not catch it. `Window::dollars_last_hour` normalises it, 144> and `an_empty_hour_is_positive_zero_not_negative_zero` checks the sign bit. 145 146### Why the lifetime total is "estimated" 147 148There is no vendor endpoint to check the total against (below), so it is 149booked from the ledger's own recorded token counts plus one guessed opening 150line for whatever was spent before the ledger existed. The ledger was opened 151once, dated to when the mechanism was built, at $0.25 on 2026-09-20. That 152guess was revised on 2026-09-22 against the owner's own TypeSafe dashboard 153reading (last 7 days, $0.1876, the account's entire history to date): 154subtracting the ledger's own real spend at that moment ($0.0359) leaves 155$0.1517 of real pre-ledger spend. `OPENING_ESTIMATE_USD` in 156[src/ledger.rs](src/ledger.rs) carries the arithmetic. It is still a guess, 157which is why it stays flagged rather than trusted exactly: the dashboard 158itself is marked "*Estimated", and every day of further use makes the 159pre-ledger fraction of the total smaller. 160 161**No balance, usage or credit endpoint exists to read instead.** Checked 162directly on 2026-09-22, on top of the digest's own §10, which had already 163searched the vendor's full doc dump and found none: 164 165| Checked | Grade | Result | 166| --- | --- | --- | 167| `GET api.typesafe.ai/v1/{usage,billing,balance,credits,credit,account,organization(s),me,whoami,spend,api-keys,keys}` and a few nested variants, with the real key | Authoritative (the API itself) | Every one `404 {"detail":"Not Found"}`. | 168| `GET api.typesafe.ai/v1/models` | Authoritative | Lists model aliases and release dates only (§1); no account field. | 169| `docs.typesafe.ai/api` | Authoritative (vendor docs) | Documents exactly one endpoint, the evaluation POST. | 170| `docs.typesafe.ai/llms.txt` (the full site index) | Authoritative | 44 sections; none titled account, billing, usage (outside the SDK's per-response `Usage` type), credits, balance, organization, dashboard or console. | 171| `typesafe.ai` home page | Authoritative | Links a "Sign in" to `console.typesafe.ai`, a session-authenticated web dashboard, not a documented API route the key can call. This is where the owner's dashboard reading comes from. | 172| Web search for a `GET /api/v1/credits`-shaped route | Anecdotal, and not the vendor: `jev-ai.pro`, `jevapi.org`, `jevtypesafeai.com` are unofficial products trading on the "Jev" name (the same kind of copycat the brain's `ecosystem/` bundle catalogues), not `api.typesafe.ai` | Probed directly against the real API anyway (above): `404`. | 173 174So the ledger's file is the only account of spend this project has, and 175`Status::line`'s lifetime figure is shown "estimated" for exactly this reason. 176 177### The vendor's own ceilings 178 179The vendor documents 250,000 tokens a second and 1,200 requests a minute 180(digest §4). `HttpsTransport` keeps a per-process token bucket under each, 181as a courtesy against a `429`. The shared question bucket above is far 182stricter, so in practice these never bite. 183 184## Host-only 185 186`jev-protocol` compiles for `wasm32-unknown-unknown`; this crate does not, 187and does not need to. `api.typesafe.ai` refuses browser origins outright (a 188CORS preflight from `https://example.com` returns HTTP 400 "Disallowed CORS 189origin", measured 2026-09-20), so a web page talks to a relay that holds the 190key server-side. The relay speaks `jev-protocol`'s types too; only the 191transport differs. That is why the protocol and the transport are separate 192crates, and a Cloudflare Worker has its own transport in 193[jev-worker](../jev-worker/). 194 195## TLS without C 196 197rustls with the **RustCrypto** provider and **compiled-in** Mozilla roots: 198no OpenSSL, no `ring`, no `aws-lc`, nothing to build with a C compiler or 199cmake, and nothing read out of `/etc/ssl`, so what it trusts is the same on 200every machine and in a sandbox with no `/etc/ssl` at all. hyper drives the 201HTTP/2 connection directly, so a refused stream or a GOAWAY is told apart 202from an interrupted request (`TransportErrorKind::NotSent`) and resent 203without risking a second charge. `Endpoint::trusting` adds a PEM file to the 204roots, for jev-mock's throwaway CA and for private endpoints. 205 206> **Aside.** `cargo tree -p jev-http -i ring` does find `ring`, but only by 207> way of the dev-dependency on jev-mock, whose TLS server turns on rustls's 208> `ring` feature for the tests. `cargo tree -p jev-http -e normal -i ring` 209> finds nothing. 210 211## The key 212 213`TYPESAFE_API_KEY`, from the environment and from nowhere else. It is never 214logged, never printed, never written to a file, and never put on a command 215line. On this machine it reaches a process through `op-env-run` (from 2161Password), as in `op-env-run -- <command>`. `Jev::from_env` returns `None` 217when it is absent or blank, which is not an error: the program runs, with 218asking switched off, and can say why. 219 220## Try it 221 222``` 223cargo test -p jev-http 224``` 225 226The unit tests replay the ledger's history on a fake clock (the 2026-09-21 227trip, the hour breaker reopening, two processes sharing one bucket and 228admitting exactly 30 between them). [tests/mock.rs](tests/mock.rs) runs the 229whole client against jev-mock over real TLS. 230 231## For the people who maintain it 232 233### In this folder 234 235| Path | What | 236| --- | --- | 237| [src/](src/) | The code, file by file: Chapter 20½. | 238| [tests/](tests/) | The client against jev-mock: Chapter 20¾. | 239| [Cargo.toml](Cargo.toml) | Dependencies: hyper, h2, rustls with `rustls-rustcrypto`, tokio, `webpki-roots`, `libc` (for `flock`), serde, schemars, and the two lower crates; `jev-mock` for tests. | 240| [CLAUDE.md](CLAUDE.md) | Invariants for agents. Long, and every line of it has a history. | 241 242← Previous: [Chapter 19½: inside jev-worker/src](../jev-worker/src/) · Up: [jevcrates](../) · Next: [Chapter 20½: inside jev-http/src](src/) →