Jev

A Jev model doesn't write text. You give it a premise and a hypothesis, and it answers with one label — contradiction, entailment or neutral — and how likely each label is. TurboLLM runs Jev models through vLLM and gives them their own Workspace screen (the Jev Playground). On the API, a Jev model answers structured questions about a piece of content at POST /v1/systemone, and also serves POST /v1/classify and POST /v1/rerank for raw premise/hypothesis work.

Needs vLLM: Linux or WSL2, and an NVIDIA GPU

vLLM is the only engine that runs Jev models, and vLLM doesn't run on native Windows. On Windows you run TurboLLM inside WSL2 — see Windows: run TurboLLM inside WSL2. On any other engine a Jev model is listed as Needs vLLM (Linux or WSL2) instead of disappearing.

What a Jev model is

A Jev model is an NLI cross-encoder (natural language inference). It reads a pair of texts together and decides how they relate. It doesn't chat, it writes no prose, and it isn't an embedding model.

LabelMeans
entailmentThe premise supports the hypothesis: if the premise is true, the hypothesis is too.
contradictionThe premise rules the hypothesis out.
neutralThe premise says nothing either way.

Because the answer is a typed label with a probability for each class, your code can branch on it directly. There's nothing to parse and nothing to prompt-engineer.

Here is a real run. The premise was “A chef is chopping onions in a busy restaurant kitchen.” and the model was asked about three hypotheses:

HypothesisLabelProbability of that label
Someone is preparing food.entailment0.957
The kitchen is empty and silent.contradiction1.000
The chef is wearing a blue apron.neutral0.998

The first follows, the second is ruled out, and the premise never mentions an apron, so the third comes back neutral. These numbers were measured on 2026-09-19 with OpenJev's qwen3.5-4b-nli-v2 checkpoint served by vLLM 0.29.0 under TurboLLM (WSL2, RTX 5070 Ti), and are rounded to three places.

How TurboLLM recognises a Jev model

TurboLLM reads the model's own config.json. A folder is a Jev model when its first architectures entry ends in ForSequenceClassification and its id2label is exactly three labels that read contradiction, entailment and neutral (any letter case, any order). The labels and the prompt template (nli_template) come from that file. Nothing about them is hardcoded, so a model that lists its classes in a different order still maps correctly. Other classifiers, such as sentiment or toxicity models, aren't Jev models and TurboLLM leaves them alone.

Requirements

The only environment this has been run in is vLLM 0.29.0 on WSL2 (Ubuntu 24.04) with a 16 GB RTX 5070 Ti. Native Linux and other GPUs are untested; see What's tested, and what isn't.

Windows: run TurboLLM inside WSL2

vLLM can't run on Windows, so a Windows machine needs a second TurboLLM that lives inside WSL2 and owns the GPU work. The short version:

  1. Set up WSL2 with Ubuntu

    In PowerShell: wsl --install -d Ubuntu-24.04. The regular Windows NVIDIA driver is what exposes the GPU inside WSL2; run nvidia-smi in the Ubuntu shell to confirm you can see it.

  2. Install Node.js 22 or newer inside Ubuntu

    TurboLLM needs Node 22+, and it has to be a Linux install inside Ubuntu, not the Windows one.

  3. Start TurboLLM inside Ubuntu

    npx turbollm, then open http://localhost:6996 in your normal Windows browser. If the Windows TurboLLM is already using port 6996, stop it or start this one on another port with --port.

  4. Install vLLM from Engines

    Engines → vLLM → install, then make it the active engine.

It's a separate install

A TurboLLM running inside WSL2 keeps its own data folder in the Linux home directory: its own model library, settings, chats and Routines. It doesn't share the Windows install's. Download the Jev model from inside it.

There's a full walkthrough on video: Run vLLM on Windows via WSL2 — the real setup.

Get OpenJev

OpenJev (AlexWortega/openjev on Hugging Face, MIT licence) is the model this page was written and tested with. The repo keeps several checkpoints in subfolders. The one that has been tested is qwen3.5-4b-nli-v2: Qwen3.5-4B with a three-label head, about 9 GB of weights.

  1. Activate vLLM first

    Discover's search follows the active engine: on llama.cpp it lists GGUF repos only, on MLX it lists MLX repos only. With vLLM active there's no format filter, so searching for openjev can find it. With any other engine active, search won't.

  2. Or open the repo by its address

    Paste https://huggingface.co/AlexWortega/openjev into Discover's Import from URL (the link icon beside the search box). It opens the repo's file picker whichever engine is active.

  3. Pick the checkpoint and download

    Because the repo holds more than one checkpoint, Discover shows one row per checkpoint, with its size and a Jev model badge. Download the qwen3.5-4b-nli-v2 row. Only that checkpoint's weight, config, tokenizer and template files are fetched, into openjev/qwen3.5-4b-nli-v2/ inside your models folder.

