jev is a thin shell over the Python API. It computes nothing that jevimage.Jev and jevimage.connect() do not already compute, so a number that looks wrong here is wrong upstream, not in the formatting. The output is plain ASCII on purpose; it is meant to survive being pasted into an issue or a log.
Four of those (ask, train, heads, rm) take --url and drive a remote jev serve instance instead of local weights. Remote mode runs the same code path, because jevimage.connect(url) exposes the same methods as jevimage.load() and the command body is written once against both. Related pages: Install · Quickstart · Question types · Encoders · Serving · Python API · HTTP API

About the transcripts on this page

Every block below is a command that was run, with its real output. The one substitution: the encoder is a deterministic toy encoder (a 16-dim hash-of-the-bytes model registered as toy / toy-calibrated), not SigLIP, because this machine has no network for model weights. The images are ds/, the twelve squares examples/make_fixtures.py writes. The toy is not semantic (it will happily call a blue square “red”), so read the shapes of these outputs, not the probabilities. Everything that is not a probability (columns, ids, error text, exit codes, which install a command needs) is exactly what you will see. Two cosmetic edits: long scratch-directory paths are elided to /tmp/.../ so the lines fit, and a repeated listing already printed in full above is cut short with a ... line. Nothing in a transcript is retyped. The toy appears in jev encoders listings on this page because it was registered by a sitecustomize.py on PYTHONPATH. That is also the only way to get a custom encoder into the CLI at all. See Encoders and the CLI.

What each command needs installed

jev encoders listing the registry without torch is deliberate: seeing which encoders exist should not require the stack that runs them. jev rm gets there by accident of being an unlink; it never has to read a head file. The failure is one message, not a traceback, and it names both fixes:
The second half only appears on commands that can go remote. jev serve cannot, so it gets its own message:
jev heads and jev train produce the same two-part message as jev ask, with the command name substituted.
Listing local heads needs torch even though a head listing is only metadata: the head files are torch tensors, and the command imports the training module to format them. Listing a server’s heads does not, which is the whole reason that import sits inside the function rather than at the top of the module.

Exit codes

Exit 1 covers ValueError, KeyError, OSError, ImportError and JevError (the client’s HTTP error). Anything else is a bug and prints a traceback.
Ctrl-C, here against a server that accepts the connection and never answers:
jev serve is the exception: uvicorn installs its own SIGINT handler, shuts the app down and returns normally, so Ctrl-C there exits 0.

Environment variables

JEV_HOME at work. Note the /heads suffix the CLI appends:

Remote mode: --url and $JEV_URL

Set either and ask, train, heads and rm run against a jev serve instance: no torch, no weights, no GPU on this machine. --url beats $JEV_URL. The last line of jev ask output tells you which backend answered, so a transcript is self-describing:
Against a local encoder the header reads encoder toy instead, and for this image the numbers are identical: same engine, same arithmetic, different side of a socket. --encoder is silently ignored when a URL is set. The server decides which encoder runs; nothing is downloaded here. This command answers from the remote toy encoder and never touches siglip2-giant-384:
jev encoders has no --url: it lists the registry compiled into this install, which is not necessarily what the server runs. Ask the server itself: curl $JEV_URL/v1/health returns its encoder name and dim.

Authentication

A wrong key gets the identical 401 from the server - it does not distinguish “no key” from “wrong key”, and should not. The printed output differs: the four export lines above are the client’s own hint, added only when $JEV_API_KEY is unset, so a JEV_API_KEY=wrong jev heads --url ... prints the first line and nothing else.

When the server is not there

The scheme check is separate because urllib’s own message (“unknown url type: 127.0.0.1”) points at the host and sends you looking in the wrong place. The client’s default timeout is 60s. ask and train take --timeout SECONDS for a cold server that is still loading a model, or a training job too big to finish inside a minute; $JEV_TIMEOUT sets it for all four remote commands, including the two with no flag:
That is port 8134 again: a socket that accepts the connection and then says nothing, which is what a timeout has to be provoked with.

jev encoders

Lists the registry and marks the default. No flags, no --url, no --json. Works in an API-only install.
toy and toy-calibrated are the stand-ins this page’s transcripts run on, registered by a sitecustomize.py as described in About the transcripts; a stock install lists the seven above them and nothing else. (toy and toy-calibrated are there because every transcript on this page runs with the sitecustomize.py shim described under Custom encoders and the CLI. A plain install lists the seven built-ins only.) The * follows $JEV_ENCODER when it is set, and the footnote says so, which is how you catch an override you forgot was exported:
Nothing here downloads weights. An entry in this list is a name plus a factory; the model arrives on the first ask or train that uses it.

