Decision Engine API (POST /v1/decisions)
Decision Engine API – send text or JSON plus questions with fixed options, get probabilities and a recommendation back in milliseconds.
The Decision Engine answers questions with a fixed set of options, such as: which team is responsible, how urgent is this, does a human need to review? It generates no text. For every question the model runs one forward pass, and we read the probability of each allowed option directly. That is why a decision comes back in milliseconds, where a chat answer takes seconds.
Base URL: https://sovrgpt.com/api/v1 · Scope: decisions · Auth: Authorization: Bearer sov_…
Changed on 23 Sep 2026: without a
policy, the default mode is nowthreshold(previouslyreview_only), and the double check is on. How to keep the old behaviour is described under Automatic or human. Limits, prices and model versions may still change.
Request a decision
curl https://sovrgpt.com/api/v1/decisions \
-H "Authorization: Bearer $SOVR_KEY" -H "content-type: application/json" \
-d '{
"preset": "ticket-routing-v1",
"state": "Since this morning I cannot log in. My invoice was also charged twice — please check this urgently."
}'Without a policy the default { "mode": "threshold", "threshold": 0.85, "stability_check": true } applies.
Response (abridged). The probabilities come from a measurement on sovr-decision-v2;
recommendation, warning signals, double check, usage and timings were added for
illustration:
{
"object": "decision",
"id": "dec_3f1c…",
"request_id": null,
"status": "completed",
"model": { "id": "sovr-decision-v2", "runtime": "…", "operator": "…", "country": "IE", "prompt_version": "…", "via": "platform" },
"preset": { "id": "ticket-routing-v1", "version": "1.0.0" },
"answers": {
"team": {
"type": "choice",
"value": "support",
"probabilities": { "support": 0.6512, "billing": 0.0367, "sales": 0.0044, "unclear": 0.3076 },
"max_probability": 0.6512,
"margin": 0.75,
"margin_is_lower_bound": false,
"normalized_entropy": 0.58,
"calibration": { "status": "uncalibrated" },
"recommendation": "review",
"reasons": ["below_threshold"],
"stability": { "rotations": 2, "stable": true, "alternatives": [] },
"abstain": true,
"abstain_reasons": ["below_threshold", "calibration_unavailable"],
"diagnostics": { "label_mass": 0.9967, "low_label_mass": false, "missing_labels": [] }
},
"urgency": { "type": "score", "value": 3, "expected_index": 3.29, "normalized_score": 0.82, "max_probability": 0.7042, "recommendation": "review", "reasons": ["below_threshold"], "stability": null, "…": "…" },
"needs_human": { "type": "binary", "value": true, "probability_true": 0.9933, "recommendation": "act", "reasons": [], "stability": null, "…": "…" },
"language": { "type": "choice", "value": "de", "max_probability": 0.9993, "recommendation": "act", "reasons": [], "stability": { "rotations": 2, "stable": true, "alternatives": [] }, "…": "…" }
},
"policy": { "mode": "threshold", "threshold": 0.85, "stability_check": true },
"usage": { "input_tokens": 1290, "output_tokens": 6, "evaluated_questions": 4, "forward_passes": 6 },
"timing_ms": { "total": 430, "inference_max": 405, "inference_sum": 1030 }
}How to read it: team and urgency are below the 0.85 threshold and go to a human,
needs_human and language are unremarkable. Two of the four questions are choice
questions, hence six passes instead of four.
recommendation rates how reliable the answer is, not what it says. needs_human: true
with "act" means the engine decided, without any warning signal, that a human should look
at the ticket. Your application can route it there without a further check.
Request
| Field | Required | Meaning |
|---|---|---|
state or messages | exactly one | The state to judge: text, a JSON object/array or a conversation ([{role, content}], text only). Treated as data, never as instructions. |
questions or preset | exactly one | Your own questions (below) or a server-side preset (GET /v1/decisions/presets). |
model | no | sovr-decision-v1 or sovr-decision-v2. Omitted: the organisation's default. |
images | no | Up to 2 images as [{ "data_url": "data:image/png;base64,…" }], ≤ 4 MB each. data: URLs only; the service never fetches remote URLs. |
policy | no | { "mode": …, "threshold": …, "stability_check": … }, every field can be omitted on its own. Default: threshold, 0.85, double check on. See Automatic or human. |
request_id | no | Free-form, echoed back (correlation). |
Question types
{
"team": { "type": "choice", "instructions": "Which team first?", "options": [ { "id": "support", "description": "technical" }, { "id": "billing" }, { "id": "unclear", "description": "cannot be assigned" } ] },
"urgency": { "type": "score", "instructions": "How urgent?", "levels": ["not", "slightly", "normal", "urgent", "immediately"] },
"needs_human": { "type": "binary", "instructions": "Does a human need to review?", "true_description": "yes", "false_description": "no" }
}| Type | Result |
|---|---|
choice — 2–26 options | value = option id with the highest probability, probabilities over all options |
score — 2–10 ordered levels | value = most likely level (0-based), expected_index = Σ p·i, normalized_score = expected/(n−1). A score is not a physical scale. |
binary | value = true/false, probability_true |
Order matters: options and questions are evaluated in source order, and a model may react to the order. For choice questions the engine checks this with the double check. For scores the order is the meaning and stays as you send it.
Presets
A preset binds questions and options to an id; you send only preset and state.
preset and questions are mutually exclusive (400). Audience, situation, example call
and how to use the answer for all ten presets are on the
Use cases page.
preset | Name | Questions |
|---|---|---|
ticket-routing-v1 | Ticket routing | team, urgency, needs_human, language |
inbox-document-v1 | Inbox document classification | doc_type, has_deadline, action_level, language |
customer-feedback-v1 | Customer feedback analysis | sentiment, churn_risk, topic, needs_response |
lead-qualification-v1 | Lead qualification | fit, timeline, budget_signal, decision_maker |
behoerden-posteingang-v1 | Public-authority inbox | art, zustaendigkeit, fristrelevant, eskalation |
antrag-vollstaendigkeit-v1 | Application complete? | vollstaendig, hauptmangel, widerspruch, naechster_schritt |
veroeffentlichung-check-v1 | Publication release check | personenbezug_dritter, art9_daten, tonalitaet, freigabe |
agent-handoff-v1 | Agent handoff | action, risk, sensitive_data |
grounding-check-v1 | Grounding check | support, unsupported_claims, contradiction, verdict |
agent-step-risk-v1 | Agent step gate | irreversible, effect, risk |
GET /v1/decisions/presetsreturns the full questions per preset, plusaudienceandexample_state(a realistic example state).- Three presets expect a JSON object as
state:antrag-vollstaendigkeit-v1(an application withpflichtfelder,erforderliche_unterlagen,eingereichte_unterlagen,unterschrift_vorhanden,anmerkung),grounding-check-v1(question,sources,answer) andagent-step-risk-v1(the agent's goal, current page and planned step, in the exampleziel,seite,schritt). Theirexample_stateis therefore JSON text; when calling, send the object directly, not as a string. - Every choice question of a preset has a domain abstention as its last option (
unclearorother). If the model picks it,reasonscontainschose_abstain_optionand the recommendation is"review", except inautonomousmode. - The same engine with the same presets is available in chat as the “Entscheiden”
(decide) tool (
decision_engine) and in the MCP server assovrgpt_decide/sovrgpt_list_decision_presets.
Models
| Model | Operated in | Images | Character |
|---|---|---|---|
sovr-decision-v1 (default) | Germany (STACKIT) | yes | very fast, very decisive: probabilities almost always near 1.0 or 0 |
sovr-decision-v2 | EU, a European partner data centre (Ireland/Finland), zero retention by contract | yes | finer-grained probabilities, suited to threshold logic |
Both run on open base models; we decide which runtime computes. Which version applies
without a model field and where it computes is listed per organisation under
GET /v1/decisions/models. Owners and admins change the default under
Decision Engine → Settings of this organisation.
Latency. Measured from Jena, warm, a ticket text with four options, without the double
check: 65 ms per question on sovr-decision-v1, 198 ms on sovr-decision-v2; four
questions in parallel 123 ms and 397 ms respectively. A chat answer to the same text on the
same models takes 5–30 s. These values are not a guarantee; they depend on the provider,
the load and the length of the state. The double check's second pass runs at the same time
as the first, so we expect only a small increase; we have not re-measured it yet. The actual
duration is part of every answer under timing_ms: inference_max is the longest single
pass, inference_sum the sum of all.
Automatic or human: policy and recommendation
Every answer carries a recommendation: recommendation: "act" means your application can go
ahead with the answer; "review" means a human should check first. reasons says why. The
evaluation itself is complete either way. The engine never acts itself: binding approvals,
access rights and actions stay with your application.
Three modes (policy.mode)
| Mode | recommendation | For |
|---|---|---|
threshold (default) | "act" as long as reasons is empty; otherwise "review" | Automation with a recommendation: unremarkable answers run through, remarkable ones go to a human. |
review_only | always "review"; reasons contains review_only_policy | Review everything, for instance during the introduction phase or wherever human review is mandatory. |
autonomous | always "act" | There is no human, for example a character in a computer game or a machine that acts on its own. The warning signals are still listed in reasons. Your application can react more cautiously: fall back to a safe default, slow down, log. |
policy field | Values | Default |
|---|---|---|
mode | threshold, review_only, autonomous | threshold |
threshold | a number greater than 0, up to and including 1 | 0.85 |
stability_check | true / false | true |
Every field can be omitted on its own; whatever is missing takes the default. threshold
acts as the limit in threshold mode, only as a warning signal in autonomous
(below_threshold in reasons), and not at all in review_only. The applied policy is
echoed in every answer: "policy": { "mode": "threshold", "threshold": 0.85, "stability_check": true }.
Moving over from the behaviour before 23 Sep 2026: exactly the old behaviour at the old
cost (every answer "review", one pass per question) is
"policy": { "mode": "review_only", "stability_check": false }. With
"policy": { "mode": "review_only" } alone the double check stays on. Every answer still
goes to a human, who additionally sees unstable_under_reordering in reasons when a choice
flips; in return every choice question costs twice the input tokens.
Warning signals (reasons)
reasons lists all warning signals of an answer, in every mode:
| Signal | Meaning |
|---|---|
review_only_policy | review_only mode: every answer is presented to a human. |
low_label_mass | Less than half of the probability sat on the allowed options (diagnostics.label_mass below 0.5). The model actually wanted to answer something else; the distribution is not reliable. |
labels_missing_from_top_logprobs | At least one option was not among the model's candidates (diagnostics.missing_labels), and the best option is below 0.99. Missing options get probability 0; that is assumed, not measured. |
below_threshold | max_probability is below policy.threshold. Does not occur in review_only mode. |
chose_abstain_option | A domain abstention was chosen, i.e. a choice option with the id unclear, other, unknown, none or unsure. |
unstable_under_reordering | The double check picked a different option with reordered options. It is the only signal that marks an otherwise unremarkable choice (enough label mass, no abstention) as uncertain even at max_probability 1. |
stability_unavailable | The double check was requested, but its second pass failed (for example throttled or too late). The answer is complete but unconfirmed: stability is null, and in threshold mode the recommendation is "review". |
An empty reasons means nothing remarkable was found. It is no guarantee that the answer is
right; the values are uncalibrated (calibration.status: "uncalibrated").
Double check (stability_check)
With stability_check: true (default) the engine asks every choice question a second time,
at the same time as the first pass. The options are shifted cyclically by half the list
(rounded; with two options that means swapped). The answer in your order is the one that
counts. The second pass does not feed into the result; it only shows whether the choice
flips. If it picks a different option, stability.stable is false,
stability.alternatives names the deviating choice, and reasons contains
unstable_under_reordering.
| Question type | stability |
|---|---|
choice with the double check | { "rotations": 2, "stable": true, "alternatives": [] } or stable: false with the deviating option |
choice, second pass failed | null, plus stability_unavailable in reasons |
score, binary | null: they are not asked twice; for scores the order is the meaning |
any question with stability_check: false | null |
When only the check pass fails (the runtime throttles, answers too late or unusably),
the answer still arrives: evaluated in your order, with stability: null and the warning
signal stability_unavailable. Because nothing is confirmed, threshold mode then
recommends "review". If the first pass fails, i.e. the evaluated answer itself, the whole
request fails (see Errors).
Why it exists. A choice answer can flip purely because of the order of the options. On
our internal test set that was rare, 3 of 86 choice answers per version. On
sovr-decision-v1, however, it happened precisely at a displayed certainty of around 100 %.
Example: a complete application (antrag-vollstaendigkeit-v1) got
naechster_schritt: "bearbeiten" (process) with 1.00 in source order, and "nachfordern"
(request missing documents) with 0.97 with the options reordered. Neither the probability
nor margin nor a threshold warns about this; only the second pass shows it. On
sovr-decision-v2, answers only flipped where they were uncertain anyway.
This is what that case looks like in the answer (reconstructed, abridged):
"naechster_schritt": {
"type": "choice",
"value": "bearbeiten",
"max_probability": 1,
"recommendation": "review",
"reasons": ["unstable_under_reordering"],
"stability": { "rotations": 2, "stable": false, "alternatives": ["nachfordern"] },
"abstain": true,
"abstain_reasons": ["unstable_under_reordering", "calibration_unavailable"],
"…": "…"
}In autonomous mode recommendation would stay "act" and abstain false; reasons
would still contain unstable_under_reordering.
Switching it off: "policy": { "stability_check": false }. You then get one pass per
question, stability is null, and the unstable_under_reordering signal disappears. Only
do this if you secure the order some other way. What the double check costs is under
Cost.
Legacy fields abstain and abstain_reasons
They remain for existing integrations:
abstainistrueexactly whenrecommendationis"review".abstain_reasonsequalsreasonspluscalibration_unavailablewhenabstainistrue, and is empty otherwise.- In
autonomousmodeabstainis therefore alwaysfalseandabstain_reasonsempty; the warning signals are only inreasons.
New integrations use recommendation and reasons.
What the numbers mean
probabilitiesis the distribution over your options.max_probability: 0.92means the model gives that option 92 % of the mass. It does not mean it is 92 % likely to be correct;calibration.statusis alwaysuncalibratedas of today. On our internal test set of 122 cases, both versions were over 95 % correct. That is a snapshot on our own examples, not a promise for your data.diagnostics.label_masstells you how much probability sat on the allowed options at all. Below 0.5 the model wanted to write something else. The distribution is then an artefact,diagnostics.low_label_massistrue, andreasonscontainslow_label_mass.sovr-decision-v1mostly answers 1.0 / 0 / 0, with few shades in between. A percentage threshold rarely bites there. Two other nets are more reliable: the domain abstention (unclear,other), which triggerschose_abstain_optionregardless of the percentage, and the double check.
margin — the number a threshold belongs on
margin is the gap between the best and the second-best option, measured as
log p(best) − log p(second best) in nats. Unlike max_probability it has no ceiling:
two answers that both come out as max_probability: 1 may have a gap of 8.5 or 19.2.
On our internal test set (463 answers per version), max_probability separates correct from
incorrect answers on sovr-decision-v1 with an AUROC of 0.777. No threshold on it catches
every error, because four of the eleven wrong answers carry max_probability of exactly
1. Using margin the AUROC is 0.867, and at a gap of 20 or more half of all answers ran
through without a single error in that measurement.
More decimal places on max_probability would not fix this. One reason is rounding to four
places. The other: for roughly a quarter of answers only one option appears among the
model's candidates at all. The probability is then exactly 1 by construction, and there
is no resolution left to recover. For that case margin_is_lower_bound: true is reported:
the gap was computed against the candidate cut-off and is a lower bound. The true gap is at
least that large, never smaller.
A workable threshold is not a guarantee. Across 400 random halvings of our test set, no
error slipped through in 72 % of them, but in the worst case ten did. margin is a second
net; the first remains the domain abstention option. Which gap holds for your data must be
determined on your data. Our 20 applies to our test set.
recommendation (and with it the legacy field abstain) is derived from
max_probability, threshold and the other warning signals. margin is returned but not
yet part of the recommendation. If you want to gate on margin, do it in your application.
Errors
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_request_error, invalid_json, images_unsupported | invalid body, including an unknown field in policy |
| 401 | — | key missing, invalid or revoked |
| 402 | org_spend_limit, user_spend_limit | spending limit reached |
| 403 | missing_scope | key lacks the decisions scope |
| 404 | unknown_preset, model_not_found | |
| 413 | request_too_large | |
| 429 | upstream_rate_limited | the runtime is throttling right now (type: "rate_limit_error"). Wait briefly and retry; nothing is billed. Until 23 Sep 2026 this came back as 502. |
| 502 / 504 | upstream_error, upstream_timeout | the evaluated answer (first pass) did not arrive usably or in time. There is no partial answer, and nothing is billed. If only the second pass of the double check fails, that is not an error: the answer arrives with stability: null and stability_unavailable. |
| 503 | runtime_unavailable | no measured runtime is reachable in this deployment. A different model is never used silently. |
Limits and cost
Limits
1–16 questions · state ≤ 16,000 characters · question ≤ 1,000 characters · description
≤ 512 characters · body ≤ 64 KiB. Too large → 413. Unknown fields are rejected with
400, inside policy as well, so that a tenant_id or tools in the body never looks as
if it had an effect.
Cost
- Billing counts input tokens plus one output token per forward pass, only for passes the runtime answered usably.
- With the double check, every choice question uses twice the input tokens (two passes over the same state) and two output tokens. Score and yes/no questions cost the same as without the double check.
usage.forward_passescounts all answered passes,usage.input_tokensandusage.output_tokensinclude both.usage.evaluated_questionscounts the questions. Exampleticket-routing-v1: four questions, two of them choice questions → six passes.- A failed check pass costs nothing and is missing from
forward_passes. If the request fails as a whole (error code), nothing is billed.
Privacy
State, questions and answers are not stored; your usage records only the billed token
counts. Images are never fetched: only data: URLs are accepted. The processing location is
part of every response (model.operator, model.country).
Further endpoints
GET /v1/decisions/presets: the ten server-side presets with audience (audience), example state (example_state) and questions.GET /v1/decisions/models: this organisation's versions, default, operator, country, measurements, limits.
A playground with real inference is available after signing in, under Decision Engine in the sidebar.
Audio API (TTS & STT)
OpenAI-compatible audio API – text-to-speech with German voices and voice cloning, transcription via Voxtral, processed in EU data centres.
SDK examples
Use SovrGPT with the OpenAI SDKs: examples for Python, Node.js, .NET, Java and Go, plus Vercel AI SDK, LangChain and LiteLLM, each with base URL and key.