A checkpoint TurboLLM has no verified launch settings for is tagged Not verified rather than hidden. That includes the repo's 35B checkpoint, which is untested. A repo with a single checkpoint downloads exactly as it always did.

Load it

Load the model from Models (or Discover → Load). vLLM must be the active engine.

The Jev Playground

While a Jev model is loaded, Workspace shows exactly one thing: the Jev Playground at /workspace/jev. Chat, Code and Routines are hidden, and opening any of their addresses redirects here with a one-line notice. Nothing is deleted: your chats and Code sessions are hidden, not stopped, and everything comes back the moment no Jev model is loaded, at the place you left it.

The playground calls POST /v1/systemone, described below, so what it shows is what your code gets.

What swaps the Jev model out

Other work can take the GPU while a Jev model is loaded, and the two ways it happens behave differently:

A Jev model can't be the model a Routine uses, and it can't be the starting model in onboarding. turbollm launch refuses it (coding agents need a chat model), and the list_models tools mark it kind: jev so agents know not to pick it for chat.

The endpoints

All three live on the same gateway and port as the OpenAI-compatible API. They aren't part of OpenAI's API, so the OpenAI SDKs have no method for them: use plain HTTP.

MethodPathPurpose
POST/v1/systemoneAsk up to 64 questions (yes/no, pick one, a position on a scale) about one piece of content
POST/v1/classifyLabel one premise against up to 128 hypotheses
POST/v1/rerankRank up to 128 documents (answer options) against a query
GET/v1/modelsLists Jev models with "kind": "jev"

TurboLLM handles these paths itself rather than passing them through to the engine, and POST /v1/systemone is TurboLLM's own: it is not a proxied engine path. Because POST /v1/rerank is handled here too, it is a Jev endpoint: if you were reaching a llama.cpp reranker on /v1/rerank through the gateway, that path no longer gets there. A request that names a model that isn't a Jev model gets 400 not_a_jev_model. A request with no model gets 400 invalid_request on /v1/classify and /v1/rerank, and 422 invalid_request on /v1/systemone.

Rules every Jev endpoint shares

POST /v1/systemone

Ask a set of questions about one piece of content and get one answer per question. You send the content (state) and a map of questions under ids of your own. TurboLLM turns each question into premise/hypothesis pairs, runs them on the Jev model named in model, and turns the model's entailment scores into an answer. There are three kinds of question: yes/no (noul), pick one (choice) and a position on a scale (score). The request and response field names follow TypeSafe AI's public System One API, so a client written for that shape only changes its base URL and key. The numbers behind those fields are worked out by TurboLLM as set out in The mapping, and they are not calibrated.

Request body

FieldTypeRequiredDescription
statestring, object or arrayyesThe content the questions are about.
modelstringyesA Jev model's id or name, or jev-latest. See Choosing the model.
questionsobjectyesA map from your own question ids to questions: 1 to 64 of them, each id 1 to 128 characters long. The ids are yours. They come back as the keys of answers and are never sent to the model.

Whitespace is never trimmed. JSON parsing puts keys that look like whole numbers first, in numeric order, so if your question ids or option names look like numbers, answers and the tie-break between equal options follow that order.

A question is { "type", "instructions", "criteria" }.

FieldDescription
typenoul (yes/no), choice (pick one) or score (a position on ordered levels).
instructionsWhat to decide. A string, an object or an array, 1 to 4,000 characters once serialised. An object with a question field leads with it, and the rest follows as JSON. Backticked paths inside instructions are not resolved: they are passed through as text.
criteria for noulOptional: { "true": …, "false": … }, each a string, an object or an array. criteria.true is sent with the question (see The mapping). criteria.false is accepted for compatibility and is not used: normalising a yes/no against a second statement moved the 0.5 boundary and cut the probe's accuracy from 0.917 to 0.750 (24 pairs, measured through /v1/classify on 2026-09-21; see What's tested).
criteria for choiceRequired: a map from each option to its description (a string, an object, an array or null). 2 to 255 options, each named with 1 to 255 characters.
criteria for scoreRequired: an ordered array of 2 to 10 level descriptions, each a string, an object or an array (not null, not empty).

Limits

Response body (200)

FieldDescription
modelThe versioned id of the model that answered.
answersExactly your question ids, each with one answer in the shape of its type.
answers.<id> for noul{ "type", "noul" }. noul is a number between 0 and 1.
answers.<id> for choice{ "type", "choice", "probabilities", "confidence" }. choice is the chosen option. probabilities is keyed by option and sums to 1. confidence is a number between 0 and 1.
answers.<id> for score{ "type", "score", "legend", "probabilities", "confidence" }. score is the probability-weighted level. legend maps each level index to the description that was sent. probabilities is keyed by the level index as a string and sums to 1. confidence is a number between 0 and 1.
usage{ "input_tokens", "output_tokens" }. input_tokens is the engine's prompt tokens summed over the batch. output_tokens is always 0.

Numbers are returned as computed, not rounded.

