Every route under /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 real jevimage.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:
The scheme token is matched case-insensitively, as RFC 7235 §2.1 requires, and any run of whitespace separates it from the key: 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/health is 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_digest against 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.
The same 401 comes back for a wrong key and for a header without the Bearer prefix. The server does not distinguish, because the caller’s fix is identical.

Content limits

All of these are enforced in jevimage/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:
Two things sit outside that. A 500 is Starlette’s own plain-text 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:
Note that the echoed 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. Response encoder 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).
Status: 200 only.

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:
Question bodies are documented in Question types. Summarised: 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.
Two questions at once, with a 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 toy encoder gets the second image wrong. A hash is not a vision model.) Status codes 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:
That last call ran against a third toy server whose encoder declares 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.
(Piped through 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.
Heads are global to the server, not scoped to a caller. This list re-reads the head directory on every call, so it also picks up heads another process wrote. Status codes: 200; 401.

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:
That body came back in 0.01s from a request carrying 4001 examples, over the example cap, and it never got as far as counting them. Per-example failures are labelled by their index in examples, not images:

GET /v1/heads/{name}

One head’s metadata. Same object as an entry in GET /v1/heads, without comparison.
Status codes: 200; 401; 404 (no such head).
It lists the heads that do exist, which is usually what you want. It does not name the head directory: the store’s own 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.
The same body comes back from /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.
Read the rows, not the total. A head can gain overall and still lose a class you care about. Status codes
(That head was produced by saving a trained head with its stored embeddings stripped, the shape of an older or hand-edited head file.) 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.
Status codes
As on 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.
The schema is generated from the request models, so it is exact about request bodies and vague about responses; the handlers return plain dicts with no declared response model. For response shapes, this page and 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/heads embeds 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_ENCODER and every head question becomes a 422, and so does GET /v1/heads/{name}/compare — though GET /v1/heads still lists the head and GET /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. k is min(5, thinnest class), so a class with two examples is scored on two folds and cv_accuracy means very little. Read it next to counts. (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 on jevimage.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 →