Custom encoders and the CLI

jevimage.register(name, factory) is a Python call, and jev never imports your code, so a registered encoder is invisible to the CLI unless you arrange for your module to be imported at interpreter startup. The error says as much:
The two ways to make your own encoder reachable from a shell:
  1. Put a sitecustomize.py on PYTHONPATH that imports your module and calls register(). This is what the transcripts on this page do to get toy.
  2. Do not use the CLI for it. Build the Jev in Python and serve it: uvicorn-hosting jevimage.server.build_app(jevimage.load(MyEncoder())).

jev ask

One image is encoded once; every question is read against that one embedding. N questions cost barely more than one, which is why all four flags repeat. Flag syntax is checked before any weights load - a malformed name=description pair, a repeated --choice name, or three --noul captions costs you no model download. The remaining bounds (option and level counts, an unknown head name) are the core’s, and it sees the question after the encoder is in memory.

Question ids

--choice becomes choice, a second --choice becomes choice2, and so on per type. A --head question is named after the head. Ids are how you find an answer in --json output.
The id and type come first, then the verdict and confidence, then every class with its probability and a bar. A score prints 1.28 / 2, the expected level out of the top index, not a percentage. A head answer also carries its provenance: how many examples fitted it and its held-out accuracy, so a suspiciously confident answer from a head trained on nine images says so on the same line. noul prints one number and its basis, either contrast pair or sigmoid readout.

--json

The table is for humans; --json is the contract. It prints exactly what the Python API returns, which is what you pipe into jq.
Note that --json prints no header line, so it does not record whether a server or a local encoder answered.

Failure modes

Single-caption --noul needs a calibrated encoder. The one-caption form turns a cosine into a probability through a sigmoid, which requires the encoder to publish both a logit_scale and a logit_bias. SigLIP has both. CLIP-family models have a scale but no bias, and an ensemble has no bias either. It averages its members’ scales, but a bias fitted against one member’s own negatives does not transfer to a concatenated cosine. Rather than invent a bias and report a wrong number, it refuses:
The two-caption form works everywhere, and is what you want unless you know your encoder is calibrated. A head refuses to run under a different encoder. A head is a linear map in one encoder’s embedding space. Under another encoder the same matrix produces confident nonsense, so it is checked, not attempted:
This bites most often when $JEV_ENCODER is set in one shell and not another.

jev train

Fits a linear head on the frozen encoder. FOLDER has one sub-folder per label. --encoder is ignored with --url, exactly as on ask: the head is trained in the server’s embedding space, and stamped with the server’s encoder name. --epochs defaults to 500; both sides require at least 1, and a server caps it at 5000. Locally, the head lands in $JEV_HEADS_DIR (see the environment table); with --url it stays on the server and only its metadata comes back. Raise --timeout for a folder big enough that uploading and encoding it outlasts the 60s default.
trained is held-out k-fold accuracy (k = min(5, the thinnest class)), never the fit. zero-shot is what plain prompts scored on the same images. delta is the answer to “did training actually help”, per class, and it is allowed to be negative. When the overall delta is zero or below, the command says so instead of letting you read a table and conclude the opposite:
A - in any accuracy column means “not measured”, not zero: k-fold reports no accuracy for a class too thin to hold anything out. The same command with --url (or $JEV_URL) trains on the server. Identical output, identical numbers; the images are uploaded once:

Failure modes

That second message is the server’s validation error passed through verbatim. It is ugly, and it is the truth. The epoch bound is the server’s, not the client’s, so an API-only install cannot pre-check it. Local training has no such cap; 5000 epochs on a small dataset is seconds. The floor is shared: --epochs 0 is refused either way, because a head fitted in zero steps is all zeros and would still be saved and listed. The other bounds are the core’s, and apply locally and remotely alike: at least 2 examples per class (k-fold has to hold one out), at most 4000 examples and at most 100 classes per head. Head names become filenames and are checked rather than sanitised, because silently rewriting a name means ask cannot find what train said it saved:

jev heads

Lists trained heads. Locally it reads the head directory directly, because listing what you trained should not download an encoder’s weights, though the local branch does need torch, for the reason in the note above.
The last line is where the heads came from: a directory locally, a URL with --url. An empty result is not an error:

Unreadable head files

A .pt file that will not load is reported on stderr and the listing continues. It is not silently skipped, because a later “no head named x” about a file sitting right there in the directory would be a lie:
The reason is truncated to 160 characters. Torch’s unpickling error is several paragraphs, and this is a listing. --json carries the same file under its broken key, so a script reading only stdout hears about it too. Three things put a file here: it will not load at all; it loads but is not a head (someone else’s checkpoint, a bare tensor - anything without W/classes/n); or its filename is not a valid head name, so no command could address it. All three are reported the same way and none of them stops the listing. A directory that cannot be read is a different thing and is an error, not an empty listing - jev heads and jev rm both say Permission denied rather than disagreeing about whether the head is there. Exit status stays 0. A broken file is a warning, not a failure of the listing.

--json

An object, not a bare array: heads is the listing, directory is the same last line the table prints, and broken appears when a .pt file would not load. That is the one channel a --json script has for the warning the table puts on stderr. It is absent when nothing is broken, as above. With junk.pt back in the directory (heads unchanged, cut short here):
Never the weights. A head entry is the same metadata object the HTTP API returns. bytes is what the weights would cost: 136 bytes for a 16-dim, 2-class head. Real encoders are 768 to 1536 dims, so tens of kilobytes.

jev rm

Deletes one head. No confirmation prompt and no undo; a head is cheap to retrain, and the command is used in scripts.
Deleting something that was not there is exit 1, not a silent success. In a script, a typo’d head name should not look like a completed cleanup. This is the one local command that works in an API-only install: deleting a file never reads it.

jev serve

Hosts the HTTP API. Needs pip install 'jevimage[serve]'. Defaults: host 127.0.0.1, port 8000, the default encoder, the default heads directory, no authentication.
The flags are passed through the environment, not into the app object: --encoder sets JEV_ENCODER, --heads-dir sets JEV_HEADS_DIR, --api-key sets JEV_API_KEYS. The same knobs therefore work when a container or a process manager starts uvicorn itself:
The encoder loads at startup, not on the first request; importing the app builds it. A jev serve that has printed “Application startup complete” has its weights in memory. --api-key takes several keys, so clients can migrate to a new key before the old one is withdrawn. Any one of them authenticates; anything else is a 401. Withdrawing the old key is a restart - the list is read once, at startup, from $JEV_API_KEYS - and the port refuses connections while the encoder reloads, so the rotation is seamless for callers but the revocation is not free. See Serving for the systemd shape. The flag also repeats, so --api-key old --api-key new is the same as --api-key old new:
Without --api-key the API is open to anyone who can reach the port, including POST /v1/heads, which trains, and DELETE /v1/heads/{name}, which deletes. --host 0.0.0.0 with no key exposes both. GET /v1/health stays open even when keys are set, so a load balancer does not need a secret to ask whether the process is alive. A key is one argument with no space or comma in it. $JEV_API_KEYS - which is how --api-key reaches the server process - is split on both, so a passphrase like correct horse battery staple would authenticate as four short keys while the passphrase itself got a 401. That is refused at parse time, as is an empty key: --api-key "$JEV_KEY" with $JEV_KEY unset asked for authentication, and starting an open server instead is the one outcome that must not be silent.

Failure modes

A port already in use is uvicorn’s own error, and it exits 1 after a clean shutdown:
Note the ordering: application startup (which loads the encoder) happens before the bind. A model download will complete before you learn the port was taken. The routes this serves are documented in HTTP API.

Things the CLI does not do

Know these before you build a script on top.
  • No --api-key flag on the client commands. $JEV_API_KEY only.
  • No --heads-dir flag except on serve. $JEV_HEADS_DIR or $JEV_HOME.
  • No --temperature. ask uses 1 / logit_scale (the reciprocal of the encoder’s learned scale), or 0.01 for an encoder that publishes none. Pass temperature= to jevimage.load() if you need to sharpen or flatten the distributions.
  • No batch mode. One image per ask. The HTTP API takes up to 8 per request; a loop over jev ask re-encodes the text captions every time. For many images, write the four-line Python loop; see Python API.
  • No compare command. jev train prints the comparison once; to re-run it later, use jev.compare(name) in Python or GET /v1/heads/{name}/compare.
  • --json is only on ask and heads. train and rm print prose.

← Serving · Docs index · Python reference →