Shape only — the numbers are illustrative. This request has three questions, one of each type. The response below shows the shape of the answers. Its numbers are made up to show the fields, not the output of a real run. They are rounded to three places, and <n> stands for a token count.

curl http://localhost:6996/v1/systemone \
  -H "Content-Type: application/json" \
  -d @- <<'EOF'
{
  "model": "qwen3.5 4b nli v2",
  "state": "I've been unable to connect my payment provider for three days and the integration keeps failing. I'm losing sales, please help as soon as possible.",
  "questions": {
    "urgent": { "type": "noul", "instructions": "Does the message convey urgency?" },
    "team": {
      "type": "choice",
      "instructions": "Which team should handle this message?",
      "criteria": {
        "billing": "Payment, invoices, refunds or subscription charges",
        "technical": "Bugs, outages or integration problems",
        "sales": "Pricing, plans, upgrades or discounts",
        "documentation": "Questions about where to find docs or reference material"
      }
    },
    "mood": {
      "type": "score",
      "instructions": "What is the customer's tone?",
      "criteria": [
        "Calm, just asking or stating facts",
        "Mildly annoyed but polite",
        "Clearly frustrated",
        "Very angry, strong language"
      ]
    }
  }
}
EOF

# Only when Require API key is on, add:
#   -H "Authorization: Bearer <your key>"
import requests

headers = {"Content-Type": "application/json"}
# headers["Authorization"] = "Bearer <your key>"  # only when Require API key is on

resp = requests.post(
    "http://localhost:6996/v1/systemone",
    headers=headers,
    json={
        "model": "qwen3.5 4b nli v2",
        "state": "I've been unable to connect my payment provider for three days and the integration keeps failing. I'm losing sales, please help as soon as possible.",
        "questions": {
            "urgent": {"type": "noul", "instructions": "Does the message convey urgency?"},
            "team": {
                "type": "choice",
                "instructions": "Which team should handle this message?",
                "criteria": {
                    "billing": "Payment, invoices, refunds or subscription charges",
                    "technical": "Bugs, outages or integration problems",
                    "sales": "Pricing, plans, upgrades or discounts",
                    "documentation": "Questions about where to find docs or reference material",
                },
            },
            "mood": {
                "type": "score",
                "instructions": "What is the customer's tone?",
                "criteria": [
                    "Calm, just asking or stating facts",
                    "Mildly annoyed but polite",
                    "Clearly frustrated",
                    "Very angry, strong language",
                ],
            },
        },
    },
)
if not resp.ok:
    raise RuntimeError(resp.json()["error"]["message"])

answers = resp.json()["answers"]
print("urgent", answers["urgent"]["noul"])
print("team", answers["team"]["choice"], answers["team"]["confidence"])
print("mood", answers["mood"]["score"], answers["mood"]["confidence"])
const res = await fetch("http://localhost:6996/v1/systemone", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    // "Authorization": "Bearer <your key>", // only when Require API key is on
  },
  body: JSON.stringify({
    model: "qwen3.5 4b nli v2",
    state: "I've been unable to connect my payment provider for three days and the integration keeps failing. I'm losing sales, please help as soon as possible.",
    questions: {
      urgent: { type: "noul", instructions: "Does the message convey urgency?" },
      team: {
        type: "choice",
        instructions: "Which team should handle this message?",
        criteria: {
          billing: "Payment, invoices, refunds or subscription charges",
          technical: "Bugs, outages or integration problems",
          sales: "Pricing, plans, upgrades or discounts",
          documentation: "Questions about where to find docs or reference material",
        },
      },
      mood: {
        type: "score",
        instructions: "What is the customer's tone?",
        criteria: [
          "Calm, just asking or stating facts",
          "Mildly annoyed but polite",
          "Clearly frustrated",
          "Very angry, strong language",
        ],
      },
    },
  }),
});
if (!res.ok) throw new Error((await res.json()).error.message);

const { answers } = await res.json();
console.log("urgent", answers.urgent.noul);
console.log("team", answers.team.choice, answers.team.confidence);
console.log("mood", answers.mood.score, answers.mood.confidence);

A real response to the request above (OpenJev 4B on vLLM, 2026-09-21). Identical requests differ slightly from run to run; see What the numbers are, and what they aren't:

{
  "model": "<model id>",
  "answers": {
    "urgent": { "type": "noul", "noul": 0.946 },
    "team": {
      "type": "choice",
      "choice": "technical",
      "probabilities": { "billing": 0.309, "technical": 0.505, "sales": 0.18, "documentation": 0.005 },
      "confidence": 0.319
    },
    "mood": {
      "type": "score",
      "score": 2.231,
      "legend": {
        "0": "Calm, just asking or stating facts",
        "1": "Mildly annoyed but polite",
        "2": "Clearly frustrated",
        "3": "Very angry, strong language"
      },
      "probabilities": { "0": 0.026, "1": 0.065, "2": 0.561, "3": 0.348 },
      "confidence": 0.45
    }
  },
  "usage": { "input_tokens": 509, "output_tokens": 0 }
}

