Skip to content

Decision Models

Since v1.3.0, LatteReview can review with decision models (also called System One models) in addition to LLMs. v1.3.0 added TypeSafe's Jev; v1.4.0 adds Perplexity's pplx-decider and OpenAI's gpt-6-luna through their Decisions APIs. This page explains what decision models are, how they differ from LLM reviewers, how the three models compare, and how to use them well.

What is a decision model?

An LLM reviewer reads a prompt and generates text: LatteReview asks it for JSON with a score and a written reasoning. A decision model generates nothing. It reads a text state (here, an article's title and abstract) and a set of typed questions, and returns a probability for every allowed answer. Three question types exist:

Question You define The answer
Noul (yes/no) an instruction, optionally what "true" and "false" mean the probability of "yes"
Choice named options (up to 255 with Jev), optionally with descriptions the chosen option, a probability per option, and a confidence
Score 2-10 ordered levels the expected level, the most likely level, a probability per level, and a confidence

Three decision models are supported:

Model Maker How to use it Price (input tokens; output is free) Screening cost per 1,000 abstracts*
Jev (jev-latest) TypeSafe AI SystemOneProvider() or backend="openrouter" $0.042 per million about $0.06
pplx-decider (pplx-decider-v1.1-27b) Perplexity backend="perplexity", or backend="openrouter", model="perplexity/pplx-decider-v1.1-27b" $0.02 per million about $0.05
gpt-6-luna OpenAI backend="openai" (OpenAI's Decisions API) $0.10 per million about $0.16

* Measured with DecisionTitleAbstractReviewer on the evaluation below. For comparison, TitleAbstractReviewer with the gpt-6-luna LLM costs about $0.10-0.13 per 1,000 abstracts, and larger LLMs one to two orders of magnitude more.

All three answer in well under a second per article. In our evaluation, pplx-decider ranked articles best and cost least; gpt-6-luna's Decisions API ranked worse than the LLM baseline on long, multi-part criteria (see Comparing the three models).

Decision models vs LLM reviewers

LLM reviewers (TitleAbstractReviewer, ...) Decision reviewers (DecisionTitleAbstractReviewer, ...)
Output JSON generated by the model probabilities; answers can never be off-schema
Written reasoning yes (reasoning="brief" or "cot") no. The reasoning column is generated by LatteReview from the probabilities
Certainty the model's self-reported number a probability, which can be thresholded for a target recall
Inputs text and images text only in LatteReview (Jev: English-optimized, up to 32k tokens for the state plus the longest question)
Multi-step reasoning yes no multi-hop reasoning; keep hard, multi-step criteria for an LLM
Prompting prompt templates, examples, model_args typed questions only; examples, reasoning, model_args and prompt templates raise an error
Cost per 1,000 abstracts about $0.13 (gpt-6-luna) to several dollars (gpt-6-sol, gpt-6-astra) about $0.05-0.16
Latency per article one to several seconds 0.2-0.8 seconds (Jev and pplx-decider are paced to about 16 articles per second by default)
Repeatability answers can change between runs pplx-decider and gpt-6-luna repeated identical requests exactly; Jev's uncertain answers vary (we saw 0.62-0.84 for one article)
Refusals the model may refuse in text OpenAI may decline single questions; the answer is then None (see Refusals)

Decision reviewers are BasicReviewer subclasses, so they work in any ReviewWorkflow, next to LLM reviewers, with the usual round-{R}_{name}_{key} columns, filters, cost tracking and memory. backstory is accepted but ignored.

Backends

SystemOneProvider works the same way with every backend. Most speak the /v1/systemone protocol; OpenAI's Decisions API has its own request format, which the provider translates, so reviewers see the same answers either way.

from lattereview.providers import SystemOneProvider

provider = SystemOneProvider()                                    # TypeSafe, jev-latest, reads TYPESAFE_API_KEY
provider = SystemOneProvider(backend="openrouter")                # OpenRouter, ~typesafe/jev-latest, reads OPENROUTER_API_KEY
provider = SystemOneProvider(backend="perplexity")                # Perplexity, pplx-decider-v1.1-27b, reads PERPLEXITY_API_KEY
provider = SystemOneProvider(backend="openai")                    # OpenAI, gpt-6-luna, reads OPENAI_API_KEY
provider = SystemOneProvider(backend="openrouter", model="perplexity/pplx-decider-v1.1-27b")  # pplx-decider via OpenRouter
provider = SystemOneProvider(model="jev-1.13.0")                  # pin a version instead of the -latest alias
provider = SystemOneProvider(base_url="http://localhost:3000")    # a self-hosted server (see below)
provider = SystemOneProvider(
    base_url="https://your-gateway.example/v1/systemone",         # any other /v1/systemone gateway
    api_key="...",
    model="...",
    input_price_per_million=0.042,                                # for cost tracking (custom URLs default to 0)
)
Backend How to get access Default model Key variable
typesafe (default) an API key from the TypeSafe console jev-latest TYPESAFE_API_KEY
openrouter an OpenRouter API key; billed through your OpenRouter credits. Also serves perplexity/pplx-decider-v1.1-27b ~typesafe/jev-latest OPENROUTER_API_KEY
perplexity an API key from the Perplexity console (Decisions API) pplx-decider-v1.1-27b PERPLEXITY_API_KEY (or PERPLEXITYAI_API_KEY)
openai an OpenAI API key (Decisions API, public beta since October 2026) gpt-6-luna OPENAI_API_KEY
custom base_url your own server or gateway; the URL may end with /v1/systemone or /v1/decisions, or with neither the server's default none (pass api_key= if needed)

For a custom server that speaks OpenAI's Decisions format (e.g., another region or a gateway), pass protocol="openai" with base_url.

Other options: timeout (default 60 s), max_retries (default 3; retries 429, 529, 5xx and timeouts with exponential backoff, honoring Retry-After), and requests_per_minute. Requests are paced below each service's limit by default: 1,000 per minute for TypeSafe and OpenRouter (Jev allows 1,200) and 500 for Perplexity (10 per second). OpenAI allows 30,000 per minute, so OpenAI and custom URLs are not paced. Pass requests_per_minute=0 to turn pacing off.

On OpenRouter, the cost comes from the response; elsewhere it is computed from the reported input tokens and the list price.

Other decision models

Newer hosted decision models that speak /v1/systemone work without a named backend:

# Microsoft-Decision-1 through OpenRouter (tested with the live conformance suite in 1.4.1)
provider = SystemOneProvider(backend="openrouter", model="microsoft/microsoft-decision-1")

# Celeris-1 Decision and Liquid AI's d1-ultra through their own APIs (not yet tested with LatteReview)
provider = SystemOneProvider(base_url="https://inference.celeris.ai/celeris-1-decision", model="celeris-1-decision",
                             api_key="...", input_price_per_million=0.04)
provider = SystemOneProvider(base_url="https://api.liquid.ai/decisions", model="d1-ultra",
                             api_key="...", input_price_per_million=0.042)

Pass input_price_per_million with a custom base_url so the cost is tracked; otherwise it is reported as 0. Azure AI Foundry takes its API keys for Microsoft-Decision-1 in an api-key header, which SystemOneProvider does not send, so use the model through OpenRouter.

Screening titles and abstracts

from lattereview.agents import DecisionTitleAbstractReviewer

reviewer = DecisionTitleAbstractReviewer(
    provider=SystemOneProvider(),
    name="Jev",
    inclusion_criteria={1: "The study must involve CT scans.", 2: "The study must use deep learning."},
    exclusion_criteria={1: "The study must not include PET scans."},
)

Criteria can be a string (one criterion), a list, or a numbered dict. For each article, the reviewer sends one request with a 5-level evaluation score (mirroring the 1-5 rubric of TitleAbstractReviewer), an overall include question, and one yes/no question per criterion. It returns these columns:

Column Meaning
evaluation int 1-5, the most likely rubric level; existing filters such as evaluation >= 4 keep working
include_probability the probability that the article meets all inclusion and no exclusion criteria
confidence Jev's confidence in the evaluation score (None if the backend does not report one)
criteria {"inclusion": {criterion: p(met)}, "exclusion": {criterion: p(applies)}}
reasoning a sentence generated by LatteReview from the probabilities, e.g., P(include)=0.03; rated 'absolutely exclude'. Likely fails inclusion 1 (p=0.02: 'The study must involve CT scans.').

The raw normalized answers (per-level probabilities, confidence) are kept under _answers in the output column.

Scoring and extraction

DecisionScoringReviewer is the counterpart of ScoringReviewer. score_descriptions become the answer levels, and certainty is the confidence x 100, on the same 0-100 scale:

from lattereview.agents import DecisionScoringReviewer

reviewer = DecisionScoringReviewer(
    provider=SystemOneProvider(),
    name="Validation",
    scoring_task="How strong is the validation of the model in this study?",
    scoring_set=[1, 2, 3],
    score_descriptions={1: "no validation", 2: "internal validation only", 3: "external validation"},
)
# Columns: score (a value from scoring_set), certainty (0-100), probabilities ({score: p})

For your own questions, use DecisionReviewer. Each question becomes a column: the chosen option for Choice, the probability of "yes" for Noul, and the expected 0-based level for Score:

from lattereview.agents import DecisionReviewer
from lattereview.providers import Noul, Choice, Score

reviewer = DecisionReviewer(
    provider=SystemOneProvider(),
    name="Extractor",
    questions={
        "modality": Choice("What is the main imaging modality?", ["CT", "MRI", "X-ray", "ultrasound"]),
        "deep_learning": Noul("Does the study use deep learning?"),
        "quality": Score("How rigorous is the evaluation?", ["weak", "moderate", "strong"]),
    },
)

This covers categorical extraction. Free-text extraction (e.g., "list the reported complications") still needs an LLM AbstractionReviewer.

You can also call the provider directly:

result = await provider.decide(state="Title: ...\nAbstract: ...", questions={"rct": Noul("Is this an RCT?")})
result.answers["rct"].value   # probability of yes
result.cost, result.input_tokens

Cost model

The three services bill a request's questions differently. Input tokens for the same abstract (about 750 tokens) with 1, 2, 4 and 8 short yes/no questions:

1 question 2 4 8 Billing
Jev 1,134 1,150 1,182 1,246 the state once per request; about 16 tokens per extra question
gpt-6-luna 902 1,055 1,361 1,973 the state once per request; about 150 tokens per extra question
pplx-decider 926 1,852 3,704 7,408 the state once per question (the same directly and through OpenRouter)

So:

  • Ask all questions about an item in one request. The decision reviewers always do.
  • On Jev, one question per criterion is nearly free. On gpt-6-luna each extra question adds a small fixed cost, and on pplx-decider each one costs as much as the first. pplx-decider's low price still made it the cheapest model in our evaluation, but a reviewer with many criteria costs proportionally more.
  • Long criteria repeated in several questions cost more than the abstract itself; keep criteria concise.

Refusals

OpenAI's Decisions API may decline to answer a single question. In our evaluation, 4 of about 50,000 questions were declined, all on ordinary medical abstracts (e.g., a spine-surgery study). LatteReview does not fail or retry the item:

  • The refused answer has refused=True and value=None (in _answers), and the matching output column is None. For DecisionTitleAbstractReviewer, the generated reasoning names the unanswered questions.
  • The other answers of the same request are kept.
  • A warning is printed the first time a reviewer gets a refusal.

Send such items on to an LLM or a human. A None value fails comparisons such as 0.1 <= p < 0.9, so check for it explicitly in workflow filters, as in the hybrid example below.

Confidence-gated hybrid workflows

Jev is cheap enough to screen everything, and an LLM can then review only the articles Jev is unsure about. Filters are plain row functions, so no special workflow is needed:

import pandas as pd
from lattereview.providers import SystemOneProvider, OpenAIProvider
from lattereview.agents import DecisionTitleAbstractReviewer, TitleAbstractReviewer
from lattereview.workflows import ReviewWorkflow

jev = DecisionTitleAbstractReviewer(provider=SystemOneProvider(), name="Jev",
                                    inclusion_criteria=inclusion, exclusion_criteria=exclusion)
llm = TitleAbstractReviewer(provider=OpenAIProvider(model="gpt-6-luna"), name="LLM",
                            inclusion_criteria=inclusion_text, exclusion_criteria=exclusion_text)

workflow = ReviewWorkflow(workflow_schema=[
    {"round": "A", "reviewers": [jev], "text_inputs": ["title", "abstract"]},
    {"round": "B", "reviewers": [llm], "text_inputs": ["title", "abstract"],
     "filter": lambda row: pd.isna(p := row["round-A_Jev_include_probability"]) or 0.1 <= p < 0.9},
])

The pd.isna check also routes articles whose include question was refused (see Refusals).

Jev's probabilities are mostly close to 0 or 1, so a band of [0.1, 0.9) routed about 20% of articles to the LLM in our evaluation (5% to 43% depending on the dataset). Replacing Jev's decision with the LLM's inside the band gave almost the same recall and precision as reviewing everything with the LLM, for about one fifth of the LLM calls. The LLM is most useful on criteria that need an extra reasoning step, such as telling a segmentation study from a classifier study.

See the hybrid tutorial for a complete pipeline with measurements.

Choosing a threshold

A probability cutoff fitted on one dataset (or one backend) does not carry over to another. With a labeled sample of your own data, suggest_threshold finds the highest cutoff that still reaches a target recall:

from lattereview.utils import suggest_threshold

suggest_threshold(labeled_df, "round-A_Jev_include_probability", "label", target_recall=0.95)
# {'threshold': 0.08, 'recall': 0.96, 'precision': 0.41, 'n_included': 212, 'wss': 0.52}

Articles close to the cutoff can land on either side of it when re-run, because uncertain answers vary slightly between identical requests.

wss is the work saved over sampling: the fraction of articles you would not need to read at that cutoff, minus the recall given up.

Evaluation

We ran DecisionTitleAbstractReviewer (Jev 1.13) on all 11,793 articles of LatteReview's v1 evaluation: three custom searches over 978 radiology AI articles and six SYNERGY systematic-review datasets. The criteria were passed exactly as the v1 LLM reviewers got them. The comparison is the stored v1 LLM decision (gemini-2.5-flash and gpt-4o-mini, with gpt-4o resolving disagreements). The full run took 14 minutes and cost $0.71. The notebook and per-article results are in evaluation/decision_evaluation.ipynb.

Dataset Articles Included Jev AUC LLM AUC Jev WSS@95 LLM WSS@95 Jev recall at p ≥ 0.5 LLM recall at ≥ 3
search1 978 697 0.957 0.937 0.25 0.00 0.92 0.92
search2 978 367 0.875 0.818 0.23 0.25 0.85 0.84
search3 978 55 0.865 0.789 0.32 0.08 0.56 0.78
Appenzeller-Herzog 2019 2,873 26 0.899 0.847 0.64 0.00 0.08 0.31
Donners 2021 258 15 0.923 0.772 0.66 0.07 0.67 0.73
Jeyaraman 2020 1,175 96 0.795 0.708 0.00 0.00 0.00 0.02
Meijboom 2021 882 37 0.941 0.902 0.76 0.54 0.14 0.84
Muthu 2021 2,719 336 0.735 0.728 0.17 0.27 0.08 0.24
Oud 2018 952 20 0.969 0.955 0.87 0.80 0.70 0.80
Mean 0.884 0.828 0.43 0.22 0.44 0.61

AUC measures how well a score ranks included articles above excluded ones. WSS@95 is the work saved over sampling at 95% recall: the fraction of articles a reviewer would not need to read at the cutoff that still finds 95% of the included ones, minus the 5% recall given up.

What this shows:

  • Jev ranks articles better than the v1 LLM reviewers on every dataset (Muthu is a tie), and saves about twice as much screening work at 95% recall.
  • A cutoff of 0.5 is too strict for long, multi-part criteria. On the SYNERGY datasets, whose criteria are paragraphs with many conditions, Jev's include_probability rarely exceeds 0.5 for included articles. The cutoffs that reach 95% recall ranged from 0.01 to 0.23. Rank by the probability, or fit a cutoff with suggest_threshold on labeled data, instead of using 0.5. Splitting long criteria into separate items (a list or numbered dict) also gives you one probability per criterion.
  • Use the probabilities, not the evaluation integer, for ranking. The 1-5 evaluation column is kept for compatibility with LLM filters, but it is coarse (mean AUC 0.79).
  • Other signals rank about as well as include_probability: the expected evaluation score (mean AUC 0.889) and the product of the per-criterion probabilities (0.894, best on 4 of 9 datasets). None was consistently best, so include_probability is the recommended signal.
  • The labels of the custom searches come from structured metadata and contain some noise: several articles labeled "CT" are PET/CT studies that both Jev and the LLMs excluded.

Comparing Jev, pplx-decider and gpt-6-luna

For v1.4.0 we ran the same DecisionTitleAbstractReviewer, with the same criteria and questions, on all 11,793 articles with each model: Jev through OpenRouter, pplx-decider through OpenRouter (served by Perplexity), and gpt-6-luna through OpenAI's Decisions API. The notebook and per-article results are in evaluation/decision_model_comparison.ipynb. Jev's numbers differ slightly from the table above because v1.4.0 leaves the workflow's task ID out of the model's input and because Jev's uncertain answers vary between runs.

AUC of include_probability (the LLM column is the stored v1 LLM decision):

Dataset Jev pplx-decider gpt-6-luna LLM
search1 0.960 0.951 0.946 0.937
search2 0.874 0.886 0.886 0.818
search3 0.863 0.864 0.832 0.789
Appenzeller-Herzog 2019 0.888 0.947 0.582 0.847
Donners 2021 0.915 0.965 0.857 0.772
Jeyaraman 2020 0.762 0.777 0.500 0.708
Meijboom 2021 0.939 0.945 0.881 0.902
Muthu 2021 0.735 0.752 0.579 0.728
Oud 2018 0.968 0.970 0.952 0.955
Mean 0.878 0.895 0.779 0.828
Jev pplx-decider gpt-6-luna LLM
Mean WSS@95 0.38 0.51 0.12 0.22
Mean AUC of expected_evaluation / criteria_product 0.887 / 0.892 0.903 / 0.914 0.872 / 0.805
Mean recall / precision at p ≥ 0.5 (LLM: score ≥ 3) 0.43 / 0.54 0.63 / 0.42 0.43 / 0.58 0.61 / 0.37
Articles with 0.1 ≤ p < 0.9 16% 12% 4%
Hybrid band [0.1, 0.9) + LLM: routed / recall / precision 19% / 0.59 / 0.37 13% / 0.62 / 0.45 6% / 0.49 / 0.47 100% / 0.61 / 0.37
Cost of the full run (11,793 articles) $0.70 $0.61 $1.87
Run time 12 min (paced) 13 min (paced) 3 min (16 concurrent, not paced)
Refused questions 0 0 4

What this shows:

  • pplx-decider was the best decision model: the highest AUC on 7 of 9 datasets and the most screening work saved at 95% recall, at the lowest cost. Its probabilities are less extreme than Jev's, so a 0.5 cutoff kept more included articles (mean recall 0.63 vs 0.43), and its hybrid pipeline matched the recall of reviewing everything with the LLM (0.62 vs 0.61) with better precision (0.45 vs 0.37), while sending only 13% of articles to the LLM.
  • gpt-6-luna's Decisions API was the weakest on the SYNERGY datasets, whose criteria are long paragraphs with many conditions. It returned exactly 0.00 for nearly every article there, included ones too (OpenAI rounds probabilities to two decimals), which leaves nothing to rank by: AUC 0.50 on Jeyaraman and 0.58 on Appenzeller-Herzog and Muthu. Its expected evaluation score ranked much better (mean AUC 0.872), so if you use gpt-6-luna, rank by output["_answers"]["evaluation"]["value"] rather than include_probability, and prefer short, single-condition criteria. It was also the most expensive of the three.
  • The three models agree on most decisions (94-98% at p ≥ 0.5), but they rank articles differently (rank correlation 0.93 between Jev and pplx-decider, 0.65-0.68 with gpt-6-luna), and their fitted cutoffs differ a lot. Fit a cutoff per model: one fitted for Jev does not carry over to another model.

Self-hosting: OpenJev and Kev

Open models that serve the same /v1/systemone API can run on your own hardware. Point base_url at them:

  • OpenJev 27B (openjev), an independent open model. The weights are licensed CC BY-NC 4.0: research and non-commercial use only. The Apache-2.0 server openjev-server runs it with vLLM or, on Apple silicon, MLX. On a 32 GB Mac, use the 4-bit MLX build (about 15 GB):

    pip install "openjev-server[mlx] @ git+https://github.com/abhishekgahlot2/openjev-server"
    hf download openjev/openjev-MLX-4bit --local-dir openjev-MLX-4bit
    openjev serve --backend mlx --model openjev-MLX-4bit --profile openjev --port 3000
    
    provider = SystemOneProvider(base_url="http://localhost:3000")
    
  • Kev (jaredpalmer/kev, Apache-2.0): Qwen base models with a LoRA adapter, from 0.8B to 27B parameters. python -m kev.serve --run jaredpalmer/kev-4b --port 8009, then SystemOneProvider(base_url="http://127.0.0.1:8009", api_key="local").

  • openjev-sglang serves any SGLang model by reading first-token probabilities, which its authors describe as not calibrated.
  • pplx-decider: Perplexity publishes the weights under Apache-2.0 on Hugging Face. We have not tested a self-hosted server; any server that speaks the same protocol works through base_url.

Known differences between backends

A compatible API is not an equivalent model. Before relying on another backend, check it on labeled data.

We ran the same 200 articles (100 each from custom searches 2 and 3, stratified by label) through three backends with DecisionTitleAbstractReviewer:

Jev 1.13 (TypeSafe) OpenJev 27B, 4-bit MLX (openjev-server 0.2.0) Kev-4B (bf16, MLX)
AUC, search 2 / search 3 0.873 / 0.838 0.872 / 0.826 0.854 / 0.814
Same include decision as Jev (p ≥ 0.5) - 98% / 92% 77% / 51%
Articles with 0.05 < p < 0.95 51% / 66% 34% / 64% 91% / 99%
Median confidence (evaluation score) 0.93 / 0.77 0.93 / 0.68 0.36 / 0.50
Time per article (6-8 questions) about 0.2 s; about 16 articles/s at the default pace 9.5-13.5 s 1.2-2.2 s
Cost and license $0.042 per million input tokens free on your hardware; weights CC BY-NC 4.0 free on your hardware; Apache-2.0

The local models ran on an Apple M5 with 32 GB of memory.

  • OpenJev ranks and decides almost like Jev, but on a laptop it is about 50 times slower. A short one-question request took about 0.9 s, and each question with an abstract-length state added about 0.7 s.
  • Kev-4B ranks almost as well, but its probabilities are much less extreme and its confidence values are lower. A cutoff or an uncertain band tuned on Jev would route almost every article to the LLM with Kev. Fit thresholds per backend.
  • Answers are not fully deterministic on any backend we tried. Confident answers (probabilities near 0 or 1) repeat, but uncertain ones vary between identical requests: six identical requests for one borderline article returned an include probability of 0.62-0.84 on TypeSafe and 0.60-0.88 on OpenRouter. Save your results instead of re-running, and expect articles near a cutoff to land on either side of it.
  • Response details differ. OpenRouter reports the cost in the response. OpenJev returns probabilities with four decimals and counts tokens differently (its prompt template makes requests look 2-4 times larger). TypeSafe requires a model; local servers use their own default when it is omitted. Missing fields (a missing confidence, legend or choice) are handled by SystemOneProvider, so reviewers see the same answer format everywhere.
  • Jev limits a choice question to 255 options; OpenJev evaluates up to 52 options in one pass. Validation errors from the server are shown in the error message.

The v1.4.0 backends:

  • pplx-decider and gpt-6-luna gave identical answers to identical requests in our tests (six repeats each on borderline articles). But all three models change their answers on borderline articles when unrelated text in the input changes. For one article, gpt-6-luna returned an include probability of 0.05 without the workflow's Review Task ID line, and 0.50 or 0.65 with two different IDs. Since v1.4.0 the decision reviewers leave that line out, so an article gets the same input, and with these two models the same answer, whatever its row or round.
  • OpenAI rounds probabilities to two decimals, so many articles tie (gpt-6-luna returned 99 distinct include_probability values over 11,793 articles, pplx-decider 11,772). Ties make ranking and fitted cutoffs coarse.
  • OpenAI's yes/no questions have no answer descriptions. LatteReview adds a Noul's true/false descriptions to the instructions ("Answer true if: ..."). Testing on two datasets showed no difference either way.
  • OpenAI may refuse single questions (see Refusals); Jev and Perplexity have not refused any in our tests.
  • Perplexity accepts up to 128 questions per request and inputs up to 262,144 tokens. Its responses report tokens but not cost, so LatteReview prices them at $0.02 per million input tokens. Through OpenRouter, the cost is reported.