A Racket client for the TypeSafe System One API.
Jev answers typed questions about a blob of state and returns calibrated probabilities. You do not get prose back and you do not parse anything: a yes/no question returns a number between 0 and 1, a multiple-choice question returns the winner plus the whole distribution, and a rating question returns a probability-weighted value plus a confidence.
(require jev)
(define c (make-client)) ; reads TYPESAFE_API_KEY
(define r
(ask c "Payouts have been failing for three days and I am losing sales."
(hash "urgent" (noul "The message conveys time pressure.")
"team" (choice "Which team should handle this"
(hash "billing" "Payments, invoicing, refunds"
"technical" "Bugs, outages, integrations"))
"anger" (score "How angry the writer sounds"
'("Calm" "Annoyed" "Furious")))))
(noul-answer-value (response-ref r "urgent")) ; 0.99
(choice-answer-value (response-ref r "team")) ; "technical"
(answer-confidence (response-ref r "team")) ; 0.9
(score-answer-value (response-ref r "anger")) ; 1.0
(response-model r) ; "jev-1.13.0"All three questions above cost one request. Jev reads the state once and evaluates every question against it, so batching is both cheaper and faster than asking separately.
This is written against the published HTTP reference at https://docs.typesafe.ai/api, not translated from the vendor's Python SDK. The wire protocol is two endpoints and a handful of JSON shapes; the SDK's bulk is ergonomics that do not survive a move to Racket anyway. Building from the spec keeps the provenance clean and leaves us independent of an SDK that is still pre-1.0.
The tests pin the documented request and response examples verbatim, so a change on the vendor's side fails the suite rather than surfacing in production.
Racket 8.x or newer with working TLS; developed and tested on 9.3. No
dependencies beyond base — net/http-client and json ship with Racket. A
client for one HTTP endpoint should not drag a dependency tree behind it.
Install from the official download. It is the newest build and needs no root:
curl -fLO https://download.racket-lang.org/installers/9.3/racket-9.3-x86_64-linux-cs.sh
sh racket-9.3-x86_64-linux-cs.sh --in-place --dest ~/.local/racket
ln -sfn ~/.local/racket/bin/racket ~/.local/racket/bin/raco ~/.local/bin/The Snap Store and Nix (nix-shell -p racket)
builds both work; the snap trails the release by a version or two.
Do not install Racket from Homebrew or Linuxbrew. The minimal-racket formula
resolves OpenSSL through the binary's DT_RPATH, and when that bottle's OpenSSL is
built against a newer glibc than the host provides, the library never loads. Racket
then reports TLS as simply unavailable and every request dies at connect time:
ssl-connect: requested protocol not supported;
SSL not available; check `ssl-load-fail-reason'
DT_RPATH outranks LD_LIBRARY_PATH, so no environment variable will redirect it.
Check an install before blaming the transport:
racket -e '(require openssl) (printf "~s ~s\n" ssl-available? ssl-load-fail-reason)'
# want: #t #fMinimality is not the problem — the official "Minimal Racket" download is fine,
since this library needs only base. It is Homebrew's packaging that breaks.
raco pkg install --link /path/to/jevracket # then (require jev)Or skip installing and require it by path, which is what a host application embedding this will usually do:
(require "../jevracket/main.rkt") ; relative path
(require (file "/opt/jevracket/main.rkt")) ; absolute needs the (file ...) formA bare absolute string is not a valid Racket module path, so an embedding host
with an absolute location must wrap it in (file ...).
choice and score are common words. If they collide, prefix the module:
(require (prefix-in jev: jev))
(jev:ask c state (hash "team" (jev:choice ...)))| Returns | Use it when | |
|---|---|---|
(noul instructions #:yes #:no) |
probability of yes, 0 to 1 | the question has two answers |
(choice instructions criteria) |
winner, per-option probabilities, confidence | one of a closed set, up to 255 |
(score instructions criteria) |
weighted value, per-level probabilities, confidence | an ordered rubric, up to 10 levels |
(locate instructions) |
a box-region in the request's image, confidence |
finding something in an image |
The vendor calls the fourth primitive Box, and so does the wire — the
constructor is locate only because box is one of Racket's most basic names
and shadowing it would break any module that uses boxes and this library
together. It needs a model that can see (jev-1.14.0 / jev-preview) and an
image in the request — see "Images" below.
instructions is optional on every type since the 0.7 API: a noul may be
carried entirely by a described outcome — (noul #:yes "the invoice is past its due date") is a complete question — and a choice or score by its criteria.
Pass #f (or nothing, for noul and locate) to omit it.
criteria for a choice is a map of option to rubric, or #f where an option
needs no explanation. An association list works too and keeps your ordering:
(choice "Route this ticket"
'(("billing" . "Payments, invoicing, refunds")
("technical" . "Bugs, outages, integrations")
("other" . #f)))criteria for a score is an ordered list, lowest level first. The order is
the scale, which is the whole difference between a score and a choice. The API
accepts one to ten levels; two is where a score means anything.
Order is part of the question. The questions map and a choice's options are JSON objects whose key order the model sees, and this client preserves the order you wrote — an association list of questions or options serializes exactly as listed (the vendor SDK does the same with Python dicts). Descriptions and levels may be strings, objects, arrays or images.
instructions may also be a JSON object or array when the question needs to
carry reference data:
(noul (hasheq 'candidate (hasheq 'name "John Smith" 'employer "Google")
'question "Is this resume the same person as `candidate`?"))Every documented limit is enforced at construction, so a bad question raises in Racket with a source location instead of costing a round trip and a 422.
An image rides OUTSIDE the JSON: the request becomes multipart/form-data with
the JSON as a part named request and each image as its own raw-bytes part.
This client builds all of that; you hand it image values:
(define photo (image (file->bytes "kitchen.png"))) ; bytes, or a path: (image "kitchen.png")
(ask c (hasheq 'photo photo) ; images sit anywhere in state
(hash "mug" (locate "the coffee mug in `photo`")
"is_tidy" (noul "Is the kitchen in `photo` tidy?"))
#:assets (list (cons "photo" photo)) ; names it, so questions can say `photo`
#:model "jev-preview")(image data)takes image file bytes or a path, read once on first use and reused by every request that sends the same value.image-from-base64takes base64 text, such as an upload. The content type is sniffed from the file signature (PNG, JPEG) purely to label the part — the API detects formats itself, so anything else still goes as generic binary data.- An image named in
#:assetsserializes as that id wherever it appears; an anonymous one serializes as its unique reference and is uploaded under it. - The API gates image requests behind an explicit opt-in while the feature is experimental. Pass the header yourself, which is also how the vendor SDK does it:
(make-client #:headers '(("TypeSafe-Danger-Zone-Images" . "true")))Use the typed accessors when you know the shape, or the three generic ones
(answer-value, answer-confidence, answer-probabilities) when you are
routing generically.
Two places this client deliberately improves on the wire:
- A score's
legendandprobabilitiesarrive keyed by integer level, not by the numeric strings JSON uses. They index a list you wrote, so you can do arithmetic on them. - A choice's probability keys stay strings, because they are option names you chose. Handing back the symbols the JSON parser happened to produce would leak the parser into your contract.
answer-confidence is #f for a noul. That is the API's shape, not an omission:
a noul's probability already is its certainty. A box's confidence may be #f
too — there the wire allows null, meaning the model did not say. Treat #f
as "not applicable", never as "unconfident".
Two more wire facts the types carry: usage token counts the API did not
report are #f rather than an error, and an answer whose type this client
does not know is skipped with a logged warning rather than failing the
response — the vendor SDK's forward-compatibility rule. A malformed answer of a
known type still raises; that is a broken contract, not a new feature. The raw
body is always available through ask/raw.
Confidence is the second axis. The answer tells you what; the confidence tells you whether to act. The usual shape is to act above a threshold and route to a human below it.
Everything raises a subtype of exn:fail:jev, split by what you can do about it.
(with-handlers ([exn:fail:jev:rate-limit? (lambda (e) (defer (exn:fail:jev:rate-limit-retry-after-ms e)))]
[exn:fail:jev:auth? (lambda (e) (alert-operator!))]
[exn:fail:jev:response? (lambda (e) (log-bad-field (exn:fail:jev:response-field-path e)))]
[exn:fail:jev:connection? (lambda (e) (fall-back))])
(ask c state qs))exn:fail:jev:api— the server answered unhappily. Carriesstatus, the parsedbody,headers, andendpoint.(api-error-request-id e)pulls thex-typesafe-request-idworth quoting in a bug report. Subtypes:bad-request400,auth401,permission403,not-found404,unprocessable422,rate-limit429,server5xx.exn:fail:jev:connectionand:timeout— no HTTP response at all.exn:fail:jev:response— a 2xx whose body broke the contract.field-pathsays where, e.g.answers.tone.confidence.
That last one is the point of the library. A missing confidence is never
returned as #f for the caller to trip over later; a half-built answer is worse
than a raised one.
An API key never appears in an exception message.
Defaults match the vendor SDK, so behaviour does not depend on which client you
used: 2 retries after the first attempt, 0.5s doubling to 5s with 25% jitter,
retry on 408, 429 and every 5xx (which covers 529 Overloaded), honour
Retry-After, give up after 30 seconds overall, 10 seconds per attempt.
(make-client #:timeout 5.0
#:retry (retry-policy 3 0.5 5.0 0.25 retryable-api-status? #t #t 20.0))Retry-After wins over the computed backoff and is clamped to the remaining
budget. The deadline is checked before sleeping, so a client never naps five
seconds only to give up on waking.
Each attempt runs under its own custodian, so abandoning a stalled request also
closes its socket. Racket's http-sendrecv has no timeout of its own, and
without this a single stuck connection hangs the calling thread forever.
Request bodies of 1 KiB or more are gzipped (the API accepts gzip and
zstd; below one TCP segment there is nothing to win). #:compress? #f on
make-client turns it off.
| Variable | Default | |
|---|---|---|
TYPESAFE_API_KEY |
none | required; make-client refuses without it |
TYPESAFE_BASE_URL |
https://api.typesafe.ai |
|
TYPESAFE_DEFAULT_MODEL |
jev-latest |
jev-latest is an alias and it moves when the vendor ships. The response reports
the versioned id that actually answered — log it. If you have tuned confidence
thresholds, pass a pinned id such as "jev-1.13.0" and move on your own
schedule.
raco test test/*-tests.rkt # 69 cases, no network
racket test/live-smoke.rkt # one real request; needs a keyThe unit suite runs the client against a real socket
(test/mock-api.rkt, a scriptable HTTP server) rather than a stubbed transport,
because the bugs worth catching live in the transport: a header the client
forgets, a Content-Length it never sets, a 401 retried three times before
failing.
test/live-smoke.rkt reads TYPESAFE_API_KEY or falls back to
~/.typesafe-key, exercises all three primitives in one request plus the models
endpoint and the 401 path, and exits non-zero on failure.
Findings from evaluating the service, not from using this client:
- It is hosted only. There is no self-hosted Jev. Unlike an OpenAI-compatible model endpoint, which an operator can point at a local server, enabling this means state leaves the machine. Zero data retention is an enterprise-plan option; the default is not ZDR.
- English is where it is strongest. The vendor says other languages, including CJK, are handled but not equally well. In our own testing it still separated good from bad Japanese translations cleanly, but with a narrower margin than English work. Measure on your own content before you trust a threshold.
- Question design is a real discipline. A plausible question can confidently answer the wrong thing. Asking "estimate this company's revenue" about a submission that claims a revenue figure returns the claim, with high confidence. Say what you mean.
- Cost is not the constraint. Input runs $0.042 per million tokens and output is free, so a five-hundred-string quality pass costs about a cent. Latency measured 120 to 260ms per request.
- State is capped at 32k tokens for the state plus the longest question, and 64k for everything together. Long documents need chunking.
MIT. Copyright IoTone, Inc. See LICENSE.