The mapping: how a question becomes a number

The premise is the state, and it is the same for every question: a string as written, an object or array as pretty-printed JSON. The question text goes in the hypothesis. Each pair goes through the model's own template (Premise: … Hypothesis: … for OpenJev), and the number TurboLLM keeps from each pair is the model's probability of entailment.

TypeHypothesis sent
noulOne hypothesis: instructions, followed by criteria.true (joined with a space) when it is given.
choiceOne per option: <instructions> The correct answer is: <option> (<description>). With no description it is just <option>.
scoreOne per level: <instructions> The correct answer is: <level>.

For the example request above, the nine hypotheses (in the order they are sent) are:

urgent  Does the message convey urgency?
team    Which team should handle this message? The correct answer is: billing (Payment, invoices, refunds or subscription charges)
team    Which team should handle this message? The correct answer is: technical (Bugs, outages or integration problems)
team    Which team should handle this message? The correct answer is: sales (Pricing, plans, upgrades or discounts)
team    Which team should handle this message? The correct answer is: documentation (Questions about where to find docs or reference material)
mood    What is the customer's tone? The correct answer is: Calm, just asking or stating facts
mood    What is the customer's tone? The correct answer is: Mildly annoyed but polite
mood    What is the customer's tone? The correct answer is: Clearly frustrated
mood    What is the customer's tone? The correct answer is: Very angry, strong language

The answers are worked out from the entailment probabilities of those hypotheses:

AnswerHow it is computed
noulThe model's raw entailment probability of that one hypothesis. It is not normalised, so it is not pulled toward the middle by a second statement.
choiceprobabilities: the options' entailments normalised to sum to 1 (uniform when every entailment is 0). choice: the option with the strongest entailment, and a tie goes to the earlier option. confidence = sqrt(fit × margin).
scoreprobabilities: the levels' entailments normalised to sum to 1 (uniform when every entailment is 0). score is the sum of index × probability over the levels. confidence = sqrt(fit × max(0, 1 − 2 × sd / (n − 1))).

Choosing the model

model takes an id or a name, matched exactly as on /v1/classify: the exact id, then the exact name, then the name ignoring case, then a fragment of the name. The alias jev-latest (any letter case, surrounding whitespace ignored) means the Jev model that is loaded; otherwise, among the Jev models whose architecture TurboLLM has verified, the one with the largest size on disk (a tie goes to the one that comes first in your library, which is alphabetical by name); and if none is verified, the largest Jev model in your library. “Verified” is a property of the architecture, not of the individual checkpoint, and “largest” is a heuristic, not a quality guarantee: a bigger checkpoint is the safer default, and a model tagged Not verified is passed over while any verified one exists. When your library holds several checkpoints, pass an explicit model id instead. jev-latest is accepted on /v1/systemone only (not on /v1/classify or /v1/rerank), is not listed in GET /v1/models, and follows the normal auto-swap rules: with auto-swap off, a model that isn't loaded is a 503, never a silent load.

Cost

The whole state is sent with every hypothesis, so the work is about hypotheses × (state tokens + hypothesis tokens), and usage.input_tokens shows what a request came to. A request runs its hypotheses in batches of 128, one batch after another. A request can't be cancelled from the playground; it finishes even if you switch to another screen in the app.

Length

By default a Jev model loads with an 8,192-token limit per request. A state plus one question longer than about 32,768 characters is refused with 422 context_length_exceeded, and the message names state or questions.<id>. If you choose a longer limit in the model's load settings, this character check still refuses over-long input, and anything the engine itself rejects comes back as 400 engine_rejected. Nothing is ever truncated.

What the numbers are, and what they aren't

The numbers come from the model's NLI entailment scores: how strongly a cross-encoder finds that the state supports a statement. They are not calibrated, and this is not a calibrated decision model. A noul of 0.9 means the model found the statement strongly supported, not that it is right nine times in ten. confidence is TurboLLM's own formula (above) and is not a calibrated probability. Compare it across messages for the same question, not across different questions: that is all that was measured.

The accuracy figures on this page were measured through /v1/systemone on 2026-09-21, with OpenJev qwen3.5-4b-nli-v2 on vLLM 0.29.0 (WSL2, RTX 5070 Ti), on one small probe: eight support messages written for it, labelled by one author, run twice with the same result. The comparisons between alternative wordings below were measured earlier with the same hypotheses through /v1/classify. What's tested has the sample sizes.

POST /v1/classify

Pairs the premise with each hypothesis using the model's own template and returns a label and probabilities for each pair.

Request body

FieldTypeRequiredDescription
modelstringyesThe Jev model, by id or name (see above).
premisestringyesThe text taken as given. 1 to 4000 characters.
hypothesesstring[]yes1 to 128 statements to check against the premise, each 1 to 4000 characters.

