Verdicts API: Typed Answers About Your Data
Level: Intermediate · Time: 15 min · Category: API reference
Tags: LLM API · Classification · Labeling · Vision
POST https://openai.rc.asu.edu/v1/verdicts asks a model typed questions about a piece of content and
returns typed answers. You send a state (text, a JSON record, or images) and a set of
questions. Each question comes back as a boolean, one option from a list, or a level on a scale,
together with a probability distribution and a confidence score.
The answer is read from the model's first-token probabilities over a fixed set of labels, so there is no prose to parse and the answer cannot come back malformed. Use it wherever you would otherwise prompt a chat model and parse its reply:
- Screening and labeling: abstracts, survey responses, transcripts, or a dataset you need labeled in bulk.
- Rubric scoring: grade answers, essays, or generated text against levels you define.
- Triage and routing: decide whether something is urgent and which queue it belongs in.
- Record matching: decide whether two records describe the same person, sample, or paper.
- Image checks: check that a figure has labeled axes, or that a micrograph or photo shows what you expect.
- Gating an agent: approve or block a tool call by thresholding a probability.
- An API key from the Voyager portal (see
LLM API Access). Keys marked Legacy in Voyager go through the older gateway, which
does not have this endpoint and returns
{"detail":"Not Found"}. Rotate a legacy key on the AI LLM tab to move it to the current gateway.
You do not have to pick a model. A request without model is answered by gemma4-31b-it, which
handles both text and images. See Choose a model to use another one.
Quick example
The examples on this page read your key from OPENAI_API_KEY, as set up in
Use Python.
- curl
- Python
- JavaScript
curl -s https://openai.rc.asu.edu/v1/verdicts \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"state": {"ticket": "My job 4821337 on Sol has been pending for two days and my thesis defense is Friday. squeue says the reason is QOSMaxGRESPerUser."},
"questions": {
"is_urgent": {
"type": "boolean",
"instructions": "Does this need a reply today?",
"criteria": {"true": "A deadline within a week, data loss, or a security issue", "false": "Can wait for the normal queue"}
},
"team": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {"scheduler": "Slurm jobs, queues, QOS, partitions", "storage": "Quotas, scratch, file recovery", "accounts": "Access, sponsors, groups", "software": "Modules, builds, containers"}
},
"frustration": {
"type": "score",
"instructions": "How frustrated does the user sound?",
"criteria": ["Calm and factual", "Mildly annoyed", "Frustrated", "Very angry"]
}
}
}'
The openai SDK has no method for this endpoint, so use requests. Install it with
mamba install -c conda-forge requests. This helper is reused in the examples further down:
import os
import requests
BASE_URL = os.environ.get("OPENAI_BASE_URL", "https://openai.rc.asu.edu/v1")
def verdicts(state, questions, model=None, images=None):
body = {"state": state, "questions": questions}
if model:
body["model"] = model # leave it out to use the default, gemma4-31b-it
if images:
body["images"] = images
r = requests.post(
f"{BASE_URL}/verdicts",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
json=body,
timeout=300,
)
r.raise_for_status()
return r.json()["verdicts"]
from verdicts_helper import verdicts
v = verdicts(
{"ticket": "My job 4821337 on Sol has been pending for two days and my thesis defense is "
"Friday. squeue says the reason is QOSMaxGRESPerUser."},
{
"is_urgent": {"type": "boolean", "instructions": "Does this need a reply today?"},
"team": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {"scheduler": "Slurm jobs, queues, QOS, partitions",
"storage": "Quotas, scratch, file recovery",
"accounts": "Access, sponsors, groups",
"software": "Modules, builds, containers"},
},
},
)
print(v["is_urgent"]["value"], v["team"]["value"])
Node.js 18 or later has fetch built in:
const response = await fetch("https://openai.rc.asu.edu/v1/verdicts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
state: { error_log: "slurmstepd: error: Detected 1 oom_kill event in StepId=4821337.batch." },
questions: {
cause: {
type: "choice",
instructions: "What most likely ended this job?",
criteria: {
memory: "Ran out of memory",
time: "Hit its time limit",
code: "An error in the user's program",
other: "Anything else",
},
},
},
}),
});
if (!response.ok) throw new Error(`verdicts ${response.status}: ${await response.text()}`);
const { verdicts } = await response.json();
console.log(verdicts.cause.value, verdicts.cause.probabilities[verdicts.cause.value]);
The curl request returns (numbers rounded):
{
"model": "gemma4-31b-it",
"verdicts": {
"frustration": {
"type": "score",
"value": 3,
"probabilities": [0.00002, 0.4073, 0.5927, 0.000001],
"confidence": 0.51,
"expected_value": 2.59
},
"is_urgent": {
"type": "boolean",
"value": true,
"probabilities": {"false": 0.0, "true": 1.0},
"confidence": 1.0
},
"team": {
"type": "choice",
"value": "scheduler",
"probabilities": {"accounts": 0.0, "scheduler": 1.0, "software": 0.0, "storage": 0.0},
"confidence": 1.0
}
},
"usage": {"prompt_tokens": 477, "completion_tokens": 3, "total_tokens": 480}
}
Notice that frustration is split between "Mildly annoyed" and "Frustrated". Its confidence of 0.51
tells you the model was unsure, which a single text answer would hide.
Request
| Field | Type | Required | Description |
|---|---|---|---|
model | string | no | A chat model ID from /v1/models, a model family, or "auto". Defaults to gemma4-31b-it. See Choose a model. |
state | string, object, or array | yes, unless images is given | The content to judge. Strings are used as written; objects and arrays are rendered as indented JSON. |
images | array of strings | no | Up to 8 images. See Images. |
questions | object | yes | 1–32 questions, keyed by an ID you choose (up to 64 bytes). The response uses the same IDs. |
Each question has a type, instructions (the question itself, as a string), and criteria:
type | criteria | value in the response | probabilities in the response |
|---|---|---|---|
boolean | Optional. {"true": "...", "false": "..."} says what each answer means. | true or false | {"false": p, "true": p} |
choice | Required. An object mapping each option name to a description (or null). 2–26 options. | The option name | {"option": p, ...} |
score | Required. An array of level descriptions, lowest first. 2–10 levels. | The level, counting from 1 | [p1, p2, ...], plus expected_value |
Each question is answered independently and cannot see the other answers. Unknown fields are
ignored. The gateway always uses greedy decoding and generates a single token, so temperature,
max_tokens, and stream have no effect. Responses are never streamed.
Response
| Field | Description |
|---|---|
model | The model that answered. For a family or "auto", this tells you which model was chosen. |
verdicts.<id>.type | The question's type. |
verdicts.<id>.value | The most probable answer. |
verdicts.<id>.probabilities | The distribution over every answer, summing to 1. An answer outside the model's top 20 first tokens gets exactly 0.0. |
verdicts.<id>.confidence | 0 to 1. It is 0 for a uniform distribution and 1 when the model is decisive and all of its probability landed on valid answers. |
verdicts.<id>.expected_value | Score questions only: the probability-weighted level, such as 2.59. Useful for ranking. |
usage | Tokens summed over every question. |
The request ID is returned in the x-obleth-request-id response header. Include it when you
ask for help.
Base decisions on both numbers: threshold on the probability you care about, and send answers with
low confidence to a person instead of acting on them.
Examples
These use the verdicts() helper from the Python tab above.
Screen abstracts
Ask every question about one item in a single request. The model processes the state once and answers all of the questions from it. Send several items in parallel, a few at a time:
from concurrent.futures import ThreadPoolExecutor
from verdicts_helper import verdicts
SCREEN = {
"relevant": {
"type": "boolean",
"instructions": "Does the study apply machine learning to genomic or transcriptomic data?",
"criteria": {
"true": "ML methods are applied to DNA, RNA, or gene-expression data",
"false": "No ML, or the data is not genomic",
},
},
"study_type": {
"type": "choice",
"instructions": "What kind of study is this?",
"criteria": {
"method": "Introduces a new method or model",
"application": "Applies existing methods to answer a biological question",
"review": "Surveys or reviews prior work",
"other": "Anything else",
},
},
"rigor": {
"type": "score",
"instructions": "How well does the abstract describe the evaluation?",
"criteria": [
"No evaluation described",
"Evaluation mentioned without detail",
"Datasets or metrics named",
"Datasets, metrics, and baselines or held-out validation named",
],
},
}
abstracts = [
"We present a transformer model that predicts enhancer activity from DNA sequence. Trained on "
"STARR-seq data from five cell lines, it outperforms CNN baselines (auROC 0.91 vs 0.85) on "
"held-out chromosomes.",
"We surveyed 412 graduate students about their sleep habits and found that students who slept "
"fewer than six hours reported lower satisfaction with their programs.",
"This review summarizes deep learning approaches for single-cell RNA-seq analysis, including "
"clustering, batch correction, and trajectory inference.",
]
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda text: verdicts({"abstract": text}, SCREEN), abstracts))
for text, v in zip(abstracts, results):
p = v["relevant"]["probabilities"]["true"]
print(f"{p:.2f} {v['study_type']['value']:<12} rigor {v['rigor']['value']} {text[:50]}")
1.00 method rigor 4 We present a transformer model that predicts enhan
0.00 other rigor 3 We surveyed 412 graduate students about their slee
1.00 review rigor 1 This review summarizes deep learning approaches fo
Check figures before you submit
Send images as base64 data URLs. When you send images, state is optional; here it gives context:
import base64
from pathlib import Path
from verdicts_helper import verdicts
def data_url(path):
return "data:image/png;base64," + base64.b64encode(Path(path).read_bytes()).decode()
FIGURE_CHECK = {
"axis_labels": {
"type": "boolean",
"instructions": "Do both axes have text labels that say what is plotted? Tick numbers alone do not count.",
},
"legend": {
"type": "boolean",
"instructions": "If the plot has more than one series, is there a legend that names each one?",
"criteria": {"true": "One series, or a legend names every series", "false": "Several series and no legend"},
},
"plot_type": {
"type": "choice",
"instructions": "What kind of plot is this?",
"criteria": {"line": None, "scatter": None, "bar": None, "histogram": None, "other": "Anything else"},
},
}
for path in ["figure-good.png", "figure-bare.png"]:
v = verdicts("A figure from a manuscript draft.", FIGURE_CHECK, images=[data_url(path)])
print(path,
"labels", round(v["axis_labels"]["probabilities"]["true"], 2),
"legend", round(v["legend"]["probabilities"]["true"], 2),
"type", v["plot_type"]["value"])
With one line plot that has axis labels and a legend, and the same plot with neither:
figure-good.png labels 1.0 legend 1.0 type line
figure-bare.png labels 0.0 legend 0.0 type line
Set thresholds from labeled examples
Before you automate a decision, run the question over a few hundred items whose answers you already know, and pick the threshold that gives the precision and recall you need:
from verdicts_helper import verdicts
labeled = [
("My scratch directory vanished and my results for tomorrow's deadline were in it.", True),
("Could you install the latest version of R on Phoenix?", False),
# ... a few hundred more
]
URGENT = {"is_urgent": {"type": "boolean", "instructions": "Does this need a reply today?"}}
results = [
(verdicts(text, URGENT)["is_urgent"]["probabilities"]["true"], label)
for text, label in labeled
]
positives = sum(label for _, label in results)
for t in (0.5, 0.7, 0.8, 0.9, 0.95, 0.99):
flagged = [label for p, label in results if p >= t]
precision = sum(flagged) / len(flagged) if flagged else 1.0
recall = sum(flagged) / positives
print(f"{t:.2f} precision {precision:.2f} recall {recall:.2f} flagged {len(flagged)}")
Calibrate again whenever you change the model or the wording of a question.
Images
-
Format: each entry in
imagesis a base64 data URL (data:image/png;base64,...) or a publichttps://URL. The model server downloadshttps://URLs itself, and some sites refuse automated downloads (the error containsFailed to fetch media from URL). Data URLs always work. -
Limits: up to 8 images and 20 MiB of image data per request. Images are sent with every question, so image size × number of questions must stay under 64 MiB. Ask fewer questions per request, or send smaller images, if you hit that limit.
-
Models: the model must accept images. List the vision models with:
curl -s https://openai.rc.asu.edu/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" \| jq -r '.data[] | select(.supports_vision) | .id'The default,
gemma4-31b-it, accepts images. A model without vision returns400withdoes not accept images; use a model with vision. With"model": "auto", only vision models are considered.
Choose a model
Leave model out and gemma4-31b-it answers. It is an instruction-tuned model that answers
decisively and accepts images, and it is the model behind every example on this page. To use another
model, set model in the request.
Reasoning (thinking) models are poor at verdicts. A verdict is read from the model's first token,
and a reasoning model spends its first token starting to think. The gateway retries such a question
once with thinking skipped, which doubles its token use, and the answers usually still come back with
confidence near 0. In testing, qwen38-27b picked the same labels as gemma4-31b-it on the quick
example, with confidence of 0.01 or less on all three questions.
-
Other instruct models that work well include
granite41-30b,olmo3-32b-instruct, andqwen3-235b-a22b-instruct-2507. -
Model families such as
olmo3-32bandqwen3-235b-a22b(entries in/v1/modelswith afamilyfield) pick a member by reasoning effort in chat. Verdicts always answers a family name with its instruct member, the one listed under"none", and the response'smodelfield names it. To see a family's members:curl -s https://openai.rc.asu.edu/v1/models/olmo3-32b -H "Authorization: Bearer $OPENAI_API_KEY" | jq .family -
"auto"picks a model for you, filtered to vision models when you send images. It can pick a reasoning model, and its choice can change over time. If you use fixed thresholds, stay on the default or name a model. -
Calibrate per model. Probabilities differ between models, so recalibrate your thresholds whenever you change
model.
Write good questions
- Ask about what the state shows, not a judgment the model has to invent.
- Ask one thing per question. Split "is it relevant and well-designed?" into two questions.
- Give choices distinct meanings, and add an
otheroption so the model is not forced into a bad fit. - List score levels lowest first, with each level differing from its neighbors in one clear way.
- Put definitions in
criteriaand keepinstructionsto the question itself. - Name options with the values your code uses, so you can switch on
valuedirectly. - Ask related questions about one item in one request. If a question depends on another's answer, send two requests: the second only when the first says it applies.
Usage, limits, and errors
Each question is one model call that generates one token. The calls run concurrently and share the
state (and images) as a common prefix, which the model server caches. A request with many questions
costs little more time than a request with one. usage.prompt_tokens counts the state once per
question. Requests count against your key's fair share like chat requests.
| Limit | Value |
|---|---|
| Questions per request | 32 |
Options per choice question | 2–26, each name up to 200 bytes |
Levels per score question | 2–10 |
| Rendered state | 2 MiB |
| Images | 8 per request, 20 MiB in total; image size × questions ≤ 64 MiB |
| Request body | 64 MiB |
If any question fails, the whole request fails with 502. There are no partial results. Errors use
the form {"error": {"message": "...", "type": "obleth_gateway_error"}}.
| Status | Example message | Fix |
|---|---|---|
401 | invalid api key | Check the key, or create a new one in Voyager. |
404 | {"detail":"Not Found"} | You are using a Legacy key. Rotate it on the AI LLM tab in Voyager. |
404 | model 'x' is not registered | Check the model ID against /v1/models, or leave model out to use the default. |
400 | invalid request: unknown variant `number`, expected one of `boolean`, `choice`, `score` | Use one of the three question types. |
400 | `state` must be a string, object, or array, and may be left out only when `images` are given | Add a state, or send images. |
400 | too many questions: 40 (maximum 32) | Split the questions across requests. |
400 | model 'x' does not accept images; use a model with vision | Leave model out to use the default, or pick a vision model. |
413 | rendered state is too large | Shorten the state; the limit is 2 MiB. |
429 / 503 | token budget exceeded / timed out waiting for model capacity | Wait and retry with backoff, or send fewer requests at once. |
502 | question 'q' on model 'm': backend returned no logprobs (does it support `logprobs`?) | The model's server cannot return token probabilities. Use another model. |
502 | question 'q' on model 'm': model produced no recognizable answer label (is this a reasoning/thinking model?) | Use an instruction-tuned model. |
502 | question 'q' on model 'm': upstream returned 422 ... Failed to fetch media from URL | The model server could not download an image URL. Send the image as a data URL. |
Related guides
- LLM API Access: keys, models, and chat completions.
- Connect AI Agents to Your RC Account: let an agent read your jobs, storage, and allocations through Voyager.
- Getting Started with AI: set up an AI coding assistant on the RC models.