pip install jevimage (a few hundred kilobytes, no torch, no weights) and calls
jevimage.connect(url).
Every route under /v1 is one method on jevimage.Jev, so there is no second API to
learn: /v1/ask is ask(), /v1/embed is embed(), /v1/heads is
train/list_heads/delete_head. The wire format itself is in
reference-http.md; this page is about operating the thing.
Every transcript below was run against a deterministic stand-in encoder (16-dim, no downloads), so the transcripts reproduce in a second. Statuses, error text, JSON shapes and commands are real. Probabilities and latencies are the toy’s, not SigLIP’s. A real encoder is slower and its probabilities are spread out rather than saturated.
Install
[serve] implies [local]: a server runs the encoder, so it pulls torch, transformers,
fastapi and uvicorn. See install.md for what each extra costs.
Starting it
jev serve
--host 127.0.0.1, --port 8000, the registry default encoder
(siglip2-base-224), heads in $JEV_HEADS_DIR, else $JEV_HOME/heads, else
~/.jev/heads, and no key.
The flags are not passed to the app object. They are written into the environment
(JEV_ENCODER, JEV_HEADS_DIR, JEV_API_KEYS) and the app reads them itself, so the
same knobs work when a container or a process manager starts uvicorn directly:
jev serve is a single uvicorn process with no --workers, no TLS flags and no
--reload. When you want any of those, run uvicorn yourself; the two paths build the
identical app.
jev serve only sees the built-in encoder registry. The CLI never imports your code,
so a registered custom encoder is invisible to it:
build_app(): your own encoder
build_app(jev=None, *, api_keys=None):
jev=Nonecallsjevimage.load(batch=True), which reads$JEV_ENCODERand$JEV_HEADS_DIR. Pass aJevto control the encoder, the heads directory, the temperature, or batching.api_keys=Nonereads$JEV_API_KEYS. Pass a list to set them in code. Note thatapi_keys=[]means open, overriding the environment.
app at module level (rather than only inside __main__) is what lets you run
it under uvicorn serve_toy:app --workers 2.
jevimage.server.app is built lazily through a module __getattr__, so
from jevimage.server import build_app does not load a model; touching
jevimage.server:app does.
Mounting it in your own FastAPI app
build_app() returns an ordinary FastAPI instance. Add routes to it, or mount it under
a prefix:
mount_toy.py has no uvicorn.run of its own - it is a module for a server to import,
which is why the ASGI path mount_toy:api is on the command line instead.)
A mount has its own auth: the sub-app checks the keys build_app was given, and the
outer app’s route dependencies do not reach it. FastAPI(dependencies=[...]) on the
outer app leaves /jev/v1/health open. Outer middleware does wrap the mount, so if you
mount an open app inside an authenticated one, put the check there, or give build_app
keys anyway.
API keys
One header, one form:bearer <key> is accepted; the key itself is compared exactly.
Keys come from $JEV_API_KEYS, split on commas and whitespace, so both spellings
work and a unit file that copies the CLI’s comma form cannot produce a key with a space
in it:
hmac.compare_digest against each. A junk or
non-ASCII header is a 401, not a 500. Empty strings are dropped before the guard is built,
so api_keys=[os.environ.get("JEV_KEY", "")] on a host where that variable is unset is
an open server that says "auth": "open", not one that claims to be protected and is
not.
On the client side, the key goes to whatever the URL resolves to. connect() refuses
redirects rather than following them, because urllib copies the Authorization header
onto the redirect target (a different host, or a downgraded scheme, included), so one
302 from a hostile or compromised server would hand over the key. A 3xx surfaces as a
JevError naming the Location. That still leaves the transport: over plain http://,
as every example on this page does, the key is on the wire in clear. Terminate TLS in
front of jev serve for anything that leaves a trusted network.
$IMG in the transcripts below is one base64 image, made once:
Bearer prefix, is rejected.
What the key does not cover
FastAPI(docs_url=None, ...) (which means constructing it yourself rather than calling build_app), or block
those three paths at the proxy.
Auth is a route dependency, so the body is read and JSON-parsed before the key is
checked. A malformed body from an anonymous caller gets a 422, not a 401:
No key means open
Not “read-only open”. Open:/v1/ask, upload up to 4000 images
to /v1/heads, overwrite a head that shares a name with theirs, and delete any head you
have. --host 0.0.0.0 with no key is a public GPU with a public disk. 127.0.0.1
with no key, behind a proxy that does the authentication, is a reasonable setup; 0.0.0.0
with no key is not.
Put the keys in a file that only the service user can read, not on the command line.
jev serve --api-key hunter2 shows up in ps for every user on the box. The systemd
unit below uses EnvironmentFile for exactly this.
The limits the server enforces
Read straight fromjevimage/server.py:
Everything each refusal returns, verified against a running server:
The transcript behind that table:
/v1/ask and /v1/embed. POST /v1/heads counts and labels examples
instead, with its own larger cap:
- The pixel cap is checked from the header, before the pixels are allocated. A solid-colour 8000×8000 PNG is 263 KB of base64 and 190 MB of RAM once decoded; the 413 above cost neither.
422is the core’s own sentence, not a wrapper.ValueErrorandKeyErrorfromjevimagereach the caller as 422 and 404, because “head x was trained on encoder y” is the answer the caller needs, not a symptom to hide. The one thing the server edits out is a filesystem path: a 404 body does not disclose the heads directory, however the message read at the REPL.- There is no cap on the total body. Only per-image size and per-request count. A training POST is 4000 images × up to 12 MB each by the server’s own arithmetic. The ceiling has to come from the proxy.
batch=True and the shared encode
build_app() with no jev uses jevimage.load(batch=True), because a server is what the
batcher is for. Requests arriving within a few milliseconds of each other are collected
into one forward pass.
The mechanism, measured with a stand-in encoder that sleeps 10 ms per call to stand in
for a real forward pass (the toy’s own encode is microseconds, so nothing ever queues):
sleep costs the same for 8 images
as for 1, so it models the coalescing but not the payoff. The payoff is real on a GPU,
where the marginal image in a batch is nearly free. jevimage/batcher.py records the
project’s own measurements on an A10G. I did not reproduce them, and this machine has no
encoder to reproduce them with:
-
The wait is adaptive, and it is off when traffic is thin.
max_waitis 6 ms, but the batcher only waits when its recent batch size (an EMA) is at least 1.5. On an idle service a lone request goes straight through and pays nothing. Under load the batch fills before the timer.max_batchis 16, so one forward pass never carries more than 16 images, whateverstats["max_seen"]says you were offered. -
Only single-image requests are batched.
Jev.embedroutes through the batcher only when it is handed exactly one image. A/v1/askwith"image"is one image and gets batched;/v1/embedwith"images": [a, b]goes straight to the encoder as its own pass:That is the right default (a caller who sent a list already batched), but it means a client that loopsembed(one)in 8 threads behaves differently from one that sendsembed([eight]). - One bad batch does not kill the worker. The exception is delivered to every caller in that batch and the thread carries on.
/v1/ask encodes the image once and reads
every question against that one vector. The response says so:
embed_ms is the encode; total_ms - embed_ms is every question. Ten questions in one
request cost one encode; ten requests cost ten. Caption embeddings are cached per string
across requests (50 000 strings, then oldest-first eviction), so a stable question set
warms up once.
Health checks
- Never authenticated, on purpose.
encoderanddimare what your heads will be trained under.encoderchanging is the single most disruptive thing that can happen to a deployment (see below).authis"bearer"or"open", a one-request check that you did not ship an open server by accident.deviceisgetattr(encoder, "device", "cpu").HFCLIPandOpenCLIPset it; a custom encoder that does not will reportcpuhowever it actually runs.headsre-reads the head directory on every call (jev.heads.sync()), which is aglobplus atorch.loadof anything that changed. Once a second is nothing; once a millisecond is a filesystem benchmark.
--workers N run accepts it (the
supervisor owns the socket) and leaves it waiting for a worker. First boot also downloads
weights. Give the check a startup grace period generous enough for a cold model pull, or
you will restart-loop a box that is doing exactly what you asked.
What to monitor
There is no/metrics route. What exists:
timing.embed_msandtiming.total_mson every/v1/askresponse. Watch the p95 ofembed_ms; that is the encoder and the queue. Iftotal_ms - embed_msgrows instead, you are asking a lot of questions with a lot of new captions.- Status-code rates, split the way the table above splits them. A rising 413/415 rate is a client sending the wrong thing; a rising 422 rate is usually one caller with a malformed question or a head that no longer matches the encoder.
uvicornaccess logs for latency and non-2xx.- Process RSS and (on GPU)
nvidia-smimemory. One model per worker process.
text_hits/text_misses is the caption cache; items / batches is your mean batch size,
which tells you whether batching is doing anything. calls counts what was submitted to
the batcher and items what a forward pass has come back for, so the two agree except
for whatever is in flight. Both of these are per process, so with
--workers N you are looking at one worker’s view. jev._batcher is private; it is
stable enough to read for metrics, and nothing else here is.
Reverse proxy and TLS
jevimage has no TLS and no CORS headers. A browser page on another origin cannot call it, andjev serve cannot terminate HTTPS. Both belong to a proxy in front.
The proxy caps the whole body; the server does not.
max_size 1MB:
HTTP 413: http: request body too large and returns
502 to the client, because it had already begun proxying when the limit tripped. If
your clients need a clean 413, check what your proxy version actually emits rather than
assuming. The nginx equivalent is client_max_body_size 100m, which does return 413.
Other proxy notes:
- If you need a browser to call this directly, add CORS at the proxy. Adding
CORSMiddlewareto the app is also possible, but then the policy lives in your code and the TLS lives in the proxy, which is one config too many. - Uvicorn trusts
X-Forwarded-*from127.0.0.1by default; if the proxy is on another host, pass--forwarded-allow-ipsor every access log line will say the proxy’s IP. /v1/heads(POST) is the slow route; it uploads a dataset and fits a head. Give the proxy a read timeout well above its default for that path, or the client will see a 504 for a request the server completes anyway.- Client-side,
jevimage.connect(url, timeout=60.0)is the default. A 4000-image training upload will blow through 60 s:connect(url, timeout=900).
A worked deployment: systemd
Not containerised, one box, one GPU.--host 127.0.0.1. The proxy is the only thing that talks to it.HF_HOMEinsideStateDirectory; otherwise the cache lands in the service user’s home, whichProtectHome=yeshas just made inaccessible, and every restart re-downloads a few gigabytes.ProtectSystem=strictplus oneReadWritePaths: heads are the only thing this process writes.Restart=on-failure, notalways: a config error that makes the encoder unloadable should stay down and visible, not loop while pulling weights./etc/jev/keys.envshould bechmod 600, owned by root, containing one line:JEV_API_KEYS=key1,key2.
A worked deployment: Docker
Unlike everything else on this page, this Dockerfile was not built here. The
machine it was written on has 2.4 GB of free disk and torch does not fit. It is
assembled from behaviour that was verified (the env-var interface, uvicorn jevimage.server:app, the health route, where heads are written), not from a build log.
Treat it as a starting point and read the build output.
--host 0.0.0.0 inside the container is right; publishing it as -p 127.0.0.1:8000:8000
is what keeps it off the network. -p 8000:8000 publishes it to the world, and the
container has no key unless you gave it one.
For a GPU: start from nvidia/cuda:12.4.1-runtime-ubuntu22.04, drop the CPU index, and
run with --gpus all. resolve_device() picks CUDA automatically when torch sees it, and
$JEV_DEVICE overrides.
Scaling
What is GPU-bound: the encoder forward pass, and nothing else. Every question is a matmul against a 768-float vector; a trained head is one more. That is whytotal_ms in
the transcripts above sits so close to embed_ms.
What is CPU-bound: base64 decoding, PIL decoding, EXIF transpose, convert("RGB"),
and the processor’s resize/normalise. For a large JPEG this is not a rounding error; it
can rival a small model’s forward pass. It is also where the request threads spend their
time.
How the concurrency works. The route handlers are ordinary def, so uvicorn runs them
in its threadpool. Decoding therefore happens in parallel across requests. With
batch=True, everything after decoding (the exif transpose, the preprocess and the
forward pass) runs in the single batcher thread, which is the serialisation point.
Given that, the knobs in order:
- Ask more questions per request. Free for a question set the process has already
seen: ten questions in one
/v1/askis one encode, and the second question costs a matmul. A caption this process has not encoded before costs a text-tower pass first, so a request full of fresh captions is not free; see encoders.md. - Send lists.
/v1/embedwith 8 images is one forward pass. Client-side,Remote.embed()chunks to 8 for you. - Keep
batch=True(the default) if callers send single images concurrently. It turns concurrency into batch size, which is where the GPU’s throughput is. - Add workers only for CPU work.
uvicorn ... --workers Ngives you N processes, and each one loads its own copy of the model. That is N times the GPU memory, and they contend for the same device. On one GPU, batching beats workers. Workers are the right answer when decoding is your bottleneck (large JPEGs, CPU inference) or when you have N GPUs and pin one per worker withCUDA_VISIBLE_DEVICES. - Scale out. Several boxes behind the proxy, all pointed at the same heads directory on shared storage.
HeadStore
re-reads any file whose mtime, inode or size changed. Trained on one worker, visible from
the others. Verified with two workers over the same directory:
Failure modes
ChangingJEV_ENCODER invalidates every head. A head records the encoder that fitted
it and refuses to run under another one. Silently wrong results are the alternative, and
the widths often match by accident:
/v1/health tells a client which encoder it is talking to; GET /v1/heads
tells it which encoder each head wants.
Single-caption noul depends on the encoder. The sigmoid form needs an encoder that
publishes both logit_scale and logit_bias. SigLIP does; CLIP-family models and every
ensemble do not. Same request, two servers:
POST /v1/heads with an existing name
overwrites it, and returns 200 either way. There is no per-caller namespace: one client’s
colour is every client’s colour. If several teams share a server, prefix the names, or
give each team its own process and heads directory.
An unreadable head file does not disappear. A truncated or foreign .pt is recorded
as broken rather than skipped, and asking for it gives a 404 whose body says the file is
present and why it would not load, not “no such head”, which would be a lie about a file
sitting right there. That 404 is the only place a server shows it: GET /v1/heads lists
only the heads that loaded, and /v1/health’s heads count leaves it out. To see the
broken ones as a set, look at the heads directory, or run jev heads --json beside it.
A restart drops the caption cache, so the first request after a deploy re-encodes its
captions. Milliseconds, but it shows up as a p99 spike on a busy dashboard.
Timeouts on training. The default client timeout is 60 s and a real training upload
exceeds it. Raise it at connect(), and at the proxy, before blaming the server for a
head that appears anyway.
See also
- reference-http.md: the exact request and response shapes.
- install.md: what each extra pulls in.
- encoders.md: the registry, ensembles, and writing your own.
- reference-python.md:
Jev.trainandcompare: what/v1/headsfits. - reference-cli.md:
jev ask/train/heads/rm --urlagainst the server you just started.
← Choosing an encoder · Docs index · CLI reference →