/v1 except /v1/health is one method on jevimage.Jev, with the same
arguments and the same return shape (a local Jev has no server to report on, so it has
no health()). POST /v1/ask is ask(), POST /v1/embed is embed(),
/v1/heads is train() / list_heads() / delete_head(). There are no HTTP-only
concepts to learn, and jevimage.connect(url) is a client that already speaks all of it;
see Python API reference.
Everything is JSON in, JSON out. Images arrive as base64: either bare, or as a
data:image/png;base64,... URL (the server strips the prefix and does not check that it
matches the actual bytes; the format is decided by the decoded content).
Start a server with jev serve; see Serving for deployment. A quick one:
The server these examples run against
Every request and response below was executed against a realjevimage.server app. The
encoder is a deterministic 16-dimension toy (a hash projected through a fixed random
matrix), not SigLIP, so no weights had to be downloaded. The numbers in the answers are
therefore meaningless as classification results. The shapes, field names, status codes
and error messages are the real thing. A real encoder produces 768-dim or larger
embeddings and answers that mean something; nothing else about the wire changes.
Three servers were used:
Images in the examples are the flat 64x64 PNGs of the
ds/ fixture, written by
examples/make_fixtures.py; a flat square compresses to
a couple of hundred bytes, which is why the base64 is short enough to paste.
The reusable recipe is:
Authentication
A single shared secret in the standard bearer form:bearer <key> and BEARER\t<key> work too. The key
itself is compared exactly.
The server accepts any one of the keys it was started with. Keys come from
jev serve --api-key K1 K2, from $JEV_API_KEYS (split on commas or whitespace), or
from build_app(jev, api_keys=[...]). With no keys configured, every route is open and
GET /v1/health reports "auth": "open".
Things worth knowing before you put this on a network:
GET /v1/healthis never authenticated, even when keys are set. It is registered outside the authenticated router on purpose: a load balancer that needs a secret to ask whether a process is alive will eventually report that it is not. Health does disclose the encoder name, dimension, device, version and how many heads exist.- Keys are compared with
hmac.compare_digestagainst the raw header bytes, so a non-ASCII key is a 401 rather than a 500. - There is no identity, no per-key scoping, no rate limiting and no audit trail. Any key can train, read and delete any head. If you need more than “one shared secret in front of a trusted network”, put a proxy in front of it.
Bearer prefix.
The server does not distinguish, because the caller’s fix is identical.
Content limits
All of these are enforced injevimage/server.py and jevimage/core.py; they are
constants, not configuration.
GET /v1/heads/{name} and /compare do not check the name: the store lookup misses
first, so an illegal name there is a 404, not a 422.
The base64 length is checked before decoding, and the pixel count is read from the
image header before the pixels are allocated. A solid-colour 12000x12000 PNG is well
under a megabyte on the wire and over a gigabyte in RAM, so compressed size is no bound
on decoded size.
Nothing caps the total request body: the byte limits are per image. Eight images near
the 12 MB ceiling is a request of roughly 96 MB that no check in the server rejects, so
cap it at your proxy if that matters.
Decoded pixels are capped across the request as well as per image: 100 MP in total,
which still admits eight 12 MP camera photos. A proxy body cap cannot bound this — eight
solid-colour 50 MP PNGs are 1.7 MB on the wire and 1.2 GB of decoded pixels, and every
image in a request is decoded before any of them is embedded.
Errors
Every error the application raises is a JSON object with one key:Internal Server Error,
with no JSON at all — any unhandled exception takes that path, and this package installs
no 500 handler — so a client that parses every error body as JSON must guard the one case
it most needs to log. And a request the body schema rejects outright has detail as
FastAPI’s list of field reports instead of a string:
input contains your whole image. Do not log error bodies verbatim.
The engine’s own refusals are the useful ones and they reach you intact. A ValueError
anywhere in the core becomes a 422 with its message; a KeyError (a head that is missing,
or a head file that is present but will not load) becomes a 404. “head ‘x’ was trained on
encoder ‘y’” is an answer, not a symptom, so it is not flattened into
“422 Unprocessable Entity”.
The 404 handler also strips any filesystem path out of the message on its way to the
client. The store names the directory it searched (the right thing at a REPL, the wrong
thing to hand anyone who can guess a head name), so over HTTP that part is removed.
GET /v1/health
What this process is running. Unauthenticated. Cheap: it syncs the head directory and returns counts, it does not touch the model. Responseencoder is the field to read before you do anything else: it tells you which heads will
work here, and whether a single-caption noul is possible (it needs an encoder with both
logit_scale and logit_bias; SigLIP has them, CLIP and every ensemble do not).
POST /v1/ask
One image encode, every question read against that same embedding. This is the route the package exists for: N questions cost barely more than one once their captions are cached, because the encoder runs once and each question is then a matmul. A caption this process has not encoded before costs a text-tower forward pass first, so the first call with a fresh question set is not cheap; see encoders.md#timings. Request
Send
image or images, not both. Sending neither is a 422: {"detail":"send 'image' (one base64 string) or 'images' (a list)"}. Sending both is also a 422, because
the two fields select different response shapes and there is no way to guess which one
you meant:
All four also accept
instructions and prompt (a caption template that must contain
{}).
Response
total_ms - embed_ms is what the questions cost. On this toy encoder the difference is
noise; on a real one, embed_ms is nearly all of it, which is the whole argument for
asking everything you need in one request.
score and the portable form of noul:
score is the expected level index, not a class: 1.1946 on a three-level rubric sits
between “half full” and “packed” in probability mass. legend tells you what each index
was.
A list of questions and a list of images. Note the answers become a list, and the
questions pick up generated ids:
The 422s worth recognising:
{"type":"noul","instructions":"..."} with no criteria needs an encoder that publishes
both logit_scale and logit_bias. Against a server whose /v1/health says siglip2-*
it works; against CLIP or any ensemble it is a 422, and the fix is the
{"true": ..., "false": ...} pair, which works everywhere:
logit_scale=100
and logit_bias=-10, purely to show that the route exists and what basis says when it
is taken. 1.0 is an artefact of a fake calibration; read nothing into the number.
POST /v1/embed
The embedding, so you can cache it, store it in a vector index, or ask questions locally later without re-uploading the image. Request
Same rule as
/v1/ask: one field or the other, and sending both is the same 422. Unlike
/v1/ask, the response shape does not change; embeddings is always a list of rows.
Response
Rows are L2-normalised float32 rendered as JSON numbers. They are only comparable to
other embeddings from the same encoder, so check
/v1/health before you mix them into an
existing index.
jq only to keep the page readable; the raw response carries all 16
numbers.)
Status codes: 200; 401; 413 (more than 8 images, over 12 MB, over 50 MP, or PIL’s own
decompression-bomb guard); 415
(wrong format); 422 (neither field, both fields, or bytes that are not an image).
The decode errors are worth recognising, because they point at different mistakes:
images[0] is the index in the list you sent; when you sent image, it is always
images[0].
GET /v1/heads
Every head on this server, sorted by name. No pagination, no filtering. A head is tens of kilobytes of metadata and servers hold tens of them, not thousands. Response: an array of head objects (see below). Empty array if none.POST /v1/heads
Train a head: upload labelled images, get back the fitted head’s metadata plus the comparison against zero-shot prompts. The encoder stays frozen; all that is fitted is a linear readout over its embeddings, which is why this is seconds and tens of kilobytes. See Training a head. Request
The 8-image cap does not apply here;
examples is bounded by 4000 instead. Every image
is embedded in this request, so a few thousand examples is a long synchronous call and
the connection stays open for the whole fit. Raise your client timeout rather than your hopes.
Response: the head object, plus comparison.
The weights never cross the wire, in either direction. There is no route that returns
W, and no route that uploads one.
train.json held 12 examples, six per class, built by base64-ing each PNG into
{"name": "docs_colour", "examples": [{"image": ..., "label": ...}], "epochs": 300}.)
Status codes
name is validated first, before a single example is decoded, so a typo costs you the
upload and nothing else:
examples, not images:
GET /v1/heads/{name}
One head’s metadata. Same object as an entry inGET /v1/heads, without comparison.
KeyError says ”… in /var/lib/jev/heads (have: …)”,
which is right at a REPL and wrong over a socket, so the server strips the path before
sending.
A second 404 shape exists: a .pt file that is present but will not load says so rather
than claiming the head does not exist.
/compare and from a head question on POST /v1/ask,
where it is a 404, not the 422 an unknown head gets. DELETE still removes the file.
GET /v1/heads/{name}/compare
“Did training actually help?”, answered per class on held-out data. Training a head does not always beat well-written prompts; this route is why you do not have to guess. Response
The two sides are scored differently on purpose: the trained side is cross-validated,
because a head scored on the examples it was fitted to wins by construction and tells you
nothing, while the zero-shot side has no training to hold out and so is scored on
everything.
jevimage.connect(...).compare() turns
this 409 into None, matching the local API.
DELETE /v1/heads/{name}
Remove a head, and its file, permanently. There is no undo and no versioning.POST /v1/heads, the name is checked before anything else happens. GET on a bad
name is a 404 rather than a 422, because the lookup fails before any filename is built
from it.
OpenAPI
FastAPI serves the generated schema at/openapi.json and interactive docs at /docs
(and /redoc). Both are unauthenticated even when API keys are configured, because they
sit outside the /v1 router.
jevimage/server.py are the reference. The key check
is a router dependency rather than a declared security scheme, so the schema carries
authorization as an ordinary optional header parameter on every /v1 operation and
defines no securitySchemes. /docs gives you a text box rather than an Authorize
button, and will happily send the request without it and show you the 401.
Failure modes worth knowing about
- Heads are shared, mutable, global state. Any caller with the key can overwrite or delete any head. Training a name that already exists replaces it with no warning.
- Training blocks.
POST /v1/headsembeds every example inline. There is no job id, no progress and no way to cancel other than dropping the connection. - A head is tied to its encoder by name. Point a server at the same head directory
with a different
JEV_ENCODERand everyheadquestion becomes a 422, and so doesGET /v1/heads/{name}/compare— thoughGET /v1/headsstill lists the head andGET /v1/heads/{name}still returns it. The same embedding width does not mean the same embedding space. - A held-out number off two examples is a coin toss.
kismin(5, thinnest class), so a class with two examples is scored on two folds andcv_accuracymeans very little. Read it next tocounts. (One example is not a thin number, it is a 422.) - No rate limiting, no request-size cap, no per-key identity. The single shared key is the whole security model.
The client that already speaks this
Every route has a method onjevimage.connect(url), which is stdlib-only and needs
neither torch nor transformers:
Non-2xx responses become
jevimage.JevError, which carries .status and .detail
verbatim. See Python API reference; for running the server,
Serving.
← Python reference · Docs index · Cookbook →