Chapter 20: jev-http — the native road, with a meter on it
jev-http is how a native program (a command-line tool, a desktop app, a
daemon) asks Jev. It fills in jev-client's two ports for tokio: one
POST to https://api.typesafe.ai/v1/systemone over HTTP/2
(src/transport.rs, ported from postjevsql-pg's
https.rs onto tokio) and a tokio clock. On top of that it puts Jev, a
client that will not send a single byte until a shared, on-disk spend ledger
has admitted the request.
jev-protocol says what to ask and jev-client how to retry. This crate
sends it, and keeps count of what it cost.
A meter on the money
The account behind the API key has a few dollars of credit, ever, and
several programs can spend it: jevsnes' native, jevprobe and zbanks
apps, jevhooks' daemon, and lmjtfy's tools/eval. Each is its own process with its
own memory. If each kept its own running total, two of them running at once
would each believe the other had spent nothing.
So the total lives in a file that every process reads and writes, under a lock. It works like a gas meter in a shared house: whoever turns on the stove, the same dial moves.
let model = jev_protocol::ModelId::pinned("jev-1.13.0")?; // never an alias
let jev = jev_http::Jev::from_env("native", model).expect("TYPESAFE_API_KEY")?; // never a literal
let mut questions = jev_protocol::Questions::new();
let raining = questions.noul("raining", jev_protocol::Noul::new(jev_protocol::Json::text("Is it raining?")))?;
let answered = jev.ask(&state, &questions, "why this question").await?;
println!("{} in {:?}", answered.response.get(raining).noul, answered.took);
"native" is which binary is asking (native, jevprobe, zbanks,
jevhooks, lmjtfy-eval, ...), recorded against every spend in the
ledger. The third
argument to ask is why: it is required, so every line of the ledger says
why the money went. (The breaker trip of 2026-09-20 had to be reconstructed
from token counts, because no line said.)
Keep the Jev and reuse it. It holds the HTTP/2 connection, and a cold TLS
handshake costs more time than the answer does.
sequenceDiagram
participant A as Your program
participant J as Jev::ask
participant L as Ledger (spend.jsonl, flock)
participant C as jev_client::Client
participant T as api.typesafe.ai
A->>J: ask(state, questions, why)
J->>L: admit(now, guards, worst case)
alt a guard refuses
L-->>J: Refusal
J-->>A: Failure::Throttled or Failure::Budget (nothing sent)
else admitted
L-->>J: Hold (written under the same lock)
J->>C: ask(state, questions)
C->>T: POST /v1/systemone (retries inside)
T-->>C: answer or error
alt answered
J->>L: settle(hold, real cost)
J-->>A: Answered
else failed
J->>L: release(hold) only if nothing_billed
J-->>A: Failure::Client
end
end
The lifetime ledger and its guards
src/ledger.rs is an on-disk, flock-guarded, append-only
log of every request any binary has ever made, at
$XDG_STATE_HOME/jev/spend.jsonl (or ~/.local/state/jev/spend.jsonl).
Jev::ask has it admit every single request before a byte is sent,
whichever process is asking. Ledger::admit reads the file, checks the
guards and appends a hold for the admitted request, all under one lock,
so two processes asking at the same instant are serialised and the second
sees the first one's question. The real cost is appended against the hold
when the answer arrives (settle), or the hold is released unanswered
(release). Jev::new takes a Ledger as a required argument: there is no
client that skips this.
| Guard | Default | When it refuses | How it clears |
|---|---|---|---|
| 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. |
| 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. |
| 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. |
A ledger that cannot be read or written refuses too (Failure::Budget): a
guard that cannot see the spend fails closed.
Nothing a caller is refused by a throttle is retried here. The caller goes
on without an answer (in jevsnes, the decisions crate's Engine falls
back to that decision's own upstream pick) and does not ask again until the
time it was told.
A request's worst case is jev_protocol::worst_case_dollars: its length in
characters, capped at 64,000 tokens, priced for all three attempts the retry
policy may make. A hold counts that much until it is settled. After an
interrupted or timed-out attempt the real cost is unknown (the vendor reports
usage only on success), so the hold stays and goes on counting its worst
case. A process killed mid-request leaves its hold unsettled the same way: a
few hundredths of a cent, counted forever, the conservative direction.
Jev::status / Ledger::status say all of this as one Status value,
which jevsnes' window shows in its Bot panel and its MCP budget tool
returns. Status::line shows what has been spent, plainly, with no cap and
no denominator.
There is no lifetime cap
An earlier version of the ledger refused once lifetime_usd crossed a
self-imposed JEV_LIFETIME_CAP_USD ($4.00, never above an
ABSOLUTE_LIFETIME_CEILING_USD of $4.50). It was removed on 2026-09-22, the
owner's own ruling on seeing it ("not this random $4 number"). A
made-up ceiling on the account's own money was never this project's number
to invent. What stays: the two RATE guards above, which bound how FAST money
can go out, not how much, ever; and the vendor's own out-of-credit answer,
which is a fact about the account rather than a guess, and the only hard stop
on total spend.
Why the rate guards heal themselves
Until 2026-09-21 a trip of either breaker wrote paused.json, which blocked
every process until a human removed it. That day an agent tripped the
question breaker (two headless runs and the window asking together) and
"cleared" it by deleting the file. A guard that needs a human to delete a
file will have its file deleted.
Now there is no file: the bucket is the ledger, and it refills as the window
slides. The hour breaker reopens by itself too, on purpose. It is a RATE
guard, not a budget, so a latch a human must reset would add nothing but a
file somebody deletes. The only thing that stops questions for good is the
vendor's own out-of-credit answer (the one flag on disk,
out-of-credit.json), which no amount of waiting clears either, because it
is a fact about the account, not a rate.
Aside. An empty hour once printed as "$-0.0000". Summing no
f64s in Rust gives negative zero, and-0.0 == 0.0is true, so an ordinary equality test could not catch it.Window::dollars_last_hournormalises it, andan_empty_hour_is_positive_zero_not_negative_zerochecks the sign bit.
Why the lifetime total is "estimated"
There is no vendor endpoint to check the total against (below), so it is
booked from the ledger's own recorded token counts plus one guessed opening
line for whatever was spent before the ledger existed. The ledger was opened
once, dated to when the mechanism was built, at $0.25 on 2026-09-20. That
guess was revised on 2026-09-22 against the owner's own TypeSafe dashboard
reading (last 7 days, $0.1876, the account's entire history to date):
subtracting the ledger's own real spend at that moment ($0.0359) leaves
$0.1517 of real pre-ledger spend. OPENING_ESTIMATE_USD in
src/ledger.rs carries the arithmetic. It is still a guess,
which is why it stays flagged rather than trusted exactly: the dashboard
itself is marked "*Estimated", and every day of further use makes the
pre-ledger fraction of the total smaller.
No balance, usage or credit endpoint exists to read instead. Checked directly on 2026-09-22, on top of the digest's own §10, which had already searched the vendor's full doc dump and found none:
| Checked | Grade | Result |
|---|---|---|
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"}. |
GET api.typesafe.ai/v1/models | Authoritative | Lists model aliases and release dates only (§1); no account field. |
docs.typesafe.ai/api | Authoritative (vendor docs) | Documents exactly one endpoint, the evaluation POST. |
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. |
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. |
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. |
So the ledger's file is the only account of spend this project has, and
Status::line's lifetime figure is shown "estimated" for exactly this reason.
The vendor's own ceilings
The vendor documents 250,000 tokens a second and 1,200 requests a minute
(digest §4). HttpsTransport keeps a per-process token bucket under each,
as a courtesy against a 429. The shared question bucket above is far
stricter, so in practice these never bite.
Host-only
jev-protocol compiles for wasm32-unknown-unknown; this crate does not,
and does not need to. api.typesafe.ai refuses browser origins outright (a
CORS preflight from https://example.com returns HTTP 400 "Disallowed CORS
origin", measured 2026-09-20), so a web page talks to a relay that holds the
key server-side. The relay speaks jev-protocol's types too; only the
transport differs. That is why the protocol and the transport are separate
crates, and a Cloudflare Worker has its own transport in
jev-worker.
TLS without C
rustls with the RustCrypto provider and compiled-in Mozilla roots:
no OpenSSL, no ring, no aws-lc, nothing to build with a C compiler or
cmake, and nothing read out of /etc/ssl, so what it trusts is the same on
every machine and in a sandbox with no /etc/ssl at all. hyper drives the
HTTP/2 connection directly, so a refused stream or a GOAWAY is told apart
from an interrupted request (TransportErrorKind::NotSent) and resent
without risking a second charge. Endpoint::trusting adds a PEM file to the
roots, for jev-mock's throwaway CA and for private endpoints.
Aside.
cargo tree -p jev-http -i ringdoes findring, but only by way of the dev-dependency on jev-mock, whose TLS server turns on rustls'sringfeature for the tests.cargo tree -p jev-http -e normal -i ringfinds nothing.
The key
TYPESAFE_API_KEY, from the environment and from nowhere else. It is never
logged, never printed, never written to a file, and never put on a command
line. On this machine it reaches a process through op-env-run (from
1Password), as in op-env-run -- <command>. Jev::from_env returns None
when it is absent or blank, which is not an error: the program runs, with
asking switched off, and can say why.
Try it
cargo test -p jev-http
The unit tests replay the ledger's history on a fake clock (the 2026-09-21 trip, the hour breaker reopening, two processes sharing one bucket and admitting exactly 30 between them). tests/mock.rs runs the whole client against jev-mock over real TLS.
For the people who maintain it
In this folder
| Path | What |
|---|---|
| src/ | The code, file by file: Chapter 20½. |
| tests/ | The client against jev-mock: Chapter 20¾. |
| 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. |
| CLAUDE.md | Invariants for agents. Long, and every line of it has a history. |
← Previous: Chapter 19½: inside jev-worker/src · Up: jevcrates · Next: Chapter 20½: inside jev-http/src →