Response body

FieldTypeDescription
modelstringThe id of the model that answered.
resultsarrayOne entry per hypothesis, in the order you sent them.
results[].hypothesisstringThe hypothesis this entry answers.
results[].labelstringThe most likely label: contradiction, entailment or neutral. A tie goes to the lower class number.
results[].probsobjectA probability for each of the three labels, keyed by label name.
usageobjectprompt_tokens and total_tokens, as the engine reported them (0 if it reported none).
curl http://localhost:6996/v1/classify \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen3.5 4b nli v2",
    "premise": "A chef is chopping onions in a busy restaurant kitchen.",
    "hypotheses": [
      "Someone is preparing food.",
      "The kitchen is empty and silent.",
      "The chef is wearing a blue apron."
    ]
  }'

# Only when Require API key is on, add:
#   -H "Authorization: Bearer <your key>"
import requests

headers = {"Content-Type": "application/json"}
# headers["Authorization"] = "Bearer <your key>"  # only when Require API key is on

resp = requests.post(
    "http://localhost:6996/v1/classify",
    headers=headers,
    json={
        "model": "qwen3.5 4b nli v2",
        "premise": "A chef is chopping onions in a busy restaurant kitchen.",
        "hypotheses": [
            "Someone is preparing food.",
            "The kitchen is empty and silent.",
            "The chef is wearing a blue apron.",
        ],
    },
)
if not resp.ok:
    raise RuntimeError(resp.json()["error"]["message"])

for item in resp.json()["results"]:
    print(item["label"], item["probs"][item["label"]], "-", item["hypothesis"])
const res = await fetch("http://localhost:6996/v1/classify", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    // "Authorization": "Bearer <your key>", // only when Require API key is on
  },
  body: JSON.stringify({
    model: "qwen3.5 4b nli v2",
    premise: "A chef is chopping onions in a busy restaurant kitchen.",
    hypotheses: [
      "Someone is preparing food.",
      "The kitchen is empty and silent.",
      "The chef is wearing a blue apron.",
    ],
  }),
});
if (!res.ok) throw new Error((await res.json()).error.message);

const { results } = await res.json();
for (const r of results) console.log(r.label, r.probs[r.label], "-", r.hypothesis);

The response, using the numbers from the run above (abridged):

{
  "model": "<model id>",
  "results": [
    {
      "hypothesis": "Someone is preparing food.",
      "label": "entailment",
      "probs": { "contradiction": …, "entailment": 0.957, "neutral": … }
    },
    {
      "hypothesis": "The kitchen is empty and silent.",
      "label": "contradiction",
      "probs": { "contradiction": 1.000, "entailment": …, "neutral": … }
    },
    {
      "hypothesis": "The chef is wearing a blue apron.",
      "label": "neutral",
      "probs": { "contradiction": …, "entailment": …, "neutral": 0.998 }
    }
  ],
  "usage": { "prompt_tokens": <n>, "total_tokens": <n> }
}

probs always carries all three labels. Only each row's winning probability was written down from the test run, so the other two are shown as here rather than invented, and <n> stands for a token count the run didn't record per example. The API returns full-precision numbers; the ones above are rounded to three places.

POST /v1/rerank

Ranks a list of candidates against a query, in the usual Cohere / Jina rerank shape. Internally the query is the premise. Each document is dropped into hypothesis_template to make a hypothesis, the pair goes through the model's own template, and a document's relevance_score is the model's probability of entailment for that pair. The Jev Playground doesn't call this endpoint, so hypothesis_template is API-only.

Request body

FieldTypeRequiredDescription
modelstringyesThe Jev model, by id or name.
querystringyesThe question or statement to rank against. 1 to 4000 characters.
documents(string | { "text": string })[]yes1 to 128 candidates, each 1 to 4000 characters. Each may be a bare string or an object with a text field.
top_nintegernoReturn only the best n. An integer of at least 1. Default: all documents.
hypothesis_templatestringnoHow each document becomes a hypothesis. Must contain {} where the document goes, at most 1000 characters. Every {} is replaced. Default: The correct answer is: {}.

Response body

FieldTypeDescription
modelstringThe id of the model that answered.
resultsarraySorted by relevance_score, highest first (ties keep the order you sent), then cut to top_n.
results[].indexintegerWhere this document was in your documents array.
results[].documentobject{ "text": … } — the document text, whichever form you sent.
results[].relevance_scorenumberThe probability of entailment for this document.
results[].labelstringThe model's most likely label for the pair.
usageobjectprompt_tokens and total_tokens, as for /v1/classify.
curl http://localhost:6996/v1/rerank \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen3.5 4b nli v2",
    "query": "What is the capital of France?",
    "documents": ["Berlin", "Paris", "Madrid"]
  }'

# Only when Require API key is on, add:
#   -H "Authorization: Bearer <your key>"
import requests

headers = {"Content-Type": "application/json"}
# headers["Authorization"] = "Bearer <your key>"  # only when Require API key is on

resp = requests.post(
    "http://localhost:6996/v1/rerank",
    headers=headers,
    json={
        "model": "qwen3.5 4b nli v2",
        "query": "What is the capital of France?",
        "documents": ["Berlin", "Paris", "Madrid"],
    },
)
if not resp.ok:
    raise RuntimeError(resp.json()["error"]["message"])

for item in resp.json()["results"]:
    print(item["document"]["text"], round(item["relevance_score"], 3))
const res = await fetch("http://localhost:6996/v1/rerank", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    // "Authorization": "Bearer <your key>", // only when Require API key is on
  },
  body: JSON.stringify({
    model: "qwen3.5 4b nli v2",
    query: "What is the capital of France?",
    documents: ["Berlin", "Paris", "Madrid"],
  }),
});
if (!res.ok) throw new Error((await res.json()).error.message);

const { results } = await res.json();
for (const r of results) console.log(r.document.text, r.relevance_score.toFixed(3));

The response, using the numbers measured in the test run:

{
  "model": "<model id>",
  "results": [
    { "index": 1, "document": { "text": "Paris" },  "relevance_score": 0.941, "label": "entailment" },
    { "index": 2, "document": { "text": "Madrid" }, "relevance_score": 0.016, "label": "contradiction" },
    { "index": 0, "document": { "text": "Berlin" }, "relevance_score": 0.008, "label": "contradiction" }
  ],
  "usage": { "prompt_tokens": <n>, "total_tokens": <n> }
}

Paris scored 0.941, Madrid 0.016 and Berlin 0.008, rounded to three places. Add "top_n": 1 to get only the best answer back.

The default template is for multiple choice, and only that was tested

The correct answer is: {} is the convention the OpenJev authors use for multiple-choice questions, and the France example above is the one ranking that was run. Whether it, or any custom hypothesis_template, ranks passages well hasn't been tested. Try it on your own data and check the order before you rely on it.

GET /v1/models

Jev models are listed next to your other models and marked "kind": "jev". Use that field to tell them apart. A Jev model gets no claude-… alias, because it can't serve a coding agent.

{
  "object": "list",
  "data": [
    { "id": "qwen3.5 4b nli v2|mlx-<quant>|<bytes>", "object": "model", "owned_by": "turbollm", "kind": "jev" }
  ]
}

The id is the model's key: its lower-case name, a format-and-quantisation tag and its size in bytes. Copy it from your own /v1/models response.

What a Jev model refuses

A Jev model answers only /v1/systemone, /v1/classify and /v1/rerank. Chat, embeddings and Messages requests that would be served by one get a clear 400 that points you at /v1/systemone, and the check happens before any model is loaded, so a mistaken call never swaps a Jev model in.

Errors

Errors use the OpenAI envelope: { "error": { "message": "…", "type": "…", "code": "…" } }. These are every code /v1/systemone, /v1/classify and /v1/rerank can return. invalid_request is a 400 on /v1/classify and /v1/rerank and a 422 on /v1/systemone.

StatuscodeWhen
400invalid_request/v1/classify and /v1/rerank only: the body isn't a JSON object; model, premise or query is missing, empty or not a string; hypotheses or documents isn't an array of 1 to 128 non-empty strings (documents may be { "text": … }); or top_n isn't an integer of at least 1.
422invalid_request/v1/systemone only: the body failed validation. The message begins with the field path, for example questions.q1.criteria.
422context_length_exceeded/v1/systemone only: a state-and-question pair is over the length limit. The message names state or questions.<id>. See Length.
400invalid_hypothesis_templateRerank only: hypothesis_template isn't a string, has no {} in it, or is longer than 1000 characters.
400link_jev_unsupportedmodel names a model on a Turbo Link machine. None of the Jev endpoints travel over Turbo Link.
404model_not_foundNo local model matches model. On /v1/systemone this is also the answer to jev-latest when the library has no Jev model.
400not_a_jev_modelmodel matched a model that isn't a Jev model, so it has no NLI head to run.
400jev_template_missingThe Jev model's config.json has no usable nli_template (one containing both {premise} and {hypothesis}), so TurboLLM can't build its input. It won't guess a prompt.
503model_not_loadedTurboLLM couldn't get the model running: it isn't loaded and auto-swap is off; the active engine isn't vLLM (the message says a Jev model “runs only on vLLM (Linux or WSL2)”); ComfyUI is rendering; or the load failed. The message is the reason.
400engine_rejectedThe engine itself answered with a 4xx. Its message is relayed.
502engine_errorThe engine answered with a 5xx.
502engine_bad_responseThe engine's reply wasn't the shape a three-label model should produce (wrong number of probabilities, or a missing or duplicate index).
500engine_unreachableTurboLLM couldn't reach the engine, or your client disconnected before it answered.

The refusals for chat, embeddings and Messages (400 jev_model_wrong_endpoint) and for in-app chat (409 jev_model_loaded) are covered in What a Jev model refuses. Trying to start a Jev model on a non-vLLM engine from the app's own load call returns 409 engine_model_mismatch, with the same “Activate a vLLM engine” message.

Use cases

Everything a Jev model does comes down to one question: does this text follow from that text? These are the shapes that question fits. They're patterns, not benchmarks: apart from the kitchen and France examples above and the measurements in What's tested, no results are claimed here, so check any of them against your own data.

Intent routing

State: the user's message. Ask one choice question at /v1/systemone whose options are your intents, each with a short description, and route on choice. A low confidence means no option fitted well; how that held up on a small probe is in What's tested. If a message can carry several intents at once, ask one noul question per intent instead and read each number on its own.

Check a claim against a source

Premise: the source passage. Hypothesis: the claim. entailment means the source supports it, contradiction means the source says otherwise, neutral means the source is silent. Good for spotting an unsupported sentence in a generated summary.

Multiple choice

Put the question in instructions and the options in criteria of a choice question at /v1/systemone. choice is the pick and probabilities shows how close the others were. For a single yes/no, ask a noul question: one number between 0 and 1, the model's raw entailment probability for the statement. /v1/rerank also ranks options (the question as query, the options as documents), and it is the case its default template was written for.

Guardrails

Premise: the text you want to screen (user input or a model's reply). Hypotheses: policy statements (“The text asks for someone's home address.”). Act when entailment is high. Where to set the threshold is up to you, and untested here.

Grade an answer

Premise: the reference answer. Hypothesis: the candidate's answer. It tells you whether the candidate follows from the reference, not whether it's complete or well written.

Reranking

/v1/rerank returns the standard rerank response shape, so tools that expect one can read it. But the score is the probability of entailment with the query as the premise, and ranking passages with it is untested. See the warning above.

What a Jev model is not for: generating or rewriting text, chatting, and producing embeddings. It can only label pairs.

What's tested, and what isn't

ItemStatus
OpenJev qwen3.5-4b-nli-v2, text onlyTested through /v1/systemone, /v1/classify and /v1/rerank. Served by vLLM 0.29.0 through TurboLLM on WSL2 (Ubuntu 24.04) with a 16 GB RTX 5070 Ti, 2026-09-19 and 2026-09-21.
/v1/systemone on OpenJev qwen3.5-4b-nli-v2Run against the real model on 2026-09-21: all three question types, every input form, batches past 128 hypotheses, over-long and hostile input, and the playground in a real browser. The yes/no, pick-one and scale answers are computed from the same entailment scores as /v1/classify; the figures below come from a small probe (vLLM 0.29.0, WSL2, RTX 5070 Ti), not from a benchmark.
jev-latestTested with one real Jev model: with it loaded, jev-latest resolved to it. The other rules (nothing loaded, or several Jev models in the library) were tested only with placeholder model folders and in unit tests, never with two real models. How it picks a model is described under Choosing the model.
The kitchen and France/Paris results on this pageMeasured in that run.
SpeedWarm requests of 11 hypotheses over a short message took 90–131 ms through /v1/systemone on that card; the first request after loading took 1.9 s. Work grows with hypotheses × text length: 512 hypotheses over a state of about 5,000 tokens took 22 s. These are a few data points, not a benchmark.
GPU memoryAbout 12 GB in use of 16 GB the moment the model reported running, and 15.2–15.9 GB from the first requests on, with TurboLLM's defaults (vLLM's 0.92 setting, 8,192-token limit). Nothing else can share the card while it's loaded.
Long inputInputs up to the limit were answered: 30,000 characters of ordinary English (about 5,600 tokens) came back 200, and a sentence at the end of a 24,000-character text still moved the answer, so nothing is cut. An input over the 8,192-token limit came back as 400 engine_rejected with the model still loaded and answering, including dense text (20,000 CJK characters, 16,000 emoji) that TurboLLM's own length check lets through. Nothing longer than 8,192 tokens has been run through the model.
Accuracy: yes/no (noul)0.917 at a 0.5 threshold on 24 pairs (22 of 24: 8 messages, 3 questions, instructions only, no criteria), measured through /v1/systemone on 2026-09-21, twice, with the same result. The two misses: a message that says “No rush” scored 0.79 for urgency, and a borderline money question scored 0.61. Only 6 of the 24 pairs are “yes”, and answering “no” every time would score 0.75. A small probe, not a benchmark, and not calibrated.
Accuracy: pick one (choice)Top-1 right on 5 of 5 labelled messages, on one 4-option routing question, measured through /v1/systemone on 2026-09-21, twice, with the same result. confidence was at most 0.083 on the 3 messages that fit no option and at least 0.229 on the 5 that were routed. A small probe, not a benchmark, and not calibrated.
Accuracy: scale (score)A mean error of 0.36 on a 0–3 tone scale, over 8 messages, with a neutral instruction (“What is the customer's tone?”), measured through /v1/systemone on 2026-09-21, twice, with the same result. A presupposing instruction did worse (see What the numbers are, and what they aren't). A small probe, not a benchmark, and not calibrated.
Accuracy: anything beyond that probeNot measured. The probe's messages and labels are one author's judgement. No accuracy figure is claimed for other data, other questions, other models or the API in general.
The 35B checkpoint in the OpenJev repoUntested. Shown as “Not verified” in Discover.
Image inputUntested. TurboLLM starts the model with image and video input switched off. Text only.
Other NLI models (for example the BERT-style cross-encoder/nli-* family)Untested. They're detected by the rule above and tagged “Not verified”. TurboLLM launches them with plain --runner pooling, and if vLLM can't load one you see vLLM's own error.
Custom hypothesis_template values, and ranking passagesUntested. Only the default template on a multiple-choice question was run.
Native Linux (outside WSL2), other GPUs, other vLLM versionsUntested with a Jev model.

If a model like this is already in your library, this release detects it as a Jev model. The case that changes most is one TurboLLM used to list as an embedding model, such as cross-encoder/nli-roberta-base. It's no longer an embedding model: it takes a chat slot instead of an embedding slot, so loading it from Models replaces the model in the main slot rather than loading beside it. It answers /v1/systemone, /v1/classify and /v1/rerank, and POST /v1/embeddings naming it returns 400 jev_model_wrong_endpoint (see What a Jev model refuses). Like every Jev model it needs vLLM, and it stays untested.

Limits

Troubleshooting

SymptomFix
The model shows Needs vLLM (Linux or WSL2), or the API returns 503 model_not_loaded saying it runs only on vLLMThe active engine isn't vLLM. Install and activate it from Engines. On Windows, that means running TurboLLM inside WSL2.
vLLM won't install on WindowsvLLM isn't supported on native Windows. Use WSL2.
Under WSL2, vLLM stops at start-up with RuntimeError: UVA is not availablevLLM 0.29's default model runner needs pinned memory, which vLLM turns off under WSL. TurboLLM starts vLLM with VLLM_USE_V2_MODEL_RUNNER=0 when it detects WSL, to avoid this. If you still hit it, set VLLM_USE_V2_MODEL_RUNNER=0 in the shell before you start TurboLLM inside WSL.
503 model_not_loaded: “is not loaded. Load it from Models, or turn on auto-swap.”Load the model from Models, or turn on Settings → Gateway → Auto model-swap so requests load it themselves.
The load fails or runs out of memoryWith the defaults, vLLM asks for 92% of the card, and the 4B showed 15–16 GB of a 16 GB card once it was serving. The load fails at start-up if anything else already holds more than about 8% of the card. Close other GPU programs (image tools included) and unload other models first. Lower vLLM memory settings haven't been measured.
400 engine_rejected saying the maximum context length is 8192The premise and hypothesis together are longer than the model's 8,192-token limit. Shorten the input. The limit is the model's Max model length in its load settings; raising it is untested, and loading with the model's full 262,144-token context failed in our test with a CUDA error, which is why TurboLLM defaults to 8,192.
404 model_not_foundUse an id from GET /v1/models, or the folder name with hyphens as spaces. Chat's local isn't accepted here.
404 model_not_found for jev-latest (“No Jev model in your library for 'jev-latest'.”)Your library has no Jev model, so the alias has nothing to pick. Download one (see Get OpenJev) and call again. jev-latest is accepted on /v1/systemone only.
422 invalid_request from /v1/systemoneThe body failed validation. Read the field path at the start of the message (for example questions.q1.criteria) and fix that field. The Jev Playground checks the same rules under each editor before it sends anything.
422 context_length_exceeded from /v1/systemoneThe state plus one question is longer than the limit (about 32,768 characters by default). Shorten the state or the question text; the message says which one. This limit is the launch default for a Jev model. See Length.
400 not_a_jev_modelThe name matched a model that isn't a Jev model. Check for "kind": "jev" in /v1/models.
400 jev_template_missingThe model's config.json has no nli_template containing both {premise} and {hypothesis}. TurboLLM won't invent one.
A downloaded NLI model isn't treated as a Jev modelIts config.json must name an architecture ending in ForSequenceClassification and have an id2label of exactly contradiction, entailment and neutral.
Discover doesn't find OpenJevSearch only includes it while vLLM is the active engine. Or paste the repo address into Import from URL.
Chat, Code and Routines are goneA Jev model is loaded. Use Switch model in the playground, or unload it. They come back untouched.

Still stuck? See Troubleshooting for the general fixes, or ask in Discord or GitHub issues.

Credits and naming

OpenJev is the work of its author, AlexWortega, and is released under the MIT licence. TurboLLM is not affiliated with TypeSafe AI, whose own product is also called Jev. In TurboLLM, “Jev” simply names this kind of model.