jevcrates.git / jev-http / README.md
README.mdpreviewREADME.mdsource242 lines · 12.7 KB · raw
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/) →