How to Use the OpenAI Decisions API: First Request, Predicate/Choice/Score, Thresholds and Caching
If your app or agent spends a full LLM call on small decisions like “which queue does this ticket go to?” or “is this photo damaged?”, OpenAI’s Decisions API is now a generally available, much cheaper way to get that answer as a typed result. You send a question plus a fixed set of allowed answers to POST /v1/decisions, and you get back a probability, one of your choices, or a score on your rubric, with no generated text to parse. OpenAI made it generally available on October 22, 2026, built on gpt-6-luna, and says it answers “up to 10x faster” than the same model through the Responses API. Input costs $0.10 per million tokens and output tokens are not charged.
This guide walks through a first request with curl and Python, the three question types, asking many questions in one call, passing images and JSON, setting thresholds, using prompt caching, and tracking usage. Every request shape, limit and price below comes from OpenAI’s own material: the GA announcement, the Decisions developer guide and the create-decision API reference. Background on the launch is from TechCrunch’s report.
What the Decisions API is, and when to use it
A normal chat or Responses call generates text. If all you need is “billing or technical?”, you then have to parse that text, handle the model wandering off format, and pay for output tokens. The Decisions API skips that. You define the question and the allowed answers; the API returns a structured answers array.
There are three question types, straight from the developer guide:
| Type | Use it to | Main result |
|---|---|---|
predicate | Check a condition, such as visible damage or passage relevance | probability: an estimate from 0 to 1 that the condition is true |
choice | Select one option, such as a department or content category | choice: one of your supplied values, plus probabilities and confidence |
score | Rate an input against ordered levels, such as issue severity | score: the probability-weighted average of the level indices, plus probabilities and confidence |
OpenAI’s own guidance on when not to use it: if you need the model to generate an object in your own JSON schema (extracted fields, a written explanation), use Structured Outputs with the Responses API. If you need the model to request a tool call with arguments, use function calling. Decisions is for bounded answers only.
Typical jobs from the announcement and the guide: routing support tickets, classifying transactions, flagging product photos for damage, choosing an agent’s next step (continue, retry or ask for clarification), and scoring risk before running an agent-generated command. Datadog, for example, told OpenAI it uses Decisions to score the risk of agent-generated shell commands before executing them, and Sierra said it runs at “under 280 ms p95” for choosing next actions in live customer conversations. Those are customer quotes in OpenAI’s post, not independent benchmarks, so measure on your own workload.
How it compares with Jev and other decision models
The Decisions API arrives in a category that TypeSafe AI’s Jev model opened up. We covered Jev’s fast adoption on Vercel AI Gateway last week. According to TechCrunch, OpenAI did not train a separate decision model: its API team “took ChatGPT Luna and reconfigured it to speed up the inference and lower the cost.” OpenAI’s API product lead Nikunj Handa told TechCrunch the speed went from “one to three seconds to 100, 120, 180 milliseconds.”
TechCrunch also notes that on public decision-model leaderboards OpenAI’s entry is not on top: it ranked eighth on the AIM System One Index and fourteenth on the Artificial Analysis Decision Index, behind models such as Liquid’s D1 Ultra and Base Lab’s Elephant-1, with Jev seventh and third. OpenAI’s pitch is image understanding and calibration on Luna. The practical takeaway is the one TypeSafe’s CEO gave TechCrunch: trial the models on your own data rather than trusting a leaderboard.
Before you start
You need:
- An OpenAI API key in
OPENAI_API_KEY. If you have not set one up on the command line yet, our guide to installing and using the official OpenAI CLI covers keys and environment setup. curl, or a current OpenAI SDK. The Decisions resource is in the SDKs asclient.decisions.create(...)(Python and JavaScript),client.Decisions.New(...)(Go),client.decisions().create(...)(Java) andclient.decisions.create(...)(Ruby). We checked that the latest releases on the package registries at the time of writing,openai3.36.1 on PyPI andopenai7.42.0 on npm, both ship adecisionsresource. If your installed SDK has nodecisionsattribute, upgrade it.- The model name
gpt-6-luna. The guide says it is “the only currently supported model” for/v1/decisions.
If you want to experiment before writing code, OpenAI has a Decisions Playground where you can try questions on your own text or images.
Install or upgrade the SDK:
# Python
pip install --upgrade openai
# JavaScript / TypeScript
npm install openai@latest
Step 1: Make your first request (a choice question)
A request has three fields:
| Field | Purpose |
|---|---|
model | Currently only gpt-6-luna |
input | Shared evidence for the questions: a text string, or an ordered array of messages, tool calls and tool outputs |
questions | What to evaluate: each question’s type, instructions and allowed choices or score levels |
This is the guide’s example for routing a customer complaint to a department:
curl https://api.openai.com/v1/decisions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-6-luna",
"input": "I was charged twice for my order.",
"questions": [{
"type": "choice",
"name": "department",
"instructions": "Which department should handle this complaint?",
"choices": [
{"value": "billing", "description": "Payments, invoices, and refunds."},
{"value": "technical", "description": "Problems using the product."},
{"value": "shipping", "description": "Delivery and tracking."},
{"value": "other", "description": "Requests outside these categories."}
]
}]
}'
The same call in Python:
from openai import OpenAI
client = OpenAI()
decision = client.decisions.create(
model="gpt-6-luna",
input="I was charged twice for my order.",
questions=[
{
"type": "choice",
"name": "department",
"instructions": "Which department should handle this complaint?",
"choices": [
{"value": "billing", "description": "Payments, invoices, and refunds."},
{"value": "technical", "description": "Problems using the product."},
{"value": "shipping", "description": "Delivery and tracking."},
{"value": "other", "description": "Requests outside these categories."},
],
}
],
)
answer = decision.answers[0]
if answer.type == "refusal":
print(f"Refused: {answer.name}")
elif answer.type == "choice":
print(f"Department: {answer.choice} (confidence: {answer.confidence})")
OpenAI’s illustrative response looks like this:
{
"answers": [
{
"type": "choice",
"name": "department",
"choice": "billing",
"probabilities": [
{ "value": "billing", "probability": 0.95 },
{ "value": "technical", "probability": 0.02 },
{ "value": "shipping", "probability": 0.01 },
{ "value": "other", "probability": 0.02 }
],
"confidence": 0.93
}
]
}
Three things to notice:
choiceis always one of your values. You never have to fuzzy-match free text.- You get the full distribution.
probabilitiescovers every option, andconfidenceis a separate field you can threshold on. - Always handle
refusal. The reference says any question can come back as{"type": "refusal", "name": ...}instead of an answer, while other questions in the same request still get answered. Code that assumesanswer.choiceexists will crash on a refusal.
Rules for choice questions from the reference: provide between 2 and 255 choices, each unique. Values can be strings or booleans, and they are typed: "true" and true are different choices. The guide recommends including a fallback such as "other" when your categories don’t cover every input, so those cases can go to a general review queue.
Step 2: Check a condition with a predicate
A predicate question returns a single probability that a statement is true. The guide’s example checks a product photo for damage:
IMAGE_BASE64="$(base64 < product.png | tr -d '\r\n')"
curl https://api.openai.com/v1/decisions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
--data-binary @- <<JSON
{
"model": "gpt-6-luna",
"input": [{
"role": "user",
"content": [
{"type": "input_text", "text": "Inspect the product in this photo."},
{"type": "input_image", "image_url": "data:image/png;base64,$IMAGE_BASE64"}
]
}],
"questions": [{
"type": "predicate",
"name": "visible_damage",
"instructions": "Does the product have visible damage, such as a crack, tear, or dent? Ignore shadows and damage to the packaging."
}]
}
JSON
In Python:
import base64
from pathlib import Path
from openai import OpenAI
client = OpenAI()
image_base64 = base64.b64encode(Path("product.png").read_bytes()).decode("ascii")
decision = client.decisions.create(
model="gpt-6-luna",
input=[
{
"role": "user",
"content": [
{"type": "input_text", "text": "Inspect the product in this photo."},
{
"type": "input_image",
"image_url": f"data:image/png;base64,{image_base64}",
},
],
}
],
questions=[
{
"type": "predicate",
"name": "visible_damage",
"instructions": (
"Does the product have visible damage, such as a crack, tear, or dent? "
"Ignore shadows and damage to the packaging."
),
}
],
)
answer = decision.answers[0]
if answer.type == "refusal":
print(f"Refused: {answer.name}")
elif answer.type == "predicate":
print(f"Visible damage probability: {answer.probability}")
The response is just {"type": "predicate", "name": "visible_damage", "probability": 0.92} in OpenAI’s example. Note how the instruction says what to ignore (shadows, packaging). Writing predicates around observable criteria and explicit exclusions is the main lever you have on quality.
Image rules from the guide: pass images as inline base64 data URLs or publicly accessible HTTP or HTTPS URLs (hosted URLs are new since the beta). file_id inputs are not supported on this endpoint.
Step 3: Rate against a rubric with a score
A score question takes 2 to 10 ordered levels, listed from lowest to highest:
curl https://api.openai.com/v1/decisions \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-6-luna",
"input": "Export fails in Safari but works in Chrome.",
"questions": [{
"type": "score",
"name": "severity",
"instructions": "How severe is this issue?",
"levels": [
{"label": "Cosmetic", "description": "Appearance only; no lost functionality."},
{"label": "Workaround available", "description": "A task fails, but another way works."},
{"label": "Fully blocked", "description": "A task fails with no workaround."}
]
}]
}'
OpenAI’s example response:
{
"answers": [
{
"type": "score",
"name": "severity",
"score": 1.1,
"probabilities": [
{ "value": 0, "label": "Cosmetic", "probability": 0.1 },
{ "value": 1, "label": "Workaround available", "probability": 0.7 },
{ "value": 2, "label": "Fully blocked", "probability": 0.2 }
],
"confidence": 0.55
}
]
}
Level indices start at 0, and score is the probability-weighted average: 0×0.1 + 1×0.7 + 2×0.2 = 1.1. That is why a score can land between levels. If you need one category rather than a position on a scale, use choice instead; the guide’s rule is choice for unordered categories (departments) and score for ordered ones (severity).
Step 4: Ask several questions in one request
You can include between 1 and 400 questions per request (up from 200 during the public beta), and each can be a different type. Answers come back in the same order, and each carries the name you gave it. This is where the API gets efficient: one call can triage a ticket on several dimensions at once.
Here is a ticket-triage request that combines all three types. It uses only the documented request fields; the questions themselves are our example:
from openai import OpenAI
client = OpenAI()
ticket = (
"Since this morning's update, the CSV export button does nothing in Safari. "
"Chrome works. I need this report for a board meeting tomorrow."
)
decision = client.decisions.create(
model="gpt-6-luna",
input=ticket,
questions=[
{
"type": "choice",
"name": "department",
"instructions": "Which team should handle this ticket?",
"choices": [
{"value": "billing", "description": "Payments, invoices, refunds."},
{"value": "technical", "description": "Bugs or problems using the product."},
{"value": "account", "description": "Login, permissions, account settings."},
{"value": "other", "description": "Anything outside these categories."},
],
},
{
"type": "score",
"name": "severity",
"instructions": "How severe is the problem for this customer?",
"levels": [
{"label": "Cosmetic", "description": "Appearance only; nothing is broken."},
{"label": "Workaround available", "description": "A task fails, but another way works."},
{"label": "Fully blocked", "description": "A task fails with no workaround."},
],
},
{
"type": "predicate",
"name": "regression",
"instructions": "Does the customer say the problem started after a recent update?",
},
{
"type": "predicate",
"name": "time_sensitive",
"instructions": "Does the customer mention a deadline within the next 48 hours?",
},
],
)
results = {}
for a in decision.answers:
if a.type == "refusal":
results[a.name] = None
elif a.type == "choice":
results[a.name] = (a.choice, a.confidence)
elif a.type == "score":
results[a.name] = (a.score, a.confidence)
elif a.type == "predicate":
results[a.name] = a.probability
print(results)
print(decision.usage)
Two rules from the guide matter here:
- Only batch independent questions. If one decision depends on another (“if damaged, which repair category?”), send separate requests: check damage first, then ask the follow-up.
- Keep concerns separate. Give each choice a distinct meaning, and define score levels so adjacent levels have clearly different criteria. One question per concern beats one vague question that mixes several.
Billing-wise, the usage endpoint (Step 8) counts a request once no matter how many questions it carries. You still pay for the input tokens, which include your question text.
Step 5: Pass JSON data and conversation history
Since GA you can pass structured data directly with an input_json content part instead of stringifying it. Put the object in data, inside a user message (that is the only role where input_json is allowed). The guide’s example checks a ticket against an order record:
{
"model": "gpt-6-luna",
"input": [
{
"role": "user",
"content": [
{ "type": "input_text", "text": "Evaluate the ticket using the order evidence." },
{
"type": "input_json",
"data": {
"ticket": { "body": "Please refund the duplicate charge." },
"order": { "charges": [49, 49] }
}
}
]
}
],
"questions": [
{
"type": "predicate",
"name": "duplicate_charge",
"instructions": "Does the ticket describe a duplicate charge supported by the order?"
}
]
}
Send that body to POST /v1/decisions. The guide stresses that keys like role or image_url inside data are just data; they do not create messages or fetch images. That is useful when the JSON comes from users.
For agents, input can also be a conversation: messages plus function or custom tool calls and their outputs, in order, with each tool output matched to its call by call_id. What each item may contain, per the guide:
| Input item | Supported content |
|---|---|
| User message | Text, images and JSON |
| Developer or system message | Input text only (system is treated as developer) |
| Assistant message | Output text or refusals |
| Function or custom tool output | Text and images, tied to a matching tool call |
Files, audio and item references are not supported. Decisions only returns answers; your application still runs whatever action the answer selects. This is the pattern for “should the agent continue, retry, or ask for clarification?” after a tool call.
Step 6: Give each question its own image
If you want to compare several candidate images against one reference, put the shared evidence in input and the per-question image inside instructions, which can be an array of input_text and input_image parts. The guide’s example:
{
"model": "gpt-6-luna",
"input": [
{
"role": "user",
"content": [
{ "type": "input_text", "text": "The shared image is reference image A." },
{ "type": "input_image", "image_url": "https://example.com/reference.jpg" }
]
}
],
"questions": [
{
"type": "predicate",
"name": "candidate_matches_reference",
"instructions": [
{
"type": "input_text",
"text": "Is candidate image B a derivative or variant of reference image A, including recompression or resizing?"
},
{ "type": "input_image", "image_url": "https://example.com/candidate.jpg" }
]
}
]
}
Replace the example URLs with real public images or base64 data URLs. Add one question per candidate image. Content parts go directly in instructions with no message wrapper or role, and question images count against the same per-request image limits as images in input.
Step 7: Turn probabilities into actions
The API gives you numbers, not decisions about what happens next. OpenAI’s guidance is to “use labeled examples from your application to set thresholds for routing, filtering, or review,” choosing them based on what a false positive and a false negative cost you.
A simple, practical pattern:
- Collect a few hundred real inputs and label them yourself (or reuse labels you already have).
- Run them through your Decisions questions and store
probabilityorconfidencealongside your label. - Pick thresholds per question. For a damage check that triggers a refund, you might auto-approve only above a high probability and send the middle band to a human; for a spam filter you might accept more false positives.
- Route low-confidence
choiceanswers, and anything that hits"other"orrefusal, to a review queue. Box described this in OpenAI’s post: it sends lower-confidence document cases to reviewers.
def route(answer, auto_threshold=0.85):
if answer.type == "refusal":
return "human_review"
if answer.type == "choice":
if answer.choice == "other" or answer.confidence < auto_threshold:
return "human_review"
return answer.choice
raise ValueError(f"unexpected answer type: {answer.type}")
The 0.85 here is a placeholder; set your own from labeled data. Re-check thresholds when your inputs shift.
Step 8: Cut cost with prompt caching, and monitor usage
Prompt caching
If many requests share the same long context (a policy document, a product catalog, a reference image) and only the questions change, prompt caching lets repeat requests reuse that prefix at a discount. How it works on this endpoint, per the guide and reference:
- By default there is an implicit cache breakpoint at the end of shared
input, so existing requests can benefit without changes. - The reusable prefix must be at least 1,024 tokens, and question content does not count toward that. Everything in
questions, including per-question images, is outside the cacheable prefix. - Keep the prefix byte-for-byte identical across requests.
- Use
prompt_cache_keyto partition cache reuse by your own key (for example per tenant). - For control over the boundary, add
"prompt_cache_breakpoint": {"mode": "explicit"}to text or image parts in sharedinput. Settingprompt_cache_options.modeto"explicit"uses only your marked boundaries. In explicit mode, a request with no marked boundaries does not use caching at all. Breakpoints are not supported in question instructions. - Each request can write up to four breakpoints, and the
ttldefaults to30m.
Check whether it worked in the response’s usage: usage.input_tokens_details.cached_tokens is cache reads and usage.input_tokens_details.cache_write_tokens is cache writes. Both are already included in usage.input_tokens, so don’t add them on top.
Pricing
From the announcement and guide:
| Item | Price |
|---|---|
| Uncached input | $0.10 per 1M tokens |
| Cached input | $0.01 per 1M tokens |
| Cache writes | Charged; see OpenAI’s pricing page |
| Output tokens | Not charged |
Regional processing premiums and long-context multipliers still apply. These rates are for /v1/decisions only; other gpt-6-luna calls use normal model pricing. If you are comparing against Luna through the Responses API, our GPT-6 Sol and Luna release coverage has the general model background.
Usage monitoring
Call GET /v1/organization/usage/decisions with an admin API key that has the api.usage.read permission to get token usage and request counts. You choose a time range and bucket width and can filter or group by project, API key or model. num_model_requests counts requests, not individual questions.
Optional: decisions inside a GPT-Live voice session
The GA release adds a native integration with GPT-Live, OpenAI’s voice model. Live can call Decisions directly, or you can trigger a decision whenever input or output transcripts arrive, for example to pick an avatar’s facial expression or decide whether a support agent should escalate. You add a trigger of type decisions to session.triggers in an existing Live session, and Live emits a decision.event your app reads. Speak’s CTO told OpenAI this cut the time to next action from 1.4 s to 0.13 s in their testing. The setup is documented separately in OpenAI’s Connect voice to Decisions guide.
Troubleshooting
Requests with another model name fail. /v1/decisions currently supports only gpt-6-luna, so don’t point it at Sol or older models.
AttributeError: 'OpenAI' object has no attribute 'decisions' (or the JS equivalent). Your SDK predates the Decisions resource. Upgrade with pip install --upgrade openai or npm install openai@latest.
Your code crashes reading answer.choice or answer.probability. One of the answers is a refusal. Check answer.type before reading fields, as every official example does.
An image is rejected. file_id image inputs are not supported. Use a base64 data URL or a publicly reachable HTTP(S) URL.
input_json is rejected. It only works inside a user message’s content, not in developer, system or assistant messages.
Choice validation errors. You need 2 to 255 unique choices. Remember values are typed, so a string "yes" and boolean true are different values, and duplicate strings are not allowed.
Score validation errors. A score question needs 2 to 10 levels, ordered from lowest to highest.
Too many questions. The maximum is 400 per request. Split larger batches across requests.
Caching never kicks in (cached_tokens stays 0). Check that the shared input prefix is at least 1,024 tokens and identical between requests. If you set prompt_cache_options.mode to "explicit", you must mark at least one prompt_cache_breakpoint in input; breakpoints in question instructions are not supported.
Answers look inconsistent between related questions. If one question depends on another’s answer, they should be separate, sequential requests, not one batch.
FAQ
Is the Decisions API a different model from GPT-6 Luna? No. OpenAI says it is “built with GPT-6 Luna,” and its API lead told TechCrunch it is “plain old Luna” reconfigured for faster, cheaper inference on bounded questions.
How fast is it? OpenAI claims up to 10x faster than Luna through the Responses API. Customer figures in the announcement include Sierra at under 280 ms p95 and Datadog reporting 40x faster p95 than the frontier models it had tried. Measure on your own traffic and region; OpenAI says it added capacity in several Asian regions for latency.
Does it return explanations? No. You get probabilities, choices, scores, confidence and usage. If you need a written rationale or extracted fields, use Structured Outputs with the Responses API.
Can I use it for data that must stay in the EU or US? Regional processing is supported in the United States and Europe (EEA plus Switzerland), and Zero Data Retention and HIPAA use are available to eligible customers. Check OpenAI’s data controls page for eligibility.
Should I use it instead of Jev? Test both on labeled examples from your own workload. Public leaderboards cited by TechCrunch currently rank several decision models above OpenAI’s, while OpenAI argues Luna is stronger on images. Our Jev on Vercel AI Gateway article covers how that model is called.
Where do I find more guides like this? Browse the latest tutorials on the 7minAI homepage.