# Authentication (/docs/guides/authentication)
Every request to carries an API key in the `Authorization` header. Keys spend the credit of the account that created them. This guide covers the key lifecycle from creation to revocation.
## Create a key [#create-a-key]
1. Sign in at [drex.nace.ai/login](https://drex.nace.ai/login) and open **API Keys**.
2. Click **Create key**.
3. Enter a name, such as the app or environment that will use the key, and click **Create key**.
4. Copy the key from the dialog.
The full key appears once. After you close the dialog, the **API Keys** page shows only the key's prefix. If you lose a key, revoke it and create a new one.
A key looks like `nace_sk_` followed by 43 URL-safe characters. An account can have at most 3 active keys. When you reach the limit, **Create key** is disabled until you revoke one.
## Store the key [#store-the-key]
Put the key in an environment variable or a secret manager. Do not commit it to source control, and do not ship it in code that runs in a browser or a mobile app.
```bash
export DREX_API_KEY="nace_sk_..."
```
The TypeSafe SDK reads the key from `TYPESAFE_API_KEY` when you do not pass `apiKey`. See [Migrate from TypeSafe](/docs/guides/migrate-from-typesafe) for the full environment setup.
## Send the key [#send-the-key]
Send the key as a Bearer token on every request.
```bash
curl https://drex.nace.ai/v1/models \
-H "Authorization: Bearer $DREX_API_KEY"
```
```ts
import { TypeSafeClient } from "@typesafe-ai/sdk";
const client = new TypeSafeClient({
baseURL: "https://drex.nace.ai",
apiKey: process.env.DREX_API_KEY,
defaultModel: "drex-v1.0",
});
```
```python
import os, httpx
headers = {"Authorization": f"Bearer {os.environ['DREX_API_KEY']}"}
response = httpx.get("https://drex.nace.ai/v1/models", headers=headers)
```
## Keep the key on the server [#keep-the-key-on-the-server]
Only call Drex from code you control on a server. Anyone who loads a page can read a key shipped in its JavaScript.
The TypeSafe SDK enforces this. If `TypeSafeClient` is constructed in a browser, the constructor throws a `TypeSafeError`:
> TypeSafeClient is running in a browser, which would expose your API key to anyone using the page. Call the API from a server instead, or pass `dangerouslyAllowBrowser: true` if you understand the risk.
Pass `dangerouslyAllowBrowser: true` only in a context where exposing the key is acceptable, such as a local demo. For a web app, call Drex from your own backend and have the browser call your backend.
## Rotate a key [#rotate-a-key]
To rotate a key without downtime:
1. On **API Keys**, click **Create key** and create the replacement. If you already have 3 active keys, revoke an unused one first.
2. Deploy the new key to your servers and confirm that requests succeed.
3. Click **Revoke** next to the old key, then confirm with **Revoke key**.
Requests with the revoked key stop working within 5 seconds. Revocation cannot be undone.
## Revoke a key [#revoke-a-key]
To revoke a key you no longer need, or one that may have leaked, open **API Keys**, click **Revoke** on that row, and confirm with **Revoke key**. A revoked key stays in the list with the status **Revoked** so you can see when it was last used.
## What a bad key returns [#what-a-bad-key-returns]
A missing, malformed, or revoked key returns `401` with the type `authentication_error`. This is a real capture from `curl -i https://drex.nace.ai/v1/models` with no `Authorization` header. The request id varies per call.
```http
HTTP/2 401
x-request-id: req_7c1e4a9b2d3f4e5a8b6c7d8e9f0a1b2c
x-typesafe-request-id: req_7c1e4a9b2d3f4e5a8b6c7d8e9f0a1b2c
access-control-expose-headers: retry-after, retry-after-ms, x-request-id, x-typesafe-request-id
{
"error": {
"type": "authentication_error",
"message": "Invalid API key. Pass a nace_sk_ key as `Authorization: Bearer `."
},
"request_id": "req_7c1e4a9b2d3f4e5a8b6c7d8e9f0a1b2c"
}
```
The TypeSafe SDK raises this as an `AuthenticationError` and does not retry it. Every Drex response carries the same request id in the body and in both headers. Quote it when you contact support. Other error types are listed in [Errors](/docs/reference/errors).
## Check a key without spending credit [#check-a-key-without-spending-credit]
`GET /v1/models` needs a valid key but works when the account has no credit, so you can use it to confirm that a key is active before you deploy it.
```bash
curl https://drex.nace.ai/v1/models \
-H "Authorization: Bearer $DREX_API_KEY"
```
```json title="Response"
{
"models": [
{
"name": "drex-v1.0",
"description": "Drex System One decision model. Typed questions in, calibrated probabilities out.",
"release_date": "2026-09-24",
"alias_for": null
},
{
"name": "drex-v1.5",
"description": "Drex 1.5 decision model. Typed questions in, calibrated probabilities out; states up to 131,072 tokens.",
"release_date": "2026-09-28",
"alias_for": null
},
{
"name": "drex-latest",
"description": "Points to drex-v1.5 (Drex 1.5). Moves to a newer version only on an announced date.",
"release_date": "2026-09-28",
"alias_for": "drex-v1.5"
},
{
"name": "drex-v1.1",
"description": "Retired on 2026-09-28. Requests that name it are served by drex-v1.5 (Drex 1.5) at its price.",
"release_date": "2026-09-25",
"alias_for": "drex-v1.5"
}
],
"request_id": "req_0a2b4c6d8e1f4a3b9c5d7e9f1a3b5c7d"
}
```
A `POST /v1/systemone` call with a valid key and no credit returns `402` with the type `insufficient_credit`. Top up on **Billing** to keep going. See [Pricing](/docs/reference/pricing).
# Errors and retries (/docs/guides/errors-and-retries)
Drex returns one error envelope for every failure, with a `type` that tells you whether to retry. This guide shows how to classify each error, how long to wait, and how to write the retry loop in the TypeSafe SDK or by hand. For every status, message, and header, see the [error reference](/docs/reference/errors).
## Retry or fix [#retry-or-fix]
Every error response has this body. `issues` appears only on 422, and `request_id` is always present.
```json title="Error envelope"
{
"error": {
"type": "rate_limit_error",
"message": "Too many concurrent requests for this account."
},
"request_id": "req_<32 hex characters>"
}
```
Decide by `type` or by HTTP status.
| HTTP | `error.type` | What happened | Do this |
| ----------- | ------------------------ | ------------------------------------------------------------------- | -------------------------------------------------------------- |
| 429 | `rate_limit_error` | Your account is over its requests per minute or its in-flight limit | Wait `retry-after-ms`, then retry |
| 529 | `overloaded` | Drex is at capacity, the model timed out, or Drex is paused | Wait `retry-after-ms`, then retry |
| 500 | `internal_error` | Something failed inside Drex | Retry with backoff |
| No response | Network error or timeout | The request did not complete | Retry with backoff |
| 401 | `authentication_error` | Missing or invalid `nace_sk_` key | Fix the key. See [Authentication](/docs/guides/authentication) |
| 402 | `insufficient_credit` | The account has no spendable credit | Top up in the dashboard |
| 422 | `invalid_request_error` | The body failed validation or is over a limit | Fix the body. `error.issues` says where |
A 4xx that is not 429 will fail the same way on every retry. Retrying it spends your rate limit and delays the real fix.
## Honor retry-after [#honor-retry-after]
A 429 or 529 carries two headers with the same value: `retry-after` in whole seconds, rounded up, and `retry-after-ms` in milliseconds. Read `retry-after-ms` when you can. Waiting shorter than the header will hit the same limit again. The values Drex sends:
| Cause | `error.type` | `retry-after-ms` |
| ------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------ |
| Too many concurrent requests for this account | `rate_limit_error` | 1000 |
| Requests per minute exceeded for this account | `rate_limit_error` | Time until the oldest request leaves the 60-second window, at least 1000 |
| Drex is at capacity, or its rate limiter is unavailable | `overloaded` | 2000 |
| The model timed out or was unavailable | `overloaded` | 2000 |
| Drex is paused | `overloaded` | 30000 |
The in-flight limit is the one most clients hit first. A free account may have 2 requests in flight, a paid account 16. If you send a batch of tickets with `Promise.all` or a thread pool, cap the concurrency at your tier's limit instead of retrying your way through 429s. Limits are on the [limits reference](/docs/reference/limits).
## Set the client timeout to at least 60 seconds [#set-the-client-timeout-to-at-least-60-seconds]
Drex waits up to 55 seconds for the model before it returns a 529, and the route itself stops at 60 seconds. Under load a request can sit in the model's queue for most of that window while capacity is added. A client timeout below 60 seconds abandons requests that were about to succeed, and the retry joins the queue again at the back.
The TypeSafe SDK's default timeout is 10,000 ms per attempt. Raise it.
## Configure the TypeSafe SDK [#configure-the-typesafe-sdk]
The SDK retries on its own. Its defaults, from `RetryPolicy`:
* `maxRetries` is 2, so a request is attempted at most 3 times.
* Backoff starts at 500 ms, doubles per attempt, and is capped at 5,000 ms, with up to 25% subtracted as jitter.
* It retries HTTP 408, 429, and every 5xx, which includes 529. It also retries connection errors and timeouts.
* It honors `retry-after-ms` and `retry-after` up to 60,000 ms. Longer server delays fall back to backoff.
Point it at Drex and give it the longer timeout:
```ts title="client.ts"
import { TypeSafeClient } from "@typesafe-ai/sdk";
export const client = new TypeSafeClient({
apiKey: process.env.DREX_API_KEY,
baseURL: "https://drex.nace.ai",
defaultModel: "drex-v1.0",
timeout: 60_000,
retry: { maxRetries: 4, backoffMaxMs: 10_000 },
});
```
Each HTTP status maps to an error class, so you can tell a fix from a retry that ran out:
```ts title="handle-errors.ts"
import {
APIConnectionError,
APIError,
AuthenticationError,
RateLimitError,
UnprocessableEntityError,
noul,
} from "@typesafe-ai/sdk";
import { client } from "./client";
try {
const { answers } = await client.systemOne({
state: "I was charged twice for my March invoice.",
questions: { wants_refund: noul("Is the customer asking for a refund?") },
});
console.log(answers.wants_refund.noul);
} catch (error) {
if (error instanceof UnprocessableEntityError) {
// Fix the request. error.body holds the envelope with error.issues.
console.error("invalid request", error.requestId, error.body);
} else if (error instanceof AuthenticationError) {
console.error("check DREX_API_KEY", error.requestId);
} else if (error instanceof RateLimitError) {
// Retries are used up. error.retryAfterMs is the last server hint.
console.error("rate limited", error.requestId, error.retryAfterMs);
} else if (error instanceof APIError) {
// 402 arrives here as a plain APIError with status 402.
console.error(`Drex ${error.status}`, error.requestId, error.message);
} else if (error instanceof APIConnectionError) {
console.error("network or timeout after retries", error.message);
} else {
throw error;
}
}
```
`error.requestId` comes from the `x-typesafe-request-id` header, which Drex sets on every response. See [Migrate from TypeSafe](/docs/guides/migrate-from-typesafe) for the environment variables that do the same as the constructor options.
## Write your own retry loop [#write-your-own-retry-loop]
Without the SDK, retry 429, 529, 500, and network failures. Use the server's `retry-after-ms` when present, and capped exponential backoff with jitter otherwise. Stop after a fixed number of attempts, and never retry 401, 402, or 422.
```ts title="drex.ts"
const RETRYABLE = new Set([429, 500, 529]);
export async function systemOne(body: unknown, maxRetries = 4): Promise {
for (let attempt = 0; ; attempt++) {
let response: Response | undefined;
try {
response = await fetch("https://drex.nace.ai/v1/systemone", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.DREX_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(60_000),
});
} catch (error) {
if (attempt >= maxRetries) throw error;
}
if (response) {
if (response.ok) return response.json();
const requestId = response.headers.get("x-request-id");
if (!RETRYABLE.has(response.status) || attempt >= maxRetries) {
throw new Error(`Drex ${response.status} (${requestId}): ${await response.text()}`);
}
console.warn(`Drex ${response.status} (${requestId}), retrying`);
}
await sleep(retryDelayMs(attempt, response?.headers));
}
}
function retryDelayMs(attempt: number, headers?: Headers): number {
const serverMs = Number(headers?.get("retry-after-ms"));
if (serverMs > 0) return serverMs;
const capped = Math.min(500 * 2 ** attempt, 10_000);
return capped / 2 + Math.random() * (capped / 2);
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
```
```python title="drex.py"
import os
import random
import time
import httpx
RETRYABLE = {429, 500, 529}
def system_one(body: dict, max_retries: int = 4) -> dict:
with httpx.Client(timeout=60.0) as client:
attempt = 0
while True:
headers = None
try:
response = client.post(
"https://drex.nace.ai/v1/systemone",
headers={"Authorization": f"Bearer {os.environ['DREX_API_KEY']}"},
json=body,
)
except httpx.TransportError:
if attempt >= max_retries:
raise
else:
if response.is_success:
return response.json()
request_id = response.headers.get("x-request-id")
if response.status_code not in RETRYABLE or attempt >= max_retries:
raise RuntimeError(f"Drex {response.status_code} ({request_id}): {response.text}")
print(f"Drex {response.status_code} ({request_id}), retrying")
headers = response.headers
time.sleep(retry_delay_seconds(attempt, headers))
attempt += 1
def retry_delay_seconds(attempt: int, headers: httpx.Headers | None) -> float:
server_ms = headers.get("retry-after-ms") if headers is not None else None
if server_ms and server_ms.isdigit() and int(server_ms) > 0:
return int(server_ms) / 1000
capped = min(0.5 * 2**attempt, 10.0)
return capped / 2 + random.random() * capped / 2
```
`httpx.TimeoutException` is a subclass of `httpx.TransportError`, so the Python loop retries timeouts too. In both loops the jitter keeps a fleet of clients that hit a 529 at the same moment from retrying at the same moment.
## Log and report request\_id [#log-and-report-request_id]
Every response, success or failure, carries the same id in three places: the `request_id` field of the body and the `x-request-id` and `x-typesafe-request-id` headers. Log it next to your own correlation id on every call. If a request fails in a way this page does not explain, or a 500 or 529 keeps coming back, write to [support@nace.ai](mailto:support@nace.ai) with the `request_id` and the time. Drex joins every log line for a request on that id, so it is the fastest way to a cause.
## Know what a retry costs [#know-what-a-retry-costs]
Drex debits your account only after it has sent a successful response. The debit runs after the response is on the wire and records `usage.input_tokens` for that `request_id`. A 401, 402, 422, 429, 500, or 529 is not billed, so retrying a failure does not cost credit. A 429 is decided before Drex reads the body, so a rate-limited request costs nothing but the round trip.
Every successful response is billed, including duplicates. Do not hedge by sending the same request twice in parallel: if both succeed, you pay for both. Usage by request is on the dashboard's **Usage** page.
# Migrate from TypeSafe (/docs/guides/migrate-from-typesafe)
Drex is wire-compatible with TypeSafe's Jev. The `@typesafe-ai/sdk` package works against without a fork. This guide covers the configuration change, the requests that Drex rejects, and a check that the switch worked. It is written against SDK version 0.6.0.
## Change three environment variables [#change-three-environment-variables]
The SDK reads its base URL, key, and default model from the environment when the constructor does not set them.
```dotenv title=".env"
TYPESAFE_BASE_URL=https://drex.nace.ai
TYPESAFE_API_KEY=nace_sk_...
TYPESAFE_DEFAULT_MODEL=drex-v1.0
```
If your code sets these in the constructor instead, change them there. Explicit options take precedence over environment variables.
```ts title="Before"
import { TypeSafeClient } from "@typesafe-ai/sdk";
const client = new TypeSafeClient({
apiKey: process.env.TYPESAFE_API_KEY,
});
```
```ts title="After"
import { TypeSafeClient } from "@typesafe-ai/sdk";
const client = new TypeSafeClient({
baseURL: "https://drex.nace.ai",
apiKey: process.env.DREX_API_KEY,
defaultModel: "drex-v1.0",
timeout: 60_000,
});
```
Set `defaultModel` in one of the two places. Without it, the SDK sends `jev-latest`, and Drex returns `422`. The `timeout` line is explained under [Raise the SDK timeout](#raise-the-sdk-timeout).
## Verify the switch [#verify-the-switch]
List the models. The SDK unwraps the `{ "models": [...] }` envelope and returns the array.
```ts
const models = await client.models.list();
console.log(models.map((m) => m.name));
// [ 'drex-v1.0', 'drex-v1.5', 'drex-latest', 'drex-v1.1' ]
```
The list has each pinned version, then the `drex-latest` alias, then retired ids such as `drex-v1.1`, which `drex-v1.5` now answers. See [Models](/docs/reference/models#versions-and-the-drex-latest-alias). `GET /v1/models` needs a valid key but works with zero credit, so this check does not spend anything. A `401` here means the key is wrong. See [Authentication](/docs/guides/authentication).
## What changes [#what-changes]
Work through this list before you send production traffic. Each item is a request that TypeSafe accepted and Drex rejects, or a behavior that differs.
### Send a `drex-*` model name [#send-a-drex--model-name]
Drex accepts pinned versions such as `drex-v1.0`, the `drex-latest` alias, any other `drex-*` name, and legacy `nacedm-*` names. Anything else returns `422`, so a half-migrated client fails on the first call instead of silently using the wrong model. A request with `"model": "jev-latest"` returns this issue:
```json
{
"path": "model",
"message": "unknown model \"jev-latest\"; use \"drex-v1.0\", \"drex-v1.5\" or \"drex-latest\""
}
```
Search your code for hard-coded `jev-` model names and for calls that pass `model` per request.
### Pass `instructions` as a string [#pass-instructions-as-a-string]
Drex requires `instructions` to be a string when present. The SDK types allow a JSON object, an array, or `null`, and `noul()` with no arguments sends `instructions: null`. Drex rejects both:
```json
{
"path": "questions.a.instructions",
"message": "must be a string"
}
```
Replace `noul()` and `noul(null, criteria)` with `noul("Your question as text", criteria)`. Replace JSON instructions with text.
### Pass criteria descriptions as strings [#pass-criteria-descriptions-as-strings]
The SDK types allow a JSON object or array as any criteria description. Drex does not.
* `choice`: each label's description must be a string or `null`. A JSON description returns the issue `must be a string or null` at `questions..criteria.`.
* `noul`: the `true` and `false` descriptions must be strings or `null`. A JSON description returns `must be a string`.
* `score`: `criteria` must be an array of level labels, each a string. The SDK types allow `null` entries, but Drex returns `must be a string` at `questions..criteria.` for a `null` level.
### Raise the SDK timeout [#raise-the-sdk-timeout]
The SDK's per-attempt `timeout` defaults to 10,000 ms. Drex waits up to 55 s for the model on a heavy request, so a large state with many questions can exceed the default and surface as an `APITimeoutError`. Set `timeout: 60_000` on the client, or per call in the second argument to `systemOne`. The timeout applies per attempt, and the SDK retries `APITimeoutError` by default. [Errors and retries](/docs/guides/errors-and-retries) covers retries in detail.
### Handle `402` and `529` [#handle-402-and-529]
Drex adds `402` to TypeSafe's statuses. Handle it next to `529`, which Drex returns when it cannot serve a request right now.
* `402` with type `insufficient_credit` is new in Drex. It means the account has no spendable credit. The SDK raises this as a plain `APIError` with `status` 402. It is not retried. Top up on **Billing**.
* `529` with type `overloaded` means Drex is at capacity or the model did not respond in time. It carries `retry-after` and `retry-after-ms`. The SDK treats it as a `5xx`, raises `InternalServerError` if retries run out, and honors the `retry-after` headers while retrying.
[Errors](/docs/reference/errors) lists every type with its status.
### Read `legend` as an array [#read-legend-as-an-array]
Drex returns the `score` answer's `legend` as an array of level labels. The SDK types describe it as an object keyed by score. Index access works the same either way: `answers.sentiment.legend[2]` returns the third label.
## What stays the same [#what-stays-the-same]
* The endpoints, `POST /v1/systemone` and `GET /v1/models`.
* The request shape: `state`, `questions`, and optional `model`.
* The response shape: `model`, `answers` keyed by question name, and `usage` with `input_tokens` and `output_tokens`. Drex's body also carries `evaluation_time_ms` and `request_id`, which the SDK's `SystemOneResult` type does not declare.
* The statuses `401`, `422`, `429`, and `5xx`, which the SDK maps to `AuthenticationError`, `UnprocessableEntityError`, `RateLimitError`, and `InternalServerError`.
* The `x-typesafe-request-id` header. The SDK reads it into `APIError.requestId` and `withResponse().requestId`. Drex sends the same value as `x-request-id`.
* The SDK's retry behavior: 2 retries by default, backoff from 500 ms doubling to 5 s, on `408`, `429`, and `500` to `599`, honoring `retry-after` and `retry-after-ms`.
* The `noul`, `choice`, and `score` helpers, and the answer types they infer.
## Checklist [#checklist]
1. Set `TYPESAFE_BASE_URL`, `TYPESAFE_API_KEY`, and `TYPESAFE_DEFAULT_MODEL`, or the matching constructor options.
2. Remove hard-coded `jev-*` model names.
3. Make sure that every `instructions` value is a string.
4. Make sure that every criteria description is a string, and that `score` levels contain no `null`.
5. Raise `timeout` for large requests.
6. Handle `402` and `529` where you handle other `APIError` cases.
7. Run `client.models.list()` and confirm that it includes `drex-v1.0`.
# Questions (/docs/guides/questions)
Every request to `POST /v1/systemone` carries one `state` and a map of `questions`. Each question has a `type` of `noul`, `choice`, or `score`. This guide helps you pick the type, write the JSON that Drex accepts, and fit many questions into one call. To read what comes back, see [Reading answers](/docs/guides/reading-answers).
## Pick the type by the decision you make next [#pick-the-type-by-the-decision-you-make-next]
Start from the code that consumes the answer, not from the text you are asking about.
| You need | Type | Example |
| ---------------------------------------- | -------- | ------------------------------------------------------------------ |
| A yes or no, with a threshold you set | `noul` | Is the customer asking for a refund? |
| One label from a fixed set with no order | `choice` | Is this ticket about billing, a technical problem, or the account? |
| A position on an ordered scale | `score` | How upset is the customer, from calm to angry? |
If the labels have an order, use `score` and you get a fractional position on the scale. If the labels have no order, use `choice`. If there are only two outcomes, use `noul` and compare the probability with a threshold.
## Shape a noul question [#shape-a-noul-question]
```json title="noul"
{
"type": "noul",
"instructions": "Is the customer asking for a refund?",
"criteria": {
"true": "The customer asks for money back",
"false": "The customer asks for anything else"
}
}
```
Drex applies these rules:
* `instructions` is optional, but when present it must be a string. `null` and JSON objects are rejected with `must be a string`.
* `criteria` is optional. Only the `true` and `false` keys are read. Each value is a string or `null`. Other keys are ignored.
* The question needs a non-empty `instructions` or at least one non-empty criterion. Otherwise Drex rejects it with `noul needs instructions or criteria`.
The TypeSafe SDK helper `noul()` defaults `instructions` to `null`. Drex rejects that, so pass a string: `noul("Is the customer asking for a refund?")`. See [Migrate from TypeSafe](/docs/guides/migrate-from-typesafe).
## Shape a choice question [#shape-a-choice-question]
```json title="choice"
{
"type": "choice",
"instructions": "What is this support ticket about?",
"criteria": {
"billing": "Payments, invoices, refunds, or charges",
"technical": "Bugs, errors, or outages",
"account": "Login, profile, or permissions",
"other": null
}
}
```
* `criteria` is required and must be an object. A string or an array is rejected with `must be an object with options`.
* It needs at least one label. An empty object is rejected with `choice needs at least 1 option`.
* Each description is a string or `null`. A number or an object is rejected with `must be a string or null`.
The keys of `criteria` are the labels that come back in `choice` and `probabilities`. Keep them short and machine friendly, such as `billing` rather than `Billing and payments`.
## Shape a score question [#shape-a-score-question]
```json title="score"
{
"type": "score",
"instructions": "How upset is the customer?",
"criteria": ["Calm", "Mildly annoyed", "Frustrated", "Angry"]
}
```
* `criteria` is required and must be an array of strings. Anything else is rejected with `must be an array of level labels`.
* The array index is the score. `Calm` is 0 and `Angry` is 3. Order the levels from low to high.
* The API accepts one level or more. An empty array is rejected with `score needs at least 1 level`. The TypeSafe SDK requires at least two levels, and a one-level scale gives the model nothing to choose between, so use two or more.
## Name question ids [#name-question-ids]
The keys of `questions` become the keys of `answers`. An id must be non-empty. Drex rejects a blank id with `question ids must be non-empty`.
Name each id for the field it fills in your code, for example `wants_refund`, `category`, `sentiment`. An id like `q1` forces every reader of the answer to look up what `q1` asked.
## Batch questions into one call [#batch-questions-into-one-call]
One request accepts up to 512 questions about the same `state`. Drex bills input tokens, and a batch sends the state once instead of once per question, so one call with several questions costs less than one call per question.
We measured this with a 27-word support ticket. Output is an example and varies slightly per call.
| Request | `usage.input_tokens` |
| ------------------------------------------------------------ | -------------------- |
| 1 noul question | 54 |
| 4 questions (2 noul, 1 choice, 1 score) in one call | 127 |
| The same 4 questions as 4 separate calls (54 + 78 + 55 + 54) | 241 |
The batch used 47% fewer input tokens than the separate calls. The answers matched. `wants_replacement` was 0.9988 in both runs, the `category` probabilities were identical to three decimals, and the `urgency` score was 1.522 in the batch and 1.528 alone.
```ts title="batch.ts"
import { TypeSafeClient, choice, noul, score } from "@typesafe-ai/sdk";
const client = new TypeSafeClient({
apiKey: process.env.DREX_API_KEY,
baseURL: "https://drex.nace.ai",
defaultModel: "drex-v1.0",
});
const { answers, usage } = await client.systemOne({
state:
"Hi, I ordered a standing desk on March 3 and it arrived with a cracked top. I'd like a replacement, not a refund. Order #48213.",
questions: {
wants_replacement: noul("Is the customer asking for a replacement?"),
category: choice("What is this ticket about?", {
damaged_item: "Item arrived broken or damaged",
wrong_item: "A different item was delivered",
refund: "Customer wants money back",
other: null,
}),
urgency: score("How urgent is this ticket?", ["Low", "Medium", "High"]),
has_order_id: noul("Does the message include an order number?"),
},
});
console.log(answers.category.choice, answers.urgency.score, usage.input_tokens);
```
```python title="batch.py"
import os
import httpx
response = httpx.post(
"https://drex.nace.ai/v1/systemone",
headers={"Authorization": f"Bearer {os.environ['DREX_API_KEY']}"},
json={
"model": "drex-v1.0",
"state": "Hi, I ordered a standing desk on March 3 and it arrived with a cracked top. I'd like a replacement, not a refund. Order #48213.",
"questions": {
"wants_replacement": {"type": "noul", "instructions": "Is the customer asking for a replacement?"},
"category": {
"type": "choice",
"instructions": "What is this ticket about?",
"criteria": {
"damaged_item": "Item arrived broken or damaged",
"wrong_item": "A different item was delivered",
"refund": "Customer wants money back",
"other": None,
},
},
"urgency": {"type": "score", "instructions": "How urgent is this ticket?", "criteria": ["Low", "Medium", "High"]},
"has_order_id": {"type": "noul", "instructions": "Does the message include an order number?"},
},
},
timeout=60.0,
)
response.raise_for_status()
body = response.json()
print(body["answers"]["category"]["choice"], body["answers"]["urgency"]["score"], body["usage"]["input_tokens"])
```
More than 512 questions is rejected with `at most 512 questions allowed`. The state must fit the model's state limit, and the state plus your longest question must fit its row limit: 131,072 and 139,264 tokens on `drex-v1.5`. See [State](/docs/guides/state).
## Write criteria that move the answer [#write-criteria-that-move-the-answer]
Descriptions on `choice` labels change where the probability goes. We asked how to moderate the comment "This is the last time I'm warning you. Stop posting here or else." with the labels `allow`, `flag`, and `remove`, first with `null` descriptions and then with short ones.
| Descriptions | `allow` | `flag` | `remove` | `confidence` | `input_tokens` |
| --------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------ | -------- | ------------ | -------------- |
| All `null` | 0.18 | 0.48 | 0.34 | 0.22 | 35 |
| `remove` is "Explicit threat of violence or doxxing", `flag` is "Possible intimidation, send to a human reviewer", `allow` is "No policy violation" | 0.33 | 0.52 | 0.15 | 0.27 | 57 |
The comment is a vague threat. Once `remove` was defined as an explicit threat, its probability fell from 0.34 to 0.15 and the mass moved to `allow` and `flag`. Write a description for any label whose boundary is not obvious from its name. Each description costs input tokens, so keep descriptions short and skip them for labels like `other`.
For `noul`, `criteria.true` and `criteria.false` say what counts as yes and what counts as no. We asked whether the review "Would be great if the settings page didn't take five seconds to open every time." is a bug report, with the same `instructions` each time:
| `criteria` | `noul` | `input_tokens` |
| ------------------------------------------------------------------------------------------------------------- | ------ | -------------- |
| None | 0.3505 | 33 |
| `true`: "Anything that does not work as expected, including slowness", `false`: "A request for a new feature" | 0.9696 | 53 |
| `true`: "A crash, an error, or a broken feature", `false`: "Slowness, polish, or a wish" | 0.0167 | 54 |
Without criteria the review is borderline. Each set of criteria settles it in its own direction. Use criteria when your team's definition of yes differs from the everyday meaning of the question. A sentence in `instructions` also works. "Is this review a bug report? Treat slowness and performance complaints as bugs." returned 0.6552 at 43 input tokens. Criteria state both sides of the boundary, so they moved the answer further here.
## Fix a 422 [#fix-a-422]
When a question is malformed, Drex returns HTTP 422 with `error.type` of `invalid_request_error`. `error.issues` lists every problem with a `path` into your request body. `error.message` repeats the first one as `path: message`.
```json title="422 response"
{
"error": {
"type": "invalid_request_error",
"message": "questions.category.criteria: choice needs at least 1 option",
"issues": [
{ "path": "questions.category.criteria", "message": "choice needs at least 1 option" }
]
},
"request_id": "req_<32 hex characters>"
}
```
These are the messages a question mistake produces. `a` stands for your question id.
| Mistake | `path` | `message` |
| -------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------- |
| Unknown `type` | `questions.a.type` | `must be "noul", "choice", or "score"` |
| Question is not an object | `questions.a` | `must be an object` |
| `instructions` is `null` or an object | `questions.a.instructions` | `must be a string` |
| noul with no instructions and no criteria | `questions.a.instructions` | `noul needs instructions or criteria` |
| noul `criteria` is a string | `questions.a.criteria` | `must be an object` |
| choice `criteria` is an array or string | `questions.a.criteria` | `must be an object with options` |
| choice `criteria` is `{}` | `questions.a.criteria` | `choice needs at least 1 option` |
| choice description is a number | `questions.a.criteria.` | `must be a string or null` |
| score `criteria` is not an array | `questions.a.criteria` | `must be an array of level labels` |
| score `criteria` is `[]` | `questions.a.criteria` | `score needs at least 1 level` |
| score level is not a string | `questions.a.criteria.` | `must be a string` |
| `questions` is `{}` | `questions` | `must contain at least one question` |
| `questions` is not an object | `questions` | `must be an object` |
| More than 512 questions | `questions` | `at most 512 questions allowed` |
| Blank question id | `questions` | `question ids must be non-empty` |
| `model` is not a `drex-*` name, for example `jev-latest` | `model` | `unknown model "jev-latest"; use "drex-v1.0", "drex-v1.5" or "drex-latest"` |
A 422 is not retryable. Fix the body and send it again. For the other statuses, see [Errors and retries](/docs/guides/errors-and-retries) and the full [error reference](/docs/reference/errors).
# Reading answers (/docs/guides/reading-answers)
Drex does not return a verdict. It returns a probability for each question, and your code turns that into a decision. This page explains each field in a response and the reasoning behind choosing thresholds, rounding, or using the whole distribution. For how to shape the questions, see [Questions](/docs/guides/questions).
## The response shape [#the-response-shape]
A successful `POST /v1/systemone` returns HTTP 200 with one answer per question id. This is an example output for a support ticket. Numbers vary slightly per call.
```json title="200 response"
{
"model": "drex-v1.0",
"answers": {
"category": {
"type": "choice",
"choice": "billing",
"confidence": 0.986,
"probabilities": { "billing": 0.9895, "technical": 0.005, "account": 0.0006, "other": 0.0049 }
},
"sentiment": {
"type": "score",
"score": 1.5306,
"legend": ["Calm", "Mildly annoyed", "Frustrated", "Angry"],
"probabilities": { "0": 0.1572, "1": 0.3516, "2": 0.2946, "3": 0.1966 },
"confidence": 0.7183
},
"wants_refund": {
"type": "noul",
"noul": 0.9868
}
},
"usage": { "input_tokens": 106, "output_tokens": 212 },
"evaluation_time_ms": 142.6,
"request_id": "req_<32 hex characters>"
}
```
The state was "I was charged twice for my March invoice. Please refund one of the charges." Each answer repeats its `type`, so you can switch on it without looking up the question.
## A noul answer is the probability of yes [#a-noul-answer-is-the-probability-of-yes]
`noul` is a number from 0 to 1. It is the model's probability that the answer to your question is yes. For the ticket above, "Is the customer asking for a refund?" came back at 0.9868. For "Does this describe a service outage?" on an SSO ticket that locked out 40 people, it came back at 0.7632. For "Is this review a bug report?" on a complaint that a page takes five seconds to open, it came back at 0.3503.
There is no built-in cutoff. You choose a threshold, and the right one depends on what each kind of mistake costs you.
* If acting on a false yes is cheap and missing a true yes is expensive, set a low threshold. Tagging a ticket `maybe_refund` for a human to glance at costs a few seconds. Missing a refund request costs a customer.
* If acting on a false yes is expensive, set a high threshold. Auto-issuing a refund at 0.6 will refund people who did not ask for one.
* If both mistakes are expensive, use two thresholds. Act above the high one, ignore below the low one, and send the band in between to a person.
A threshold is a business decision, not a property of the model. Log the raw `noul` value with each decision, so you can move the threshold later and replay past traffic against the new one.
## A choice answer is a label and a distribution [#a-choice-answer-is-a-label-and-a-distribution]
A `choice` answer has three fields.
* `choice` is the selected label, one of the keys you sent in `criteria`.
* `probabilities` has one entry per label. In our captures they sum to 1 within rounding. The `category` answer above sums to 1.0000.
* `confidence` is the average of the confidence (probability) scores the model produced for this answer. It measures how sure the model was overall, so it tracks the top probability but is a different number. Above, `billing` has probability 0.9895 and `confidence` is 0.986. On another ticket, `p0` had probability 0.8966 and `confidence` 0.8621. Do not compute one from the other.
`choice` alone is enough when one label dominates. When the top two are close, `probabilities` tells you that the model could not separate them. A moderation question on a vague threat came back as `flag` at 0.48 with `remove` at 0.34. Code that reads only `choice` sees a clean `flag`. Code that reads `probabilities` can send that comment to a reviewer because a third of the mass sits on `remove`.
## A score answer is an expected level [#a-score-answer-is-an-expected-level]
A `score` answer places the state on your ordered scale.
* `legend` is the array of level labels you sent, so index 0 is the first label.
* `probabilities` is keyed by index as a string, `"0"` to `"n-1"`, one entry per level.
* `score` is the expected level, the sum of each index times its probability. For `sentiment` above, 0 × 0.1572 + 1 × 0.3516 + 2 × 0.2946 + 3 × 0.1966 = 1.5306, the `score` returned. It can fall between two levels. In every capture on this page it did.
* `confidence` is the average of the confidence (probability) scores the model produced for this answer. A lower value means the model was less sure. `sentiment` above, with its mass spread over four levels, has `confidence` 0.7183. The satisfaction rating below, with more than half its mass on one level, has 0.8863.
Rounding `score` to the nearest level loses information. 1.5306 rounds to 2, `Frustrated`. But the distribution puts 0.5088 of the mass on `Calm` and `Mildly annoyed` together, so "Frustrated" describes this ticket less than half the time. Three ways to read the same answer, each right for a different job:
| You want | Read | For `sentiment` above |
| ---------------------------------------------- | ----------------------------------------- | ------------------------- |
| One label for display | The index with the highest probability | `Mildly annoyed` (0.3516) |
| A number to average across many tickets | `score` | 1.5306 |
| The chance the customer is at least frustrated | `probabilities["2"] + probabilities["3"]` | 0.4912 |
The fractional `score` is the right input for an average or a trend line. Averaging rounded labels throws away the fraction every time. A second capture shows the other case. A satisfaction rating of "The new onboarding flow is fine, I guess." scored 2.173 on a five-level scale with 0.5769 of the mass on `Neutral`. There the argmax and the rounded score agree, and either is fine for display.
## usage, evaluation\_time\_ms, and request\_id [#usage-evaluation_time_ms-and-request_id]
`usage.input_tokens` is the number of tokens the model read for `state` and `questions`. This is what Drex bills. `usage.output_tokens` is what the model produced and is not billed. Rates are on the [pricing reference](/docs/reference/pricing).
`evaluation_time_ms` is the time the model spent on your request. It is not your round trip. Network time, queueing, and Drex's own validation and rate limiting are on top of it. In the captures on this page the model took between 120 ms and 145 ms. Under load, Drex waits up to 55 seconds for the model before it gives up, so set your client timeout with that in mind. See [Errors and retries](/docs/guides/errors-and-retries).
`request_id` is a string like `req_` followed by 32 hex characters. It is also sent as the `x-request-id` and `x-typesafe-request-id` headers, and it is present on error responses too. Log it with every call. When you write to [support@nace.ai](mailto:support@nace.ai), include it, and Drex can join your report to every log line for that request.
# State (/docs/guides/state)
`state` is the data every question in a request is about. This guide shows what Drex accepts in `state`, how text and JSON compare in cost, and what to do when a request is too long. For the questions themselves, see [Questions](/docs/guides/questions).
## Send any JSON value [#send-any-json-value]
`state` is required. It can be any JSON value: a string, an object, an array, a number, a boolean, or `null`. Drex serializes it and hands it to the model together with your questions.
```json title="A string"
{
"state": "I was charged twice for my March invoice. Please refund one of the charges.",
"questions": { "wants_refund": { "type": "noul", "instructions": "Is the customer asking for a refund?" } }
}
```
```json title="An object"
{
"state": {
"ticket_id": "T-1042",
"plan": "enterprise",
"messages": [
{ "from": "customer", "text": "Our SSO login has been broken since this morning and 40 people are locked out." }
]
},
"questions": {
"is_outage": { "type": "noul", "instructions": "Does this describe a service outage?" },
"priority": {
"type": "choice",
"instructions": "Which priority should this ticket get?",
"criteria": { "p0": "Production down for many users", "p1": "Major feature broken", "p2": "Minor issue", "p3": "Question or request" }
}
}
}
```
A request without `state` is rejected with HTTP 422 and the issue `{ "path": "state", "message": "is required" }`. `"state": null` passes validation and reaches the model. In one run it answered "Is there any customer message?" with a `noul` of 0.4727, so treat a null state as a bug in your code rather than a way to ask context-free questions.
An array works when the state is a list, for example the messages of a thread. `["Refund please", "Where is my order?"]` with the question "Does any message ask for a refund?" returned a `noul` of 0.9827 at 28 input tokens.
## Choose text or structured JSON [#choose-text-or-structured-json]
Send the data in the shape you already have. We sent the same lead as a sentence and as an object and asked the same two questions. Output is an example and varies slightly per call.
| `state` | `input_tokens` | `worth_a_call` (noul) | `segment` (choice) |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | --------------------- | ---------------------- |
| `"Company: Northwind Traders. Employees: 250. Budget: $40,000 per year. Timeline: wants to go live next quarter. Current tool: spreadsheets."` | 109 | 0.8226 | `mid_market` at 0.9669 |
| `{ "company": "Northwind Traders", "employees": 250, "budget_usd_per_year": 40000, "timeline": "wants to go live next quarter", "current_tool": "spreadsheets" }` | 109 | 0.6883 | `mid_market` at 0.9686 |
The token count was the same. Rebuilding text as JSON did not save anything here, and rebuilding JSON as text would not either. The `choice` answer was the same in both runs. The `noul` answer moved by 0.13, so the format is part of the question. If you compare a probability with a fixed threshold, keep the format of `state` fixed too.
Prefer an object when your data is already structured. You can then drop a field without editing prose, and dropping fields is the first step when a request is too long. Prefer a string when the data is a message, a document, or a transcript.
## Send text, not media [#send-text-not-media]
The model reads text only. Drex rejects images, audio, video and PDFs anywhere in `state` or `questions` with a 422 whose issue points at the field, for example `{ "path": "state.photo", "message": "is media (image/png data URL); drex-v1.5 reads text only" }`. It looks for base64 data URLs, raw base64 files, and image, audio and file content parts in the OpenAI, Anthropic and Gemini formats. Describe the media in text instead: a caption, an OCR result or a transcript. A URL that points at an image is fine; it is text, though the model can't open it.
## Stay under three limits [#stay-under-three-limits]
Drex checks a request against a byte limit and two token limits before it reaches the model. All three come back as HTTP 422 with `error.type` of `invalid_request_error`, and none is retryable.
**1 MiB body.** The serialized `{ "state": ..., "questions": ... }` must be at most 1,048,576 bytes. Drex rejects a larger body with:
```json
{ "path": "", "message": "request body must be at most 1048576 bytes" }
```
**The state limit.** The state alone must fit the model's state limit: 131,072 tokens on `drex-v1.5`, 32,768 on `drex-v1.0`. Drex counts it with the model's tokenizer, so a body under 1 MiB can still fail here. The response is:
```json title="422 response"
{
"error": {
"type": "invalid_request_error",
"message": "Request too long: the state exceeds the 131,072-token state limit of drex-v1.5. Shorten the state.",
"issues": [{ "path": "state", "message": "exceeds the 131,072-token state limit" }]
},
"request_id": "req_<32 hex characters>"
}
```
**The row limit.** The model reads the state once for each question. Each read, the state plus one question with its instructions and options, must fit the model's row limit: 139,264 tokens on `drex-v1.5`, 32,768 on `drex-v1.0`. So the limit applies to the state plus your longest question. The response is:
```json title="422 response"
{
"error": {
"type": "invalid_request_error",
"message": "Request too long: the state plus its longest question exceed the 139,264-token limit of drex-v1.5. Shorten the state or that question.",
"issues": [{ "path": "state", "message": "exceeds the 139,264-token limit" }]
},
"request_id": "req_<32 hex characters>"
}
```
Adding questions doesn't use up the row limit; only the longest one counts. English text runs about 4 characters per token, so a state of roughly 500,000 characters leaves room for short questions on `drex-v1.5`. `usage.input_tokens` counts the state once plus every question, so it can pass the row limit while each read still fits. The full table of limits is on the [limits reference](/docs/reference/limits).
## Trim a request that is too long [#trim-a-request-that-is-too-long]
The 422 message names both levers. Start with the state.
1. Send only the fields the questions need. A support ticket's `messages` matter for "Is the customer asking for a refund?", but its `created_at`, `assignee`, and `tags` do not. Every field you send is billed as input tokens and counts toward the limit.
2. Ask fewer questions per call. Questions share the limit with the state. In our batching measurement, going from 1 question to 4 added 73 input tokens, about 24 per question, so questions are the smaller lever unless you send hundreds of them. See [Batch questions into one call](/docs/guides/questions#batch-questions-into-one-call).
A request that fits is billed by `usage.input_tokens`. Prices are on the [pricing reference](/docs/reference/pricing).
# Drex API (/docs)
Drex is a decision model. You send `state` (text or JSON) and a set of named, typed questions about that state. Drex answers every question in one HTTP call and returns a probability distribution for each answer, so you can act on the answer and on how sure the model is.
The API is one endpoint, `POST /v1/systemone` at , authenticated with a Bearer key. The model is .
## One request, three answers [#one-request-three-answers]
This request asks three questions about one support ticket. The response is a real capture. Numbers vary slightly per call.
```json title="Request"
{
"model": "drex-v1.0",
"state": "I was charged twice for my March invoice. Please refund one of the charges.",
"questions": {
"category": {
"type": "choice",
"instructions": "What is this support ticket about?",
"criteria": {
"billing": "Payments, invoices, refunds, or charges",
"technical": "Bugs, errors, or outages",
"account": "Login, profile, or permissions",
"other": null
}
},
"sentiment": {
"type": "score",
"instructions": "How upset is the customer?",
"criteria": ["Calm", "Mildly annoyed", "Frustrated", "Angry"]
},
"wants_refund": {
"type": "noul",
"instructions": "Is the customer asking for a refund?"
}
}
}
```
```json title="Response"
{
"model": "drex-v1.0",
"answers": {
"category": {
"type": "choice",
"choice": "billing",
"confidence": 0.986,
"probabilities": {
"billing": 0.9895,
"technical": 0.005,
"account": 0.0006,
"other": 0.0049
}
},
"sentiment": {
"type": "score",
"score": 1.5306,
"legend": ["Calm", "Mildly annoyed", "Frustrated", "Angry"],
"probabilities": { "0": 0.1572, "1": 0.3516, "2": 0.2946, "3": 0.1966 },
"confidence": 0.7183
},
"wants_refund": {
"type": "noul",
"noul": 0.9868
}
},
"usage": { "input_tokens": 106, "output_tokens": 212 },
"evaluation_time_ms": 142.6,
"request_id": "req_3f9c2b7e1a4d4c0e9b8a7f6e5d4c3b2a"
}
```
Each answer carries its type, so you can read `answers.wants_refund.noul` without checking what kind of question you asked. Billing counts input tokens only. See [Pricing](/docs/reference/pricing).
## Three question types [#three-question-types]
* `noul` asks a yes or no question. The answer is `noul`, the probability of yes, from 0 to 1.
* `choice` picks one label from a set you define. The answer is the chosen label, a probability per label, and a confidence.
* `score` places the state on an ordered scale you define. The answer is an expected score, a probability per level, the legend, and a confidence.
[Questions](/docs/guides/questions) covers each type in detail, and [Reading answers](/docs/guides/reading-answers) covers what to do with the numbers.
## Coming from TypeSafe [#coming-from-typesafe]
Drex is wire-compatible with TypeSafe's Jev. The TypeSafe SDK works against Drex after you change three environment variables. A few requests that TypeSafe accepted return a `422` from Drex on purpose. [Migrate from TypeSafe](/docs/guides/migrate-from-typesafe) lists them.
## Where to go next [#where-to-go-next]
Sign in, create a key, and make your first call in curl, TypeScript, or Python.
Write `noul`, `choice`, and `score` questions that get calibrated answers.
Every field of `POST /v1/systemone` and `GET /v1/models`.
Per-token price, signup credit, and top-ups.
# Quickstart (/docs/quickstart)
In this tutorial we sign in, create an API key, and ask Drex two questions about one support message. By the end you have a working call in curl, TypeScript, or Python and know how to read the answer.
## Sign in and claim your credit [#sign-in-and-claim-your-credit]
Open [drex.nace.ai/login](https://drex.nace.ai/login). Enter your email, click **Send code**, and enter the code from your inbox.
A new account gets $25 of signup credit. The credit expires 30 days after the account is created, and each person gets it once. The rest of the price sheet is on [Pricing](/docs/reference/pricing).
## Create an API key [#create-an-api-key]
In the dashboard, open **API Keys** and click **Create key**. Name the key after the app or environment that will use it, then click **Create key** again.
The dialog shows the full key once. Copy it now. If you lose it, revoke it and create a new one.
Now export it in your shell:
```bash
export DREX_API_KEY="nace_sk_..."
```
## Ask your first question [#ask-your-first-question]
We send one line of customer text as `state` and one `noul` (yes or no) question about it.
```bash
curl https://drex.nace.ai/v1/systemone \
-H "Authorization: Bearer $DREX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "drex-v1.0",
"state": "Help! My payouts have been failing for 3 days.",
"questions": {
"is_urgent": { "type": "noul", "instructions": "Does this convey urgency?" }
}
}'
```
```ts title="quickstart.ts"
// npm install @typesafe-ai/sdk
import { TypeSafeClient, noul } from "@typesafe-ai/sdk";
const client = new TypeSafeClient({
baseURL: "https://drex.nace.ai",
apiKey: process.env.DREX_API_KEY,
defaultModel: "drex-v1.0",
});
const { answers } = await client.systemOne({
state: "Help! My payouts have been failing for 3 days.",
questions: { is_urgent: noul("Does this convey urgency?") },
});
console.log(answers.is_urgent.noul);
```
```python title="quickstart.py"
# pip install httpx
import os, httpx
response = httpx.post(
"https://drex.nace.ai/v1/systemone",
headers={"Authorization": f"Bearer {os.environ['DREX_API_KEY']}"},
json={
"model": "drex-v1.0",
"state": "Help! My payouts have been failing for 3 days.",
"questions": {
"is_urgent": {"type": "noul", "instructions": "Does this convey urgency?"}
},
},
)
print(response.json()["answers"]["is_urgent"]["noul"])
```
The curl call prints the full response. The TypeScript and Python calls print only the `noul` value. This is an example output. Numbers vary slightly per call.
```json title="Response"
{
"model": "drex-v1.0",
"answers": {
"is_urgent": { "type": "noul", "noul": 0.8401 }
},
"usage": { "input_tokens": 26, "output_tokens": 24 },
"evaluation_time_ms": 6.8,
"request_id": "req_9d4e1b2c7a3f4e0d8b6c5a4f3e2d1c0b"
}
```
`noul` is the probability of yes, from 0 to 1. Here Drex puts 84% on the message being urgent. You choose the threshold. Route on `noul > 0.5` for a plain yes or no, or on a higher cutoff when a false yes is expensive.
## Add a choice question to the same call [#add-a-choice-question-to-the-same-call]
Drex answers many questions about one `state` in one call. We add a `choice` question with three labels. The description next to each label tells Drex what the label means.
```json title="questions"
{
"is_urgent": { "type": "noul", "instructions": "Does this convey urgency?" },
"team": {
"type": "choice",
"instructions": "Which team should handle this?",
"criteria": {
"payments": "Payouts, charges, or refunds",
"support": "General questions and how-to",
"engineering": "Bugs and outages"
}
}
}
```
In TypeScript, the same question is `choice("Which team should handle this?", { payments: "Payouts, charges, or refunds", support: "General questions and how-to", engineering: "Bugs and outages" })`, imported from `@typesafe-ai/sdk`.
Run the call again with both questions. The `answers` object now has two entries:
```json title="Response"
{
"model": "drex-v1.0",
"answers": {
"is_urgent": { "type": "noul", "noul": 0.8383 },
"team": {
"type": "choice",
"choice": "payments",
"confidence": 0.884,
"probabilities": {
"payments": 0.9227,
"support": 0.0296,
"engineering": 0.0477
}
}
},
"usage": { "input_tokens": 63, "output_tokens": 86 },
"evaluation_time_ms": 85.9,
"request_id": "req_5b7a9c1e3d2f4a6b8c0d1e2f3a4b5c6d"
}
```
`choice` is the label with the highest probability. `probabilities` has one entry per label you defined, so you can see how far behind the other labels were before you act.
## Next steps [#next-steps]
You have a working call. The guides below cover the parts a real integration needs.
All three question types, including `score` for ordered scales.
Send JSON objects and arrays as state, and stay inside the size limits.
Thresholds, confidence, and expected scores.
Every error type, `retry-after`, and the SDK `timeout` setting for large requests.
Every field of `POST /v1/systemone`.
# Errors (/docs/reference/errors)
Every error from is a JSON body with one shape and an HTTP status that names the error class. For how to retry, see [Errors and retries](/docs/guides/errors-and-retries).
## Error envelope [#error-envelope]
```json
{
"error": {
"type": "invalid_request_error",
"message": "questions.a.type: must be \"noul\", \"choice\", or \"score\"",
"issues": [
{ "path": "questions.a.type", "message": "must be \"noul\", \"choice\", or \"score\"" }
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
```
| Field | Type | Present | Meaning |
| --------------- | ------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `error.type` | string | always | One of the six types in the table below. The HTTP status is a function of this field. |
| `error.message` | string | always | Human-readable description. Each type's messages are listed below. |
| `error.issues` | array | `invalid_request_error` only | Every validation problem found, as `{ "path", "message" }` objects. The first issue is also the source of `error.message`. |
| `request_id` | string | always | `req_` followed by 32 hex characters. Same value as the `x-request-id` header. Quote it in support requests. |
## Error types [#error-types]
## Response headers [#response-headers]
| Header | Responses | Value |
| ------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------- |
| `x-request-id` | all | The request id, identical to `request_id` in the body. |
| `x-typesafe-request-id` | all | The same request id, under the name TypeSafe SDK clients read. |
| `access-control-expose-headers` | all | `retry-after, retry-after-ms, x-request-id, x-typesafe-request-id`, so browsers can read the other four. |
| `retry-after` | 429, 529 | Whole seconds to wait. Drex divides `retry-after-ms` by 1000 and rounds up. |
| `retry-after-ms` | 429, 529 | Milliseconds to wait. The exact value the limiter computed. |
| `server-timing` | 200 on `POST /v1/systemone` | Per-phase timings for the request. |
## Order of checks [#order-of-checks]
`POST /v1/systemone` runs these checks in order and stops at the first failure.
1. Kill switch. `529 overloaded`.
2. API key. `401 authentication_error`, then `402 insufficient_credit`.
3. Rate limits. `429 rate_limit_error`, or `529 overloaded` for shared capacity.
4. Body parsing and validation. `422 invalid_request_error`.
5. Model evaluation. `422 invalid_request_error` for a request over the token limit, `529 overloaded` when the model service does not answer.
6. Anything unexpected. `500 internal_error`.
A request that reaches step 4 has already used a slot in the rate limiter, so a request that fails validation still counts toward the per-minute limit. See [Limits](/docs/reference/limits).
`GET /v1/models` runs steps 1 and 2 only, and step 2 stops after the key check.
## authentication\_error (401) [#authentication_error-401]
One message:
```text
Invalid API key. Pass a nace_sk_ key as `Authorization: Bearer `.
```
Returned by both endpoints when any of these is true:
* The `Authorization` header is missing.
* The header is not exactly `Bearer ` followed by `nace_sk_` and 43 URL-safe characters (`A-Z`, `a-z`, `0-9`, `_`, `-`).
* The key does not exist.
* The key was revoked. A revoked key can keep working for up to 5 seconds on an instance that had it cached.
Real response, captured against production with no key:
```http
HTTP/2 401
access-control-expose-headers: retry-after, retry-after-ms, x-request-id, x-typesafe-request-id
content-type: application/json
x-request-id: req_bd2dffad68f5f5fa4ce702e9bd516fb4
x-typesafe-request-id: req_bd2dffad68f5f5fa4ce702e9bd516fb4
{"error":{"type":"authentication_error","message":"Invalid API key. Pass a nace_sk_ key as `Authorization: Bearer `."},"request_id":"req_bd2dffad68f5f5fa4ce702e9bd516fb4"}
```
The dashboard Playground uses the same type with its own message, `Sign in to use the playground.`, when the browser session has ended.
## insufficient\_credit (402) [#insufficient_credit-402]
One message:
```text
This account has no spendable credit. Top up to keep going.
```
Returned by `POST /v1/systemone` when the account's spendable credit is zero. Spendable credit is the sum of what remains on grants that have not expired. Any positive amount admits the request, so the last request before the balance reaches zero still runs. `GET /v1/models` does not check credit and keeps working. Top up on the dashboard Billing page. See [Pricing](/docs/reference/pricing).
## invalid\_request\_error (422) [#invalid_request_error-422]
`error.message` is the first issue, written as `path: message`, or the bare message when the path is empty. `error.issues` lists every problem the validator found in one pass. Paths are dotted: `questions.`, `questions..criteria.`, `questions..criteria.`.
Every issue Drex itself produces:
| Path | Message | When |
| ---------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| (empty) | `body must be JSON` | The body does not parse as JSON. |
| (empty) | `body must be a JSON object` | The body parses, but is an array, string, number, boolean, or null. |
| `model` | `unknown model "jev-latest"; use "drex-v1.0", "drex-v1.5" or "drex-latest"` | `model` is present and does not match `drex-*` or `nacedm-*`. The quoted value is whatever was sent. |
| `state` | `is required` | The `state` key is absent. Any JSON value, including `null`, satisfies it. |
| `questions` | `must be an object` | `questions` is missing or not an object. |
| `questions` | `must contain at least one question` | `questions` is `{}`. |
| `questions` | `at most 512 questions allowed` | More entries than the limit in [Limits](/docs/reference/limits). |
| `questions` | `question ids must be non-empty` | A key is empty or whitespace only. |
| `questions.` | `must be an object` | The question is not an object. |
| `questions..type` | `must be "noul", "choice", or "score"` | Unknown or missing `type`. No further checks run on that question. |
| `questions..instructions` | `must be a string` | `instructions` is present and is not a string. `null` and objects are rejected. |
| `questions..instructions` | `noul needs instructions or criteria` | A `noul` question has neither a non-empty `instructions` string nor a non-empty `criteria.true` or `criteria.false`. |
| `questions..criteria` | `must be an object` | A `noul` question's `criteria` is not an object. |
| `questions..criteria.true`, `.false` | `must be a string` | A `noul` criterion is present, not `null`, and not a string. Other keys under `criteria` are ignored. |
| `questions..criteria` | `must be an object with options` | A `choice` question's `criteria` is missing or not an object. |
| `questions..criteria` | `choice needs at least 1 option` | A `choice` question's `criteria` is `{}`. |
| `questions..criteria.` | `must be a string or null` | A `choice` option description is neither. |
| `questions..criteria` | `must be an array of level labels` | A `score` question's `criteria` is missing or not an array. |
| `questions..criteria` | `score needs at least 1 level` | A `score` question's `criteria` is `[]`. |
| `questions..criteria.` | `must be a string` | A `score` level label is not a string. |
| `state...` or `questions...` | `is media (); reads text only` | The field holds an image, audio, video or PDF: a base64 data URL, a raw base64 file, or an OpenAI, Anthropic or Gemini media content part. `` names which, for example `image/png data URL`, and `` is the version that would have answered. At most 10 per field. See [State](/docs/guides/state#send-text-not-media). |
| (empty) | `request body must be at most 1048576 bytes` | Every check above passed, and `state` plus `questions`, serialized as JSON, is larger than the limit in UTF-8 bytes. This is the only issue in the array. |
| `state` | `exceeds the 131,072-token state limit` | The state alone is over the model's state limit; the number is the model's. `error.message` is `Request too long: the state exceeds the 131,072-token state limit of drex-v1.5. Shorten the state.` |
| `state` | `exceeds the 139,264-token limit` | The state plus the longest question is over the model's row limit; the number is the model's. `error.message` is `Request too long: the state plus its longest question exceed the 139,264-token limit of drex-v1.5. Shorten the state or that question.` |
Two more sources exist and are rare. If the model service rejects a request that passed every check above, Drex forwards the service's own validation messages as `issues`, or `invalid request` when the service's detail cannot be read. If the validator somehow finishes with no issue to report, `error.message` is `Invalid request.`.
Example outputs, all real captures. Only `request_id` is illustrative.
A TypeSafe client that still sends its default model:
```json
{
"error": {
"type": "invalid_request_error",
"message": "model: unknown model \"jev-latest\"; use \"drex-v1.0\", \"drex-v1.5\" or \"drex-latest\"",
"issues": [
{ "path": "model", "message": "unknown model \"jev-latest\"; use \"drex-v1.0\", \"drex-v1.5\" or \"drex-latest\"" }
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
```
One field, two issues. `instructions` was an object, which fails the type check and also leaves the `noul` question without instructions or criteria:
```json
{
"error": {
"type": "invalid_request_error",
"message": "questions.a.instructions: must be a string",
"issues": [
{ "path": "questions.a.instructions", "message": "must be a string" },
{ "path": "questions.a.instructions", "message": "noul needs instructions or criteria" }
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
```
A `state` of about 230 KB sent to `drex-v1.0`, under the byte limit but over its row limit:
```json
{
"error": {
"type": "invalid_request_error",
"message": "Request too long: the state plus its longest question exceed the 32,768-token limit of drex-v1.0. Shorten the state or that question.",
"issues": [
{ "path": "state", "message": "exceeds the 32,768-token limit" }
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
```
A `state` of about 1.2 MB, rejected before the model service sees it:
```json
{
"error": {
"type": "invalid_request_error",
"message": "request body must be at most 1048576 bytes",
"issues": [
{ "path": "", "message": "request body must be at most 1048576 bytes" }
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
```
## rate\_limit\_error (429) [#rate_limit_error-429]
Two messages. Both carry `retry-after` and `retry-after-ms`.
| Message | When | `retry-after-ms` |
| ------------------------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| `Requests per minute exceeded for this account.` | The account was admitted its full per-minute quota within the last 60 seconds. | Time until the oldest counted request leaves the 60-second window, and never less than 1000. |
| `Too many concurrent requests for this account.` | The account already has its maximum number of requests in flight. | 1000. |
The quotas per tier are in [Limits](/docs/reference/limits). Both limits are per account, so every API key on the account and the dashboard Playground draw from the same counters.
## overloaded (529) [#overloaded-529]
Three messages. All carry `retry-after` and `retry-after-ms`. None of them is caused by the request body, so the same request can succeed on retry.
| Message | When | `retry-after-ms` |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- |
| `Drex is temporarily unavailable. Retry after 30 seconds.` | The operators have paused the API. Both endpoints return this. | 30000 |
| `Drex is at capacity. Retry shortly.` | The shared in-flight pool for the request's model fleet is full, the free-tier pool is full, or the rate limiter itself could not be reached. | 2000 |
| `Drex is temporarily unavailable. Retry shortly.` | The model service did not answer within 55 seconds, answered with an error, rejected Drex's own credentials, or reported that it was overloaded. | 2000 |
The response never says which of these upstream conditions occurred and never includes the model service's status code or body. Drex logs that detail under the `request_id`.
## internal\_error (500) [#internal_error-500]
One message:
```text
Something went wrong on our side.
```
An exception that no other branch handled. Retry once, then send the `request_id` to [support@nace.ai](mailto:support@nace.ai). No credit is debited for a request that returns 500, or for any other error response.
# Limits (/docs/reference/limits)
Drex applies two kinds of limits. Request limits bound one call to `POST /v1/systemone`. Rate limits bound how many calls an account can make. Exceeding a request limit returns `422 invalid_request_error`. Exceeding a rate limit returns `429 rate_limit_error`, or `529 overloaded` when a shared pool is full. The messages are listed in [Errors](/docs/reference/errors).
## Request limits [#request-limits]
**Max questions per request.** The number of keys in `questions`. Over the limit, the 422 issue has path `questions` and message `at most 512 questions allowed`. The minimum is one question.
**Max serialized body.** Drex serializes `state` and `questions` together as JSON and measures the result in UTF-8 bytes. Other top-level fields such as `model` are not counted. The check runs only after every other validation check has passed, and it is the only issue in the response: path empty, message `request body must be at most 1048576 bytes`. A 131,072-token state is about 580 KB.
**State limit.** The state alone, counted in the model's tokenizer the way the model reads it. Over the limit, the 422 issue has path `state` and message `exceeds the 131,072-token state limit`, with the model's own number.
**Row limit.** The state plus the longest question, counted the same way. Other questions don't add to it. Over the limit, the 422 issue has path `state` and message `exceeds the 139,264-token limit`, with the model's own number. A body can be under the byte limit and still over a token limit. The example in [Errors](/docs/reference/errors#invalid_request_error-422) is a 230 KB state on `drex-v1.0`.
**Upstream evaluate timeout.** How long Drex waits for the model service to answer one request. When the wait runs out, the response is `529 overloaded` with the message `Drex is temporarily unavailable. Retry shortly.` and `retry-after-ms: 2000`. Requests with many questions on a long state take longest, because the model service works through the questions on one GPU.
**Route maxDuration.** The total time the platform allows one request, including authentication, the rate limiter, and the model call. The evaluate timeout is set below it so the 529 is still delivered.
**Max active API keys.** Per account. Creating a key at the limit fails on the API Keys page. Revoked keys do not count, so revoke one to make room for another.
## Rate limits [#rate-limits]
Both columns apply at the same time. A request must pass the RPM check and the in-flight check to run.
### Tiers [#tiers]
An account is `free` until it has a top-up grant. It is `paid` from its first completed top-up onward, and stays `paid` even after that credit is spent or expired. The signup credit does not make an account `paid`. Drex caches the tier with the API key check for up to 5 seconds, so the new limits apply within that time after a top-up.
### Requests per minute [#requests-per-minute]
The RPM limit is a sliding window, not a calendar minute. Drex records the admission time of every admitted request. A new request is refused when the account already has `RPM` admissions in the previous 60,000 milliseconds. The refusal is `429` with message `Requests per minute exceeded for this account.` and `retry-after-ms` set to the time until the oldest admission leaves the window, never below 1000.
What counts toward RPM on `POST /v1/systemone`:
* Every request that passes the API key and credit checks, including requests that then fail body validation, exceed the token limit, or fail at the model service.
What does not count:
* Requests refused with `401` or `402`, which never reach the limiter.
* Requests refused by any rate limit. A refused request is not recorded anywhere.
* `GET /v1/models`, which does not use the limiter.
### In flight [#in-flight]
A request holds one in-flight slot from admission until Drex has sent its response, success or error, and released the slot. If a slot is never released, for example because the function died, the limiter reclaims it 90 seconds after admission. A request that arrives while the account holds `In-flight` slots is refused with `429`, message `Too many concurrent requests for this account.`, and `retry-after-ms: 1000`.
### Scope [#scope]
Limits are per account. Every API key on the account draws from the same RPM counter and the same in-flight counter, and so does the dashboard Playground. The Playground runs the same validation, the same limiter call, and the same per-input-token debit as the API. Both validate the body and count its tokens before they call the limiter, so a request that fails validation, or is over the token limit, does not count toward RPM or take an in-flight slot.
### Shared capacity [#shared-capacity]
Two pools sit above the per-account limits and are checked after them:
* An in-flight pool per model fleet, shared by every account. A request counts against the fleet its model and length send it to. Each model has two fleets: a fast one for short requests and a long one for the rest. `drex-v1.0` sends a request to its long fleet when the state plus the longest question is over 4,096 tokens. `drex-v1.5` sends a request to its long fleet when the state is over 8,192 tokens or the state plus the longest question is over 16,383. Each pool is Drex's promise to its fleet, so a full pool for long requests leaves short ones unaffected.
* A free-tier in-flight pool shared by every `free` account, so paid traffic always has room.
When any pool is full, the response is `529 overloaded` with message `Drex is at capacity. Retry shortly.` and `retry-after-ms: 2000`. The same response is returned when the limiter's store cannot be reached. Pool sizes are operational settings and are not published.
### Order of checks [#order-of-checks]
The limiter evaluates account RPM, then account in-flight, then the request's fleet pool, then the free pool, in one atomic step. A request is recorded in all applicable counters only when every check passes. When any check fails, nothing is recorded.
# Models (/docs/reference/models)
Each model has a display name, shown in the dashboard, and an API id. Send the API id as `model` in the body of `POST /v1/systemone`. When `model` is omitted, Drex uses .
State limit and row limit are in tokens. The state limit covers the state alone. The row limit covers the state plus the longest question. See [Limits](/docs/reference/limits#request-limits).
`GET /v1/models` returns the same models for an API key, with each model's `name`, `description`, and `release_date`. Aliases come after the versions, then retired ids. `alias_for` names the version that answers an alias or a retired id. It is `null` for a version.
## Retired models [#retired-models]
A retired model has no fleet of its own. A request that names it is answered by a newer version at that version's price, and the response `model` reports the newer version. Stored usage still shows the retired id, its name and its price.
`GET /v1/models` lists a retired id after the aliases, with `alias_for` set to the version that answers it and a description that starts with `Retired on`.
## Versions and the `drex-latest` alias [#versions-and-the-drex-latest-alias]
An API id with a version number, such as `drex-v1.0`, is pinned. A pinned id is always answered by that version at that version's price.
`drex-latest` is an alias. It currently points to . It moves to a newer version only on a date announced in advance, so a request that sends it can change model and price on that date. On 2026-09-27 it moved from `drex-v1.0` to `drex-v1.1`. On 2026-09-28 it moved from `drex-v1.1` to `drex-v1.5`.
* Send a pinned id to control when you change versions. You move to a new version by changing the id in your code.
* The response `model` reports the pinned version that answered, never the alias. A request that sends `drex-latest` today gets as `model`.
* A request is billed at the price of the version in the response `model`.
## Accepted values [#accepted-values]
* A registered id from the table is answered by that model.
* `drex-latest` is answered by the version it points to.
* A retired id is answered by the version listed under [Retired models](#retired-models).
* Any other name that starts with `drex-` or `nacedm-` is answered by .
* Any other value returns `422 invalid_request_error` with path `model` and message `unknown model ""`. See [Errors](/docs/reference/errors#invalid_request_error-422).
* A registered id that is not enabled yet returns `422 invalid_request_error` with path `model` and message `model "" is not available yet`.
Both messages end with `; use` and the ids that are available, versions first and then aliases, for example `; use "drex-v1.0", "drex-v1.5" or "drex-latest"`. Retired ids are not suggested.
Prices are per input token. Output tokens are not billed. See [Pricing](/docs/reference/pricing).
## What changed in Drex 1.5 [#what-changed-in-drex-15]
`drex-v1.5` reads states up to 131,072 tokens, up from 32,768. Requests whose state is over 8,192 tokens go to its long pool, so it no longer hands them to `drex-v1.0`. See [Limits](/docs/reference/limits).
# Pricing (/docs/reference/pricing)
Drex is prepaid. Credit is granted at signup and bought in top-ups, and every successful `POST /v1/systemone` debits input tokens against it. Base URL , model .
## What is billed [#what-is-billed]
The unit is one input token, as reported in `usage.input_tokens` of the response. Output tokens are reported in `usage.output_tokens` and are not billed. Drex keeps balances in nano-USD (nUSD). One USD is 1,000,000,000 nUSD.
The cost of a request is `usage.input_tokens` multiplied by the per-token price of the model that answered it, which the response reports as `model`. A request that names a retired model, such as `drex-v1.1`, is answered by `drex-v1.5` and billed at its price. There is no per-request fee and no charge per question.
Only a `200` response is debited. A request that ends in `401`, `402`, `422`, `429`, `500`, or `529` costs nothing. The debit runs after the response has been sent and is recorded once per `request_id`, so a retried debit never double-charges.
The dashboard Playground is billed the same way as the API, per input token of each successful evaluation.
### Worked examples [#worked-examples]
Each `usage` block below is a real response captured on 2026-09-24. Token counts vary a little between calls.
Three questions on a one-sentence support ticket returned `"usage": { "input_tokens": 106, "output_tokens": 212 }`.
```text
106 input tokens × 40 nUSD = 4,240 nUSD = $0.00000424
212 output tokens × 0 = 0
```
Two questions on a small JSON object returned `"usage": { "input_tokens": 103, "output_tokens": 103 }`.
```text
103 × 40 nUSD = 4,120 nUSD = $0.00000412
```
One score question on a two-sentence review returned `"usage": { "input_tokens": 51, "output_tokens": 129 }`.
```text
51 × 40 nUSD = 2,040 nUSD = $0.00000204
```
The same arithmetic at scale:
```text
1,000,000 input tokens × 40 nUSD = 40,000,000 nUSD = $0.04
$25 signup credit ÷ 40 nUSD per token = 625,000,000 input tokens
```
The CSV export reports both `cost_usd` and `cost_nusd`, so the exact figure is always available.
## Signup credit [#signup-credit]
Every new account gets the signup credit in the table when the account is created. The rules:
* It expires 30 days after it is granted. Unspent credit is gone after that.
* It is granted once per mailbox. Drex normalizes the email before checking: lowercased, a `+suffix` in the local part removed, `googlemail.com` treated as `gmail.com`, and dots in a Gmail local part removed. A second account on the same mailbox gets no signup credit.
* Accounts on disposable email domains get no signup credit. The account itself still works and can be topped up.
* At most 3 accounts per email domain get signup credit in any 24 hours. Subdomains count toward their parent domain, so `a.example.com` and `b.example.com` share the limit. Large mail providers such as Gmail, Outlook, iCloud, Yahoo, and Proton are exempt. Later accounts on the same domain still work and can be topped up.
* It does not change the account's rate-limit tier. See [Limits](/docs/reference/limits#tiers).
## Top-ups [#top-ups]
Top up on the dashboard Billing page. Payment is by card through Stripe Checkout, in USD.
* Amounts are whole dollars between the minimum and maximum in the table. The page offers the preset amounts and a custom field.
* Each top-up is its own credit grant. It expires 12 months after purchase.
* Prepaid credit is non-refundable except where required by law.
* Credit appears when Stripe reports the payment as paid. Drex fulfils each Checkout session once, whether the webhook or the return page arrives first.
* The first completed top-up moves the account to the `paid` rate-limit tier permanently.
## How credit is spent [#how-credit-is-spent]
Spendable credit is the sum of what remains on grants that have not expired. Each debit draws from the grant that expires soonest, then the next, until the cost is covered. A single request can span two grants. Used-up and expired grants are skipped.
If a request costs more than what remains, the request still completes, because the answer has already been delivered when the debit runs. The shortfall is recorded as overdraft. The next grant of any kind settles the overdraft first, and only the remainder becomes spendable credit. The Billing page shows any overdraft next to the balance.
## At zero credit [#at-zero-credit]
When spendable credit is zero, `POST /v1/systemone` and the Playground return `402 insufficient_credit` with this message:
```text
This account has no spendable credit. Top up to keep going.
```
Any positive balance admits a request, so the last request before zero still runs and may leave an overdraft. `GET /v1/models` does not check credit and keeps working, so a client can still list models while the account is empty.
Drex caches the credit check per API key for up to 5 seconds, or 2 seconds once the balance is at or below $1. Each debit updates the cached balance on the instance that served the request. A top-up therefore takes effect within a few seconds.
## Where to see usage [#where-to-see-usage]
The dashboard Usage page shows requests, tokens, and cost as charts for the last 24 hours, 7 days, or 30 days, in hourly or daily buckets, for all traffic, the Playground only, or one API key.
The download icon on that page, labeled **Download CSV**, exports the same series with these columns:
| Column | Meaning |
| ------------------ | -------------------------------------------------------------------- |
| `bucket_start_utc` | Start of the bucket as an ISO 8601 timestamp in UTC. |
| `requests` | Successful evaluations in the bucket. |
| `input_tokens` | Sum of `usage.input_tokens`. |
| `output_tokens` | Sum of `usage.output_tokens`. |
| `cost_usd` | Exact cost in dollars, without a `$` sign, for example `0.00000424`. |
| `cost_nusd` | The same cost as an integer in nUSD, for example `4240`. |
The file is named `drex-usage--.csv`, for example `drex-usage-7d-hour.csv`. Buckets with no traffic are present with zeros.
The Billing page shows the spendable balance, any overdraft, the soonest-expiring grant, the list of purchases with a link to each Stripe receipt, and the list of grants with type, amount, remaining credit, expiry, and status.
# Evaluate questions (/docs/api-reference/systemone)
```json
{
"openapi": "3.2.0",
"info": {
"title": "Drex API",
"description": "Decision-model API by Nace.AI. Send a JSON `state` and a map of typed questions (`noul`, `choice`, `score`) and receive calibrated probabilities for each. Billed per input token. Authenticate with a `nace_sk_` API key from the dashboard.",
"version": "1.0.0",
"contact": {
"name": "Nace.AI Support",
"email": "support@nace.ai"
}
},
"servers": [
{
"url": "https://drex.nace.ai",
"description": "Production"
}
],
"tags": [
{
"name": "Evaluate",
"description": "Evaluate typed questions against a state."
},
{
"name": "Models",
"description": "List the models this API serves."
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "nace_sk_",
"description": "An API key created on the dashboard API Keys page. The key is `nace_sk_` followed by 43 URL-safe characters (`A-Z`, `a-z`, `0-9`, `_`, `-`) and is shown once at creation. Send it as `Authorization: Bearer `. Anything else returns `401 authentication_error`. An account can hold at most 3 active keys."
}
},
"schemas": {
"RequestId": {
"type": "string",
"pattern": "^req_[a-f0-9]{32}$",
"description": "Identifier of this request, `req_` plus 32 hex characters. Present in every response body and in the `x-request-id` and `x-typesafe-request-id` headers. Quote it in support requests.",
"examples": [
"req_bd2dffad68f5f5fa4ce702e9bd516fb4"
]
},
"ParseIssue": {
"type": "object",
"title": "Issue",
"description": "One validation problem. `path` is a dotted path into the request body, for example `questions.a.criteria`, or an empty string for the body as a whole.",
"required": [
"path",
"message"
],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"description": "Dotted path to the offending field. Empty for the whole body.",
"examples": [
"questions.a.instructions",
"model",
"state",
""
]
},
"message": {
"type": "string",
"description": "What is wrong with the field.",
"examples": [
"must be a string",
"is required"
]
}
}
},
"ErrorBody": {
"type": "object",
"title": "Error",
"required": [
"type",
"message"
],
"properties": {
"type": {
"type": "string",
"description": "Error class. The HTTP status follows from it: `authentication_error` 401, `insufficient_credit` 402, `invalid_request_error` 422, `rate_limit_error` 429, `internal_error` 500, `overloaded` 529.",
"enum": [
"authentication_error",
"insufficient_credit",
"invalid_request_error",
"rate_limit_error",
"overloaded",
"internal_error"
]
},
"message": {
"type": "string",
"description": "Human-readable description. For `invalid_request_error` it is the first issue, written as `path: message`."
},
"issues": {
"type": "array",
"description": "Present only when `type` is `invalid_request_error`. Every validation problem found in one pass.",
"items": {
"$ref": "#/components/schemas/ParseIssue"
}
}
}
},
"ErrorEnvelope": {
"type": "object",
"title": "Error response",
"description": "Every non-2xx response has this shape.",
"required": [
"error",
"request_id"
],
"additionalProperties": false,
"properties": {
"error": {
"$ref": "#/components/schemas/ErrorBody"
},
"request_id": {
"$ref": "#/components/schemas/RequestId"
}
}
},
"JsonValue": {
"title": "State",
"description": "Any JSON value: string, number, boolean, null, object, or array. Drex passes it to the model as is. The key must be present, but `null` is accepted.",
"examples": [
"I was charged twice for my March invoice. Please refund one of the charges.",
{
"ticket_id": "T-1042",
"plan": "enterprise",
"messages": [
{
"from": "customer",
"text": "Our SSO login has been broken since this morning and 40 people are locked out."
}
]
}
]
},
"NoulQuestion": {
"type": "object",
"title": "noul (yes or no)",
"description": "A yes-or-no question. Needs a non-empty `instructions` string, or at least one non-empty entry in `criteria`, or both. Unknown keys are ignored.",
"required": [
"type"
],
"properties": {
"type": {
"const": "noul"
},
"instructions": {
"type": "string",
"description": "The question to answer about the state. Must be a string when present. `null` and objects are rejected with `must be a string`.",
"examples": [
"Is the customer asking for a refund?"
]
},
"criteria": {
"type": "object",
"description": "What a yes and a no look like. Keys other than `true` and `false` are ignored.",
"properties": {
"true": {
"type": [
"string",
"null"
],
"description": "Description of a yes answer.",
"examples": [
"Many users cannot use the product"
]
},
"false": {
"type": [
"string",
"null"
],
"description": "Description of a no answer.",
"examples": [
"A single user or a question"
]
}
}
}
}
},
"ChoiceQuestion": {
"type": "object",
"title": "choice (pick one label)",
"description": "Pick one label from `criteria`. Unknown keys are ignored.",
"required": [
"type",
"criteria"
],
"properties": {
"type": {
"const": "choice"
},
"instructions": {
"type": "string",
"description": "The question to answer about the state. Must be a string when present.",
"examples": [
"What is this support ticket about?"
]
},
"criteria": {
"type": "object",
"description": "Map of label to description. At least one label. A description may be `null`.",
"minProperties": 1,
"additionalProperties": {
"type": [
"string",
"null"
]
},
"examples": [
{
"billing": "Payments, invoices, refunds, or charges",
"technical": "Bugs, errors, or outages",
"account": "Login, profile, or permissions",
"other": null
}
]
}
}
},
"ScoreQuestion": {
"type": "object",
"title": "score (ordered levels)",
"description": "Rate the state on an ordered scale. The index of a level in `criteria` is its score, starting at 0. Unknown keys are ignored.",
"required": [
"type",
"criteria"
],
"properties": {
"type": {
"const": "score"
},
"instructions": {
"type": "string",
"description": "The question to answer about the state. Must be a string when present.",
"examples": [
"How upset is the customer?"
]
},
"criteria": {
"type": "array",
"description": "Level labels in ascending order. At least one level. Every item must be a string.",
"minItems": 1,
"items": {
"type": "string"
},
"examples": [
[
"Calm",
"Mildly annoyed",
"Frustrated",
"Angry"
]
]
}
}
},
"Question": {
"title": "Question",
"description": "One of three question types, chosen by `type`.",
"oneOf": [
{
"$ref": "#/components/schemas/NoulQuestion"
},
{
"$ref": "#/components/schemas/ChoiceQuestion"
},
{
"$ref": "#/components/schemas/ScoreQuestion"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"noul": "#/components/schemas/NoulQuestion",
"choice": "#/components/schemas/ChoiceQuestion",
"score": "#/components/schemas/ScoreQuestion"
}
}
},
"SystemOneRequest": {
"type": "object",
"title": "Evaluate request",
"description": "Unknown top-level keys are ignored.",
"required": [
"state",
"questions"
],
"properties": {
"state": {
"$ref": "#/components/schemas/JsonValue"
},
"questions": {
"type": "object",
"minProperties": 1,
"maxProperties": 512,
"additionalProperties": {
"$ref": "#/components/schemas/Question"
},
"description": "Map of question id to question. Ids must be non-empty and not whitespace only. At least 1 and at most 512 questions. `state` and `questions`, serialized together as JSON, must be at most 1048576 bytes. The state must fit the model's state limit, and the state plus the longest question must fit its row limit: 131,072 and 139,264 tokens for `drex-v1.5`, 32,768 and 32,768 for `drex-v1.0`. Text only: images, audio, video and PDFs (base64 data URLs, raw base64 files, or OpenAI, Anthropic and Gemini media content parts) anywhere in `state` or `questions` are rejected with a 422."
},
"model": {
"type": "string",
"pattern": "^(?:drex|nacedm)-[A-Za-z0-9.-]+$",
"description": "Optional. A pinned version id, such as `drex-v1.0` or `drex-v1.5`, is always served by that version. The alias `drex-latest` is served by the version it points to, currently `drex-v1.5`; it moves to a newer version only on an announced date. `GET /v1/models` lists each alias with its `alias_for`. A retired id, such as `drex-v1.1`, is served by its successor, and the response `model` reports the successor; `GET /v1/models` lists it with `alias_for` set. A registered id that is not available yet returns `422` with issue path `model`. Any other `drex-*` name, or a legacy `nacedm-*` name, is served like `drex-latest`, as is a request without `model`. Any other value, including a TypeSafe SDK default such as `jev-latest`, returns `422` with issue path `model`.",
"examples": [
"drex-v1.0",
"drex-v1.5",
"drex-latest"
]
}
}
},
"NoulAnswer": {
"type": "object",
"title": "noul answer",
"required": [
"type",
"noul"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "noul"
},
"noul": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Probability of a yes answer, from zero to one.",
"examples": [
0.9868
]
}
}
},
"ChoiceAnswer": {
"type": "object",
"title": "choice answer",
"required": [
"type",
"choice",
"confidence",
"probabilities"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "choice"
},
"choice": {
"type": "string",
"description": "The selected label. One of the keys of the question's `criteria`.",
"examples": [
"billing"
]
},
"confidence": {
"type": "number",
"description": "Average of the confidence (probability) scores the model produced for this answer. Tracks the top value in `probabilities` but is a different number.",
"examples": [
0.986
]
},
"probabilities": {
"type": "object",
"description": "Probability per label, keyed by the labels of `criteria`.",
"additionalProperties": {
"type": "number"
},
"examples": [
{
"billing": 0.9895,
"technical": 0.005,
"account": 0.0006,
"other": 0.0049
}
]
}
}
},
"ScoreAnswer": {
"type": "object",
"title": "score answer",
"required": [
"type",
"score",
"legend",
"probabilities",
"confidence"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "score"
},
"score": {
"type": "number",
"description": "Expected score: the sum of each level's index times its probability. It can fall between two levels.",
"examples": [
1.5306
]
},
"legend": {
"type": "array",
"description": "The level labels from the question's `criteria`, in order. Index is score.",
"items": {
"type": "string"
},
"examples": [
[
"Calm",
"Mildly annoyed",
"Frustrated",
"Angry"
]
]
},
"probabilities": {
"type": "object",
"description": "Probability per level, keyed by the level index as a string: `\"0\"` to `\"n-1\"`.",
"additionalProperties": {
"type": "number"
},
"examples": [
{
"0": 0.1572,
"1": 0.3516,
"2": 0.2946,
"3": 0.1966
}
]
},
"confidence": {
"type": "number",
"description": "Average of the confidence (probability) scores the model produced for this answer. Lower means the model was less sure.",
"examples": [
0.7183
]
}
}
},
"Answer": {
"title": "Answer",
"description": "Shape follows the question's `type`.",
"oneOf": [
{
"$ref": "#/components/schemas/NoulAnswer"
},
{
"$ref": "#/components/schemas/ChoiceAnswer"
},
{
"$ref": "#/components/schemas/ScoreAnswer"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"noul": "#/components/schemas/NoulAnswer",
"choice": "#/components/schemas/ChoiceAnswer",
"score": "#/components/schemas/ScoreAnswer"
}
}
},
"SystemOneResponse": {
"type": "object",
"title": "Evaluate response",
"required": [
"model",
"answers",
"usage",
"evaluation_time_ms",
"request_id"
],
"additionalProperties": false,
"properties": {
"model": {
"type": "string",
"enum": [
"drex-v1.0",
"drex-v1.5"
],
"description": "The pinned version that served the request, never an alias or a retired id. It is the version `model` named, the successor of a retired id (`drex-v1.5` for `drex-v1.1`), or the version `drex-latest` points to (currently `drex-v1.5`) when `model` is `drex-latest`, is omitted, or is another `drex-*` or `nacedm-*` name. The request is billed at this version's price."
},
"answers": {
"type": "object",
"description": "One answer per question, under the same ids as the request's `questions`.",
"additionalProperties": {
"$ref": "#/components/schemas/Answer"
}
},
"usage": {
"type": "object",
"description": "Token counts from the model service. Only `input_tokens` is billed.",
"required": [
"input_tokens",
"output_tokens"
],
"additionalProperties": false,
"properties": {
"input_tokens": {
"type": "integer",
"minimum": 0,
"description": "Tokens in `state` and `questions` as counted by the model. Billed at the per-token price on the Pricing page.",
"examples": [
106
]
},
"output_tokens": {
"type": "integer",
"minimum": 0,
"description": "Tokens the model produced. Not billed.",
"examples": [
212
]
}
}
},
"evaluation_time_ms": {
"type": "number",
"description": "Time the model service spent on this request, in milliseconds. May be fractional. Excludes network time and Drex's own checks.",
"examples": [
142.6
]
},
"request_id": {
"$ref": "#/components/schemas/RequestId"
}
}
},
"Model": {
"type": "object",
"title": "Model",
"required": [
"name",
"description",
"release_date",
"alias_for"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Value to send as `model` in an evaluate request. A pinned version id, an alias such as `drex-latest`, or a retired id that a newer version now answers.",
"examples": [
"drex-v1.0",
"drex-latest",
"drex-v1.1"
]
},
"description": {
"type": "string"
},
"release_date": {
"type": "string",
"format": "date",
"description": "Calendar date, `YYYY-MM-DD`. For an alias, the release date of the version it points to. For a retired id, its own release date."
},
"alias_for": {
"type": [
"string",
"null"
],
"description": "For an alias or a retired id, the pinned version that serves and bills its requests; the description of a retired id starts with `Retired on`. `null` for a pinned version.",
"examples": [
"drex-v1.5",
null
]
}
}
},
"ModelsResponse": {
"type": "object",
"title": "Models response",
"required": [
"models",
"request_id"
],
"additionalProperties": false,
"properties": {
"models": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Model"
}
},
"request_id": {
"$ref": "#/components/schemas/RequestId"
}
}
}
},
"headers": {
"X-Request-Id": {
"description": "The request id. Same value as `request_id` in the body.",
"schema": {
"$ref": "#/components/schemas/RequestId"
}
},
"X-Typesafe-Request-Id": {
"description": "The same request id, under the header name TypeSafe SDK clients read.",
"schema": {
"$ref": "#/components/schemas/RequestId"
}
},
"Access-Control-Expose-Headers": {
"description": "Always `retry-after, retry-after-ms, x-request-id, x-typesafe-request-id`, so browser clients can read those headers.",
"schema": {
"type": "string"
}
},
"Retry-After": {
"description": "Whole seconds to wait before retrying: `retry-after-ms` divided by 1000 and rounded up.",
"schema": {
"type": "integer",
"minimum": 1
}
},
"Retry-After-Ms": {
"description": "Milliseconds to wait before retrying. 1000 for a concurrency limit, the time until the window frees up (never below 1000) for a per-minute limit, 2000 for capacity or a model-service failure, 30000 when the API is paused.",
"schema": {
"type": "integer",
"minimum": 1000
}
},
"Server-Timing": {
"description": "Per-phase timings for a successful evaluation, for example `auth;dur=1.2, limit;dur=3.4, ndm;dur=140.1`.",
"schema": {
"type": "string"
}
}
},
"responses": {
"Unauthorized": {
"description": "The `Authorization` header is missing, is not `Bearer nace_sk_...` with 43 URL-safe characters, or names a key that does not exist or was revoked.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"invalid_key": {
"summary": "Missing or invalid key (real capture)",
"value": {
"error": {
"type": "authentication_error",
"message": "Invalid API key. Pass a nace_sk_ key as `Authorization: Bearer `."
},
"request_id": "req_bd2dffad68f5f5fa4ce702e9bd516fb4"
}
}
}
}
}
},
"PaymentRequired": {
"description": "The account's spendable credit is zero. Top up on the dashboard Billing page. `GET /v1/models` does not return this.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"no_credit": {
"summary": "No spendable credit",
"value": {
"error": {
"type": "insufficient_credit",
"message": "This account has no spendable credit. Top up to keep going."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"UnprocessableEntity": {
"description": "The body is not JSON, fails validation, is over the byte limit, or is over the model's token limit. `error.issues` lists every problem. The request is not billed.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"wrong_model": {
"summary": "TypeSafe default model",
"value": {
"error": {
"type": "invalid_request_error",
"message": "model: unknown model \"jev-latest\"; use \"drex-v1.0\", \"drex-v1.5\" or \"drex-latest\"",
"issues": [
{
"path": "model",
"message": "unknown model \"jev-latest\"; use \"drex-v1.0\", \"drex-v1.5\" or \"drex-latest\""
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"two_issues": {
"summary": "instructions was an object",
"value": {
"error": {
"type": "invalid_request_error",
"message": "questions.a.instructions: must be a string",
"issues": [
{
"path": "questions.a.instructions",
"message": "must be a string"
},
{
"path": "questions.a.instructions",
"message": "noul needs instructions or criteria"
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"too_many_bytes": {
"summary": "Over the byte limit",
"value": {
"error": {
"type": "invalid_request_error",
"message": "request body must be at most 1048576 bytes",
"issues": [
{
"path": "",
"message": "request body must be at most 1048576 bytes"
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"too_many_tokens": {
"summary": "Over the model row limit",
"value": {
"error": {
"type": "invalid_request_error",
"message": "Request too long: the state plus its longest question exceed the 139,264-token limit of drex-v1.5. Shorten the state or that question.",
"issues": [
{
"path": "state",
"message": "exceeds the 139,264-token limit"
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"state_too_long": {
"summary": "Over the model state limit",
"value": {
"error": {
"type": "invalid_request_error",
"message": "Request too long: the state exceeds the 131,072-token state limit of drex-v1.5. Shorten the state.",
"issues": [
{
"path": "state",
"message": "exceeds the 131,072-token state limit"
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"TooManyRequests": {
"description": "The account is over its requests-per-minute or in-flight limit. Limits are per account and shared by all its keys and the Playground. Wait `retry-after-ms` and retry.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
},
"retry-after": {
"$ref": "#/components/headers/Retry-After"
},
"retry-after-ms": {
"$ref": "#/components/headers/Retry-After-Ms"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"rpm": {
"summary": "Requests per minute",
"value": {
"error": {
"type": "rate_limit_error",
"message": "Requests per minute exceeded for this account."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"concurrency": {
"summary": "Too many in flight",
"value": {
"error": {
"type": "rate_limit_error",
"message": "Too many concurrent requests for this account."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"InternalError": {
"description": "An unexpected failure inside Drex. Retry once, then contact support with the `request_id`. The request is not billed.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"exception": {
"summary": "Unhandled exception",
"value": {
"error": {
"type": "internal_error",
"message": "Something went wrong on our side."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"Overloaded": {
"description": "Not caused by the request. The API is paused, a shared capacity pool is full, the rate limiter is unreachable, or the model service did not answer. The response never includes upstream status or detail. Wait `retry-after-ms` and retry the same request.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
},
"retry-after": {
"$ref": "#/components/headers/Retry-After"
},
"retry-after-ms": {
"$ref": "#/components/headers/Retry-After-Ms"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"paused": {
"summary": "API paused (retry-after-ms 30000)",
"value": {
"error": {
"type": "overloaded",
"message": "Drex is temporarily unavailable. Retry after 30 seconds."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"capacity": {
"summary": "Shared pool full or limiter unreachable (retry-after-ms 2000)",
"value": {
"error": {
"type": "overloaded",
"message": "Drex is at capacity. Retry shortly."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"upstream": {
"summary": "Model service timeout or failure (retry-after-ms 2000)",
"value": {
"error": {
"type": "overloaded",
"message": "Drex is temporarily unavailable. Retry shortly."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/v1/systemone": {
"post": {
"operationId": "systemOne",
"tags": [
"Evaluate"
],
"summary": "Evaluate questions",
"description": "Send any JSON `state` and one to 512 typed questions. Each question gets an answer with calibrated probabilities under the same id. Checks run in this order and stop at the first failure: API pause (529), key (401, 402), rate limits (429, 529), body validation (422), model evaluation (422 for too many tokens, 529 when the model service does not answer within 55 seconds). Only a 200 is billed, at the per-input-token price. Unknown top-level keys are ignored.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemOneRequest"
},
"examples": {
"support_ticket": {
"summary": "Three question types on one support ticket",
"value": {
"model": "drex-v1.0",
"state": "I was charged twice for my March invoice. Please refund one of the charges.",
"questions": {
"category": {
"type": "choice",
"instructions": "What is this support ticket about?",
"criteria": {
"billing": "Payments, invoices, refunds, or charges",
"technical": "Bugs, errors, or outages",
"account": "Login, profile, or permissions",
"other": null
}
},
"sentiment": {
"type": "score",
"instructions": "How upset is the customer?",
"criteria": [
"Calm",
"Mildly annoyed",
"Frustrated",
"Angry"
]
},
"wants_refund": {
"type": "noul",
"instructions": "Is the customer asking for a refund?"
}
}
}
},
"json_state": {
"summary": "Object state with noul criteria",
"value": {
"state": {
"ticket_id": "T-1042",
"plan": "enterprise",
"messages": [
{
"from": "customer",
"text": "Our SSO login has been broken since this morning and 40 people are locked out."
}
]
},
"questions": {
"is_outage": {
"type": "noul",
"instructions": "Does this describe a service outage?",
"criteria": {
"true": "Many users cannot use the product",
"false": "A single user or a question"
}
},
"priority": {
"type": "choice",
"instructions": "Which priority should this ticket get?",
"criteria": {
"p0": "Production down for many users",
"p1": "Major feature broken",
"p2": "Minor issue",
"p3": "Question or request"
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Every question answered. `usage.input_tokens` is debited from the account's credit after the response is sent.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
},
"server-timing": {
"$ref": "#/components/headers/Server-Timing"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemOneResponse"
},
"examples": {
"support_ticket": {
"summary": "Answers for the support ticket example (real capture)",
"value": {
"model": "drex-v1.0",
"answers": {
"category": {
"type": "choice",
"choice": "billing",
"confidence": 0.986,
"probabilities": {
"billing": 0.9895,
"technical": 0.005,
"account": 0.0006,
"other": 0.0049
}
},
"sentiment": {
"type": "score",
"score": 1.5306,
"legend": [
"Calm",
"Mildly annoyed",
"Frustrated",
"Angry"
],
"probabilities": {
"0": 0.1572,
"1": 0.3516,
"2": 0.2946,
"3": 0.1966
},
"confidence": 0.7183
},
"wants_refund": {
"type": "noul",
"noul": 0.9868
}
},
"usage": {
"input_tokens": 106,
"output_tokens": 212
},
"evaluation_time_ms": 142.6,
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"json_state": {
"summary": "Answers for the object-state example (real capture)",
"value": {
"model": "drex-v1.0",
"answers": {
"is_outage": {
"type": "noul",
"noul": 0.7632
},
"priority": {
"type": "choice",
"choice": "p0",
"confidence": 0.8621,
"probabilities": {
"p0": 0.8966,
"p1": 0.1007,
"p2": 0.0021,
"p3": 0.0007
}
}
},
"usage": {
"input_tokens": 103,
"output_tokens": 103
},
"evaluation_time_ms": 133,
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"402": {
"$ref": "#/components/responses/PaymentRequired"
},
"422": {
"$ref": "#/components/responses/UnprocessableEntity"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
},
"500": {
"$ref": "#/components/responses/InternalError"
},
"529": {
"$ref": "#/components/responses/Overloaded"
}
}
}
},
"/v1/models": {
"get": {
"operationId": "listModels",
"tags": [
"Models"
],
"summary": "List models",
"description": "Returns the models this API serves. Requires a valid API key. Does not check credit, so it works while the account is at zero, and does not count toward rate limits.",
"responses": {
"200": {
"description": "The model list.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelsResponse"
},
"examples": {
"models": {
"summary": "Current catalog",
"value": {
"models": [
{
"name": "drex-v1.0",
"description": "Drex System One decision model. Typed questions in, calibrated probabilities out.",
"release_date": "2026-09-24",
"alias_for": null
},
{
"name": "drex-v1.5",
"description": "Drex 1.5 decision model. Typed questions in, calibrated probabilities out; states up to 131,072 tokens.",
"release_date": "2026-09-28",
"alias_for": null
},
{
"name": "drex-latest",
"description": "Points to drex-v1.5 (Drex 1.5). Moves to a newer version only on an announced date.",
"release_date": "2026-09-28",
"alias_for": "drex-v1.5"
},
{
"name": "drex-v1.1",
"description": "Retired on 2026-09-28. Requests that name it are served by drex-v1.5 (Drex 1.5) at its price.",
"release_date": "2026-09-25",
"alias_for": "drex-v1.5"
}
],
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"529": {
"$ref": "#/components/responses/Overloaded"
}
}
}
}
}
}
```
# List models (/docs/api-reference/models)
```json
{
"openapi": "3.2.0",
"info": {
"title": "Drex API",
"description": "Decision-model API by Nace.AI. Send a JSON `state` and a map of typed questions (`noul`, `choice`, `score`) and receive calibrated probabilities for each. Billed per input token. Authenticate with a `nace_sk_` API key from the dashboard.",
"version": "1.0.0",
"contact": {
"name": "Nace.AI Support",
"email": "support@nace.ai"
}
},
"servers": [
{
"url": "https://drex.nace.ai",
"description": "Production"
}
],
"tags": [
{
"name": "Evaluate",
"description": "Evaluate typed questions against a state."
},
{
"name": "Models",
"description": "List the models this API serves."
}
],
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "nace_sk_",
"description": "An API key created on the dashboard API Keys page. The key is `nace_sk_` followed by 43 URL-safe characters (`A-Z`, `a-z`, `0-9`, `_`, `-`) and is shown once at creation. Send it as `Authorization: Bearer `. Anything else returns `401 authentication_error`. An account can hold at most 3 active keys."
}
},
"schemas": {
"RequestId": {
"type": "string",
"pattern": "^req_[a-f0-9]{32}$",
"description": "Identifier of this request, `req_` plus 32 hex characters. Present in every response body and in the `x-request-id` and `x-typesafe-request-id` headers. Quote it in support requests.",
"examples": [
"req_bd2dffad68f5f5fa4ce702e9bd516fb4"
]
},
"ParseIssue": {
"type": "object",
"title": "Issue",
"description": "One validation problem. `path` is a dotted path into the request body, for example `questions.a.criteria`, or an empty string for the body as a whole.",
"required": [
"path",
"message"
],
"additionalProperties": false,
"properties": {
"path": {
"type": "string",
"description": "Dotted path to the offending field. Empty for the whole body.",
"examples": [
"questions.a.instructions",
"model",
"state",
""
]
},
"message": {
"type": "string",
"description": "What is wrong with the field.",
"examples": [
"must be a string",
"is required"
]
}
}
},
"ErrorBody": {
"type": "object",
"title": "Error",
"required": [
"type",
"message"
],
"properties": {
"type": {
"type": "string",
"description": "Error class. The HTTP status follows from it: `authentication_error` 401, `insufficient_credit` 402, `invalid_request_error` 422, `rate_limit_error` 429, `internal_error` 500, `overloaded` 529.",
"enum": [
"authentication_error",
"insufficient_credit",
"invalid_request_error",
"rate_limit_error",
"overloaded",
"internal_error"
]
},
"message": {
"type": "string",
"description": "Human-readable description. For `invalid_request_error` it is the first issue, written as `path: message`."
},
"issues": {
"type": "array",
"description": "Present only when `type` is `invalid_request_error`. Every validation problem found in one pass.",
"items": {
"$ref": "#/components/schemas/ParseIssue"
}
}
}
},
"ErrorEnvelope": {
"type": "object",
"title": "Error response",
"description": "Every non-2xx response has this shape.",
"required": [
"error",
"request_id"
],
"additionalProperties": false,
"properties": {
"error": {
"$ref": "#/components/schemas/ErrorBody"
},
"request_id": {
"$ref": "#/components/schemas/RequestId"
}
}
},
"JsonValue": {
"title": "State",
"description": "Any JSON value: string, number, boolean, null, object, or array. Drex passes it to the model as is. The key must be present, but `null` is accepted.",
"examples": [
"I was charged twice for my March invoice. Please refund one of the charges.",
{
"ticket_id": "T-1042",
"plan": "enterprise",
"messages": [
{
"from": "customer",
"text": "Our SSO login has been broken since this morning and 40 people are locked out."
}
]
}
]
},
"NoulQuestion": {
"type": "object",
"title": "noul (yes or no)",
"description": "A yes-or-no question. Needs a non-empty `instructions` string, or at least one non-empty entry in `criteria`, or both. Unknown keys are ignored.",
"required": [
"type"
],
"properties": {
"type": {
"const": "noul"
},
"instructions": {
"type": "string",
"description": "The question to answer about the state. Must be a string when present. `null` and objects are rejected with `must be a string`.",
"examples": [
"Is the customer asking for a refund?"
]
},
"criteria": {
"type": "object",
"description": "What a yes and a no look like. Keys other than `true` and `false` are ignored.",
"properties": {
"true": {
"type": [
"string",
"null"
],
"description": "Description of a yes answer.",
"examples": [
"Many users cannot use the product"
]
},
"false": {
"type": [
"string",
"null"
],
"description": "Description of a no answer.",
"examples": [
"A single user or a question"
]
}
}
}
}
},
"ChoiceQuestion": {
"type": "object",
"title": "choice (pick one label)",
"description": "Pick one label from `criteria`. Unknown keys are ignored.",
"required": [
"type",
"criteria"
],
"properties": {
"type": {
"const": "choice"
},
"instructions": {
"type": "string",
"description": "The question to answer about the state. Must be a string when present.",
"examples": [
"What is this support ticket about?"
]
},
"criteria": {
"type": "object",
"description": "Map of label to description. At least one label. A description may be `null`.",
"minProperties": 1,
"additionalProperties": {
"type": [
"string",
"null"
]
},
"examples": [
{
"billing": "Payments, invoices, refunds, or charges",
"technical": "Bugs, errors, or outages",
"account": "Login, profile, or permissions",
"other": null
}
]
}
}
},
"ScoreQuestion": {
"type": "object",
"title": "score (ordered levels)",
"description": "Rate the state on an ordered scale. The index of a level in `criteria` is its score, starting at 0. Unknown keys are ignored.",
"required": [
"type",
"criteria"
],
"properties": {
"type": {
"const": "score"
},
"instructions": {
"type": "string",
"description": "The question to answer about the state. Must be a string when present.",
"examples": [
"How upset is the customer?"
]
},
"criteria": {
"type": "array",
"description": "Level labels in ascending order. At least one level. Every item must be a string.",
"minItems": 1,
"items": {
"type": "string"
},
"examples": [
[
"Calm",
"Mildly annoyed",
"Frustrated",
"Angry"
]
]
}
}
},
"Question": {
"title": "Question",
"description": "One of three question types, chosen by `type`.",
"oneOf": [
{
"$ref": "#/components/schemas/NoulQuestion"
},
{
"$ref": "#/components/schemas/ChoiceQuestion"
},
{
"$ref": "#/components/schemas/ScoreQuestion"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"noul": "#/components/schemas/NoulQuestion",
"choice": "#/components/schemas/ChoiceQuestion",
"score": "#/components/schemas/ScoreQuestion"
}
}
},
"SystemOneRequest": {
"type": "object",
"title": "Evaluate request",
"description": "Unknown top-level keys are ignored.",
"required": [
"state",
"questions"
],
"properties": {
"state": {
"$ref": "#/components/schemas/JsonValue"
},
"questions": {
"type": "object",
"minProperties": 1,
"maxProperties": 512,
"additionalProperties": {
"$ref": "#/components/schemas/Question"
},
"description": "Map of question id to question. Ids must be non-empty and not whitespace only. At least 1 and at most 512 questions. `state` and `questions`, serialized together as JSON, must be at most 1048576 bytes. The state must fit the model's state limit, and the state plus the longest question must fit its row limit: 131,072 and 139,264 tokens for `drex-v1.5`, 32,768 and 32,768 for `drex-v1.0`. Text only: images, audio, video and PDFs (base64 data URLs, raw base64 files, or OpenAI, Anthropic and Gemini media content parts) anywhere in `state` or `questions` are rejected with a 422."
},
"model": {
"type": "string",
"pattern": "^(?:drex|nacedm)-[A-Za-z0-9.-]+$",
"description": "Optional. A pinned version id, such as `drex-v1.0` or `drex-v1.5`, is always served by that version. The alias `drex-latest` is served by the version it points to, currently `drex-v1.5`; it moves to a newer version only on an announced date. `GET /v1/models` lists each alias with its `alias_for`. A retired id, such as `drex-v1.1`, is served by its successor, and the response `model` reports the successor; `GET /v1/models` lists it with `alias_for` set. A registered id that is not available yet returns `422` with issue path `model`. Any other `drex-*` name, or a legacy `nacedm-*` name, is served like `drex-latest`, as is a request without `model`. Any other value, including a TypeSafe SDK default such as `jev-latest`, returns `422` with issue path `model`.",
"examples": [
"drex-v1.0",
"drex-v1.5",
"drex-latest"
]
}
}
},
"NoulAnswer": {
"type": "object",
"title": "noul answer",
"required": [
"type",
"noul"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "noul"
},
"noul": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Probability of a yes answer, from zero to one.",
"examples": [
0.9868
]
}
}
},
"ChoiceAnswer": {
"type": "object",
"title": "choice answer",
"required": [
"type",
"choice",
"confidence",
"probabilities"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "choice"
},
"choice": {
"type": "string",
"description": "The selected label. One of the keys of the question's `criteria`.",
"examples": [
"billing"
]
},
"confidence": {
"type": "number",
"description": "Average of the confidence (probability) scores the model produced for this answer. Tracks the top value in `probabilities` but is a different number.",
"examples": [
0.986
]
},
"probabilities": {
"type": "object",
"description": "Probability per label, keyed by the labels of `criteria`.",
"additionalProperties": {
"type": "number"
},
"examples": [
{
"billing": 0.9895,
"technical": 0.005,
"account": 0.0006,
"other": 0.0049
}
]
}
}
},
"ScoreAnswer": {
"type": "object",
"title": "score answer",
"required": [
"type",
"score",
"legend",
"probabilities",
"confidence"
],
"additionalProperties": false,
"properties": {
"type": {
"const": "score"
},
"score": {
"type": "number",
"description": "Expected score: the sum of each level's index times its probability. It can fall between two levels.",
"examples": [
1.5306
]
},
"legend": {
"type": "array",
"description": "The level labels from the question's `criteria`, in order. Index is score.",
"items": {
"type": "string"
},
"examples": [
[
"Calm",
"Mildly annoyed",
"Frustrated",
"Angry"
]
]
},
"probabilities": {
"type": "object",
"description": "Probability per level, keyed by the level index as a string: `\"0\"` to `\"n-1\"`.",
"additionalProperties": {
"type": "number"
},
"examples": [
{
"0": 0.1572,
"1": 0.3516,
"2": 0.2946,
"3": 0.1966
}
]
},
"confidence": {
"type": "number",
"description": "Average of the confidence (probability) scores the model produced for this answer. Lower means the model was less sure.",
"examples": [
0.7183
]
}
}
},
"Answer": {
"title": "Answer",
"description": "Shape follows the question's `type`.",
"oneOf": [
{
"$ref": "#/components/schemas/NoulAnswer"
},
{
"$ref": "#/components/schemas/ChoiceAnswer"
},
{
"$ref": "#/components/schemas/ScoreAnswer"
}
],
"discriminator": {
"propertyName": "type",
"mapping": {
"noul": "#/components/schemas/NoulAnswer",
"choice": "#/components/schemas/ChoiceAnswer",
"score": "#/components/schemas/ScoreAnswer"
}
}
},
"SystemOneResponse": {
"type": "object",
"title": "Evaluate response",
"required": [
"model",
"answers",
"usage",
"evaluation_time_ms",
"request_id"
],
"additionalProperties": false,
"properties": {
"model": {
"type": "string",
"enum": [
"drex-v1.0",
"drex-v1.5"
],
"description": "The pinned version that served the request, never an alias or a retired id. It is the version `model` named, the successor of a retired id (`drex-v1.5` for `drex-v1.1`), or the version `drex-latest` points to (currently `drex-v1.5`) when `model` is `drex-latest`, is omitted, or is another `drex-*` or `nacedm-*` name. The request is billed at this version's price."
},
"answers": {
"type": "object",
"description": "One answer per question, under the same ids as the request's `questions`.",
"additionalProperties": {
"$ref": "#/components/schemas/Answer"
}
},
"usage": {
"type": "object",
"description": "Token counts from the model service. Only `input_tokens` is billed.",
"required": [
"input_tokens",
"output_tokens"
],
"additionalProperties": false,
"properties": {
"input_tokens": {
"type": "integer",
"minimum": 0,
"description": "Tokens in `state` and `questions` as counted by the model. Billed at the per-token price on the Pricing page.",
"examples": [
106
]
},
"output_tokens": {
"type": "integer",
"minimum": 0,
"description": "Tokens the model produced. Not billed.",
"examples": [
212
]
}
}
},
"evaluation_time_ms": {
"type": "number",
"description": "Time the model service spent on this request, in milliseconds. May be fractional. Excludes network time and Drex's own checks.",
"examples": [
142.6
]
},
"request_id": {
"$ref": "#/components/schemas/RequestId"
}
}
},
"Model": {
"type": "object",
"title": "Model",
"required": [
"name",
"description",
"release_date",
"alias_for"
],
"additionalProperties": false,
"properties": {
"name": {
"type": "string",
"description": "Value to send as `model` in an evaluate request. A pinned version id, an alias such as `drex-latest`, or a retired id that a newer version now answers.",
"examples": [
"drex-v1.0",
"drex-latest",
"drex-v1.1"
]
},
"description": {
"type": "string"
},
"release_date": {
"type": "string",
"format": "date",
"description": "Calendar date, `YYYY-MM-DD`. For an alias, the release date of the version it points to. For a retired id, its own release date."
},
"alias_for": {
"type": [
"string",
"null"
],
"description": "For an alias or a retired id, the pinned version that serves and bills its requests; the description of a retired id starts with `Retired on`. `null` for a pinned version.",
"examples": [
"drex-v1.5",
null
]
}
}
},
"ModelsResponse": {
"type": "object",
"title": "Models response",
"required": [
"models",
"request_id"
],
"additionalProperties": false,
"properties": {
"models": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Model"
}
},
"request_id": {
"$ref": "#/components/schemas/RequestId"
}
}
}
},
"headers": {
"X-Request-Id": {
"description": "The request id. Same value as `request_id` in the body.",
"schema": {
"$ref": "#/components/schemas/RequestId"
}
},
"X-Typesafe-Request-Id": {
"description": "The same request id, under the header name TypeSafe SDK clients read.",
"schema": {
"$ref": "#/components/schemas/RequestId"
}
},
"Access-Control-Expose-Headers": {
"description": "Always `retry-after, retry-after-ms, x-request-id, x-typesafe-request-id`, so browser clients can read those headers.",
"schema": {
"type": "string"
}
},
"Retry-After": {
"description": "Whole seconds to wait before retrying: `retry-after-ms` divided by 1000 and rounded up.",
"schema": {
"type": "integer",
"minimum": 1
}
},
"Retry-After-Ms": {
"description": "Milliseconds to wait before retrying. 1000 for a concurrency limit, the time until the window frees up (never below 1000) for a per-minute limit, 2000 for capacity or a model-service failure, 30000 when the API is paused.",
"schema": {
"type": "integer",
"minimum": 1000
}
},
"Server-Timing": {
"description": "Per-phase timings for a successful evaluation, for example `auth;dur=1.2, limit;dur=3.4, ndm;dur=140.1`.",
"schema": {
"type": "string"
}
}
},
"responses": {
"Unauthorized": {
"description": "The `Authorization` header is missing, is not `Bearer nace_sk_...` with 43 URL-safe characters, or names a key that does not exist or was revoked.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"invalid_key": {
"summary": "Missing or invalid key (real capture)",
"value": {
"error": {
"type": "authentication_error",
"message": "Invalid API key. Pass a nace_sk_ key as `Authorization: Bearer `."
},
"request_id": "req_bd2dffad68f5f5fa4ce702e9bd516fb4"
}
}
}
}
}
},
"PaymentRequired": {
"description": "The account's spendable credit is zero. Top up on the dashboard Billing page. `GET /v1/models` does not return this.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"no_credit": {
"summary": "No spendable credit",
"value": {
"error": {
"type": "insufficient_credit",
"message": "This account has no spendable credit. Top up to keep going."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"UnprocessableEntity": {
"description": "The body is not JSON, fails validation, is over the byte limit, or is over the model's token limit. `error.issues` lists every problem. The request is not billed.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"wrong_model": {
"summary": "TypeSafe default model",
"value": {
"error": {
"type": "invalid_request_error",
"message": "model: unknown model \"jev-latest\"; use \"drex-v1.0\", \"drex-v1.5\" or \"drex-latest\"",
"issues": [
{
"path": "model",
"message": "unknown model \"jev-latest\"; use \"drex-v1.0\", \"drex-v1.5\" or \"drex-latest\""
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"two_issues": {
"summary": "instructions was an object",
"value": {
"error": {
"type": "invalid_request_error",
"message": "questions.a.instructions: must be a string",
"issues": [
{
"path": "questions.a.instructions",
"message": "must be a string"
},
{
"path": "questions.a.instructions",
"message": "noul needs instructions or criteria"
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"too_many_bytes": {
"summary": "Over the byte limit",
"value": {
"error": {
"type": "invalid_request_error",
"message": "request body must be at most 1048576 bytes",
"issues": [
{
"path": "",
"message": "request body must be at most 1048576 bytes"
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"too_many_tokens": {
"summary": "Over the model row limit",
"value": {
"error": {
"type": "invalid_request_error",
"message": "Request too long: the state plus its longest question exceed the 139,264-token limit of drex-v1.5. Shorten the state or that question.",
"issues": [
{
"path": "state",
"message": "exceeds the 139,264-token limit"
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"state_too_long": {
"summary": "Over the model state limit",
"value": {
"error": {
"type": "invalid_request_error",
"message": "Request too long: the state exceeds the 131,072-token state limit of drex-v1.5. Shorten the state.",
"issues": [
{
"path": "state",
"message": "exceeds the 131,072-token state limit"
}
]
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"TooManyRequests": {
"description": "The account is over its requests-per-minute or in-flight limit. Limits are per account and shared by all its keys and the Playground. Wait `retry-after-ms` and retry.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
},
"retry-after": {
"$ref": "#/components/headers/Retry-After"
},
"retry-after-ms": {
"$ref": "#/components/headers/Retry-After-Ms"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"rpm": {
"summary": "Requests per minute",
"value": {
"error": {
"type": "rate_limit_error",
"message": "Requests per minute exceeded for this account."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"concurrency": {
"summary": "Too many in flight",
"value": {
"error": {
"type": "rate_limit_error",
"message": "Too many concurrent requests for this account."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"InternalError": {
"description": "An unexpected failure inside Drex. Retry once, then contact support with the `request_id`. The request is not billed.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"exception": {
"summary": "Unhandled exception",
"value": {
"error": {
"type": "internal_error",
"message": "Something went wrong on our side."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"Overloaded": {
"description": "Not caused by the request. The API is paused, a shared capacity pool is full, the rate limiter is unreachable, or the model service did not answer. The response never includes upstream status or detail. Wait `retry-after-ms` and retry the same request.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
},
"retry-after": {
"$ref": "#/components/headers/Retry-After"
},
"retry-after-ms": {
"$ref": "#/components/headers/Retry-After-Ms"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorEnvelope"
},
"examples": {
"paused": {
"summary": "API paused (retry-after-ms 30000)",
"value": {
"error": {
"type": "overloaded",
"message": "Drex is temporarily unavailable. Retry after 30 seconds."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"capacity": {
"summary": "Shared pool full or limiter unreachable (retry-after-ms 2000)",
"value": {
"error": {
"type": "overloaded",
"message": "Drex is at capacity. Retry shortly."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"upstream": {
"summary": "Model service timeout or failure (retry-after-ms 2000)",
"value": {
"error": {
"type": "overloaded",
"message": "Drex is temporarily unavailable. Retry shortly."
},
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
}
}
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/v1/systemone": {
"post": {
"operationId": "systemOne",
"tags": [
"Evaluate"
],
"summary": "Evaluate questions",
"description": "Send any JSON `state` and one to 512 typed questions. Each question gets an answer with calibrated probabilities under the same id. Checks run in this order and stop at the first failure: API pause (529), key (401, 402), rate limits (429, 529), body validation (422), model evaluation (422 for too many tokens, 529 when the model service does not answer within 55 seconds). Only a 200 is billed, at the per-input-token price. Unknown top-level keys are ignored.",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemOneRequest"
},
"examples": {
"support_ticket": {
"summary": "Three question types on one support ticket",
"value": {
"model": "drex-v1.0",
"state": "I was charged twice for my March invoice. Please refund one of the charges.",
"questions": {
"category": {
"type": "choice",
"instructions": "What is this support ticket about?",
"criteria": {
"billing": "Payments, invoices, refunds, or charges",
"technical": "Bugs, errors, or outages",
"account": "Login, profile, or permissions",
"other": null
}
},
"sentiment": {
"type": "score",
"instructions": "How upset is the customer?",
"criteria": [
"Calm",
"Mildly annoyed",
"Frustrated",
"Angry"
]
},
"wants_refund": {
"type": "noul",
"instructions": "Is the customer asking for a refund?"
}
}
}
},
"json_state": {
"summary": "Object state with noul criteria",
"value": {
"state": {
"ticket_id": "T-1042",
"plan": "enterprise",
"messages": [
{
"from": "customer",
"text": "Our SSO login has been broken since this morning and 40 people are locked out."
}
]
},
"questions": {
"is_outage": {
"type": "noul",
"instructions": "Does this describe a service outage?",
"criteria": {
"true": "Many users cannot use the product",
"false": "A single user or a question"
}
},
"priority": {
"type": "choice",
"instructions": "Which priority should this ticket get?",
"criteria": {
"p0": "Production down for many users",
"p1": "Major feature broken",
"p2": "Minor issue",
"p3": "Question or request"
}
}
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "Every question answered. `usage.input_tokens` is debited from the account's credit after the response is sent.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
},
"server-timing": {
"$ref": "#/components/headers/Server-Timing"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemOneResponse"
},
"examples": {
"support_ticket": {
"summary": "Answers for the support ticket example (real capture)",
"value": {
"model": "drex-v1.0",
"answers": {
"category": {
"type": "choice",
"choice": "billing",
"confidence": 0.986,
"probabilities": {
"billing": 0.9895,
"technical": 0.005,
"account": 0.0006,
"other": 0.0049
}
},
"sentiment": {
"type": "score",
"score": 1.5306,
"legend": [
"Calm",
"Mildly annoyed",
"Frustrated",
"Angry"
],
"probabilities": {
"0": 0.1572,
"1": 0.3516,
"2": 0.2946,
"3": 0.1966
},
"confidence": 0.7183
},
"wants_refund": {
"type": "noul",
"noul": 0.9868
}
},
"usage": {
"input_tokens": 106,
"output_tokens": 212
},
"evaluation_time_ms": 142.6,
"request_id": "req_0123456789abcdef0123456789abcdef"
}
},
"json_state": {
"summary": "Answers for the object-state example (real capture)",
"value": {
"model": "drex-v1.0",
"answers": {
"is_outage": {
"type": "noul",
"noul": 0.7632
},
"priority": {
"type": "choice",
"choice": "p0",
"confidence": 0.8621,
"probabilities": {
"p0": 0.8966,
"p1": 0.1007,
"p2": 0.0021,
"p3": 0.0007
}
}
},
"usage": {
"input_tokens": 103,
"output_tokens": 103
},
"evaluation_time_ms": 133,
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"402": {
"$ref": "#/components/responses/PaymentRequired"
},
"422": {
"$ref": "#/components/responses/UnprocessableEntity"
},
"429": {
"$ref": "#/components/responses/TooManyRequests"
},
"500": {
"$ref": "#/components/responses/InternalError"
},
"529": {
"$ref": "#/components/responses/Overloaded"
}
}
}
},
"/v1/models": {
"get": {
"operationId": "listModels",
"tags": [
"Models"
],
"summary": "List models",
"description": "Returns the models this API serves. Requires a valid API key. Does not check credit, so it works while the account is at zero, and does not count toward rate limits.",
"responses": {
"200": {
"description": "The model list.",
"headers": {
"x-request-id": {
"$ref": "#/components/headers/X-Request-Id"
},
"x-typesafe-request-id": {
"$ref": "#/components/headers/X-Typesafe-Request-Id"
},
"access-control-expose-headers": {
"$ref": "#/components/headers/Access-Control-Expose-Headers"
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelsResponse"
},
"examples": {
"models": {
"summary": "Current catalog",
"value": {
"models": [
{
"name": "drex-v1.0",
"description": "Drex System One decision model. Typed questions in, calibrated probabilities out.",
"release_date": "2026-09-24",
"alias_for": null
},
{
"name": "drex-v1.5",
"description": "Drex 1.5 decision model. Typed questions in, calibrated probabilities out; states up to 131,072 tokens.",
"release_date": "2026-09-28",
"alias_for": null
},
{
"name": "drex-latest",
"description": "Points to drex-v1.5 (Drex 1.5). Moves to a newer version only on an announced date.",
"release_date": "2026-09-28",
"alias_for": "drex-v1.5"
},
{
"name": "drex-v1.1",
"description": "Retired on 2026-09-28. Requests that name it are served by drex-v1.5 (Drex 1.5) at its price.",
"release_date": "2026-09-25",
"alias_for": "drex-v1.5"
}
],
"request_id": "req_0123456789abcdef0123456789abcdef"
}
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"529": {
"$ref": "#/components/responses/Overloaded"
}
}
}
}
}
}
```