# POST /v1/decisions

Ask a decision model typed questions about a **state** (a message, a ticket, a record) and get one typed answer per question. A decision model does not write text. Each answer is a choice, a score, a yes/no, an order, a set of tags or extracted fields, with the probability or confidence behind it. Use it for routing, classification, moderation and other decision points in your code.

```bash
curl https://demo-api.routerplus.com/v1/decisions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"typesafe/jev-1.13",
       "state":"Help! My payouts have been failing for 3 days.",
       "questions":{"urgent":{"type":"noul","instructions":"Is this urgent?"}}}'
```

No SDK has a decisions method. Send plain HTTP.

Python:

```python
import os, httpx

r = httpx.post(
    "https://demo-api.routerplus.com/v1/decisions",
    headers={"Authorization": f"Bearer {os.environ['TM_API_KEY']}"},
    json={
        "model": "typesafe/jev-1.13",
        "state": "Help! My payouts have been failing for 3 days.",
        "questions": {"team": {"type": "choice", "instructions": "Which team?",
                               "criteria": {"billing": "Payments", "technical": "Bugs"}}},
    },
)
answer = r.json()["answers"]["team"]
print(answer["choice"], answer["confidence"])
```

Node:

```js
const r = await fetch("https://demo-api.routerplus.com/v1/decisions", {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.TM_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({
    model: "typesafe/jev-1.13",
    state: { ticket: "Help! My payouts have been failing for 3 days." },
    questions: { urgent: { type: "noul", instructions: "Is `ticket` urgent?" } },
  }),
});
const { answers, usage } = await r.json();
console.log(answers.urgent.noul, usage.cost);
```

## Models

The catalog has twelve decision models:

| Model | Catalog id | Made by | Providers |
|---|---|---|---|
| Jev 1.13 | `typesafe/jev-1.13` | TypeSafe | `typesafe` |
| Bespoke Nimble v4 | `bespokelabs/nimble-v4` | Bespoke Labs | `bespokelabs` |
| Bespoke Nimble v4 Max | `bespokelabs/nimble-v4-max` | Bespoke Labs | `bespokelabs` |
| Clef | `cloudflare/clef` | Cloudflare | `workers-ai` |
| Clef-flash | `cloudflare/clef-flash` | Cloudflare | `workers-ai` |
| Decider 2B | `routerplus/decider-2b` | RouterPlus | `routerplus` |
| Kev 4B | `routerplus/kev-4b` | RouterPlus | `routerplus` |
| Perplexity Decider v1 27B | `perplexity/pplx-decider-v1-27b` | Perplexity | `perplexity-decisions` |
| GPT-6 Luna Decisions | `openai/gpt-6-luna-decisions` | OpenAI | `openai-decisions` |
| Sage 1.2 | `levanto/sage-1.2` | Levanto | `levanto` |
| ScaleDown Classify-1 | `scaledown/classify-1` | ScaleDown | `scaledown` |
| GLiNER-2.5-Decide | `fastino/gliner-2.5-decide` | Fastino | `fastino` |

The gateway also accepts `bespokelabs/nimble-latest`, which no list shows. Bespoke picks the model that answers it. See [Bespoke Nimble](#bespoke-nimble).

Every call has the same envelope: `model` and `state` in, `answers` and `usage` out. The questions use the model's own format:

- Eleven models share one format, **System One** (TypeSafe's): Jev, the two Nimble models, Clef, Clef-flash, Decider 2B, Kev 4B, Perplexity Decider, GPT-6 Luna Decisions, Sage and ScaleDown Classify-1. They take a `questions` map. A body written for one of them runs on the others with only `model` changed, within each model's limits. ScaleDown Classify-1 bills each question as its own call.
- GLiNER takes a `schema` object: `classifications`, `entities`, `structures`, `relations`.
- A question in another model's format is a 400.
- The twelve are different models, so the gateway never fails over from one to another.

The gateway calls these endpoints:

| Model | Order | Provider | Endpoint the gateway calls | Model id sent upstream |
|---|---|---|---|---|
| Jev | 1 | TypeSafe (direct API) | `POST https://api.typesafe.ai/v1/systemone` | `jev-1.13.0` |
| Nimble v4, Nimble v4 Max | 1 | Bespoke Labs (direct API) | `POST https://api.bespokelabs.ai/v1/nimble/systemone` | `nimble-v4`, `nimble-v4-max` (and `nimble-latest` for `bespokelabs/nimble-latest`) |
| Clef | 1 | Cloudflare Workers AI (direct API) | `POST https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/@cf/cloudflare/clef` | `clef` |
| Clef-flash | 1 | Cloudflare Workers AI (direct API) | `POST https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/@cf/cloudflare/clef-flash` | `clef-flash` |
| Decider 2B | 1 | RouterPlus (our own service, direct API) | `POST /v1/decisions` on RouterPlus's endpoint | `decider-2b` |
| Kev 4B | 1 | RouterPlus (our own service, direct API) | `POST /v1/decisions` on RouterPlus's endpoint | `kev-4b` |
| Perplexity Decider | 1 | Perplexity (direct API) | `POST https://api.perplexity.ai/v1/decisions` | `pplx-decider-v1-27b` |
| GPT-6 Luna Decisions | 1 | OpenAI (direct API) | `POST https://api.openai.com/v1/decisions` | `gpt-6-luna` |
| ScaleDown Classify-1 | 1 | ScaleDown (direct API) | `POST https://api.scaledown.xyz/v1/scaledown` | `classify-1` |
| Sage | 1 | Levanto (direct API) | `POST https://sage.levanto.ai/v1/systemone` | `sage-latest` |
| GLiNER | 1 | Fastino (direct API) | `POST https://api.fastino.ai/v1/chat/completions` | `fastino/GLiNER-2.5-Decide` |

Each decision model has one provider, so a request has no second provider to try. The Nimble models share one Bespoke
deployment: one pool and one health circuit serve all of them.

- Decider 2B and Kev 4B share one RouterPlus deployment: one pool and one health circuit serve both.
- On Workers AI, `{account_id}` is our Cloudflare account, and the path names the model.
- OpenAI's API does not speak System One. The gateway translates your body into OpenAI's format, and OpenAI's answers back into Jev's shape (see [GPT-6 Luna Decisions](#gpt-6-luna-decisions)).
- The `x-tm-provider` header names the provider that answered.

To compare the models without code, use **Decision** in the [playground](/docs/playground). It translates one set of questions for each model.

## Authentication

| Header | Format |
|---|---|
| `Authorization` | `Bearer tm_vk_...` |
| `x-api-key` | `tm_vk_...` (also accepted, same key) |

A missing or invalid key returns 401 with `error_type: "auth"`.

## Request

The body is JSON, at most 1 MB (413 `request_too_large` above that). The gateway checks every rule on this page **before** it reserves money or calls a provider. A refused request has no ledger row, no `x-tm-attempts` header and no charge.

| Parameter | Type | Behavior |
|---|---|---|
| `model` | string, required | A decision model id. A dated id of a listed decision model routes as that model: `typesafe/jev-1.13-20260917` as `typesafe/jev-1.13`. Providers' own names are not ids (see the next table). A chat or image model is a 400 that names its route. An unknown id is a 404. |
| `state` | required | What the model evaluates. Its forms depend on the model: see [System One questions](#system-one-questions) and [GLiNER schema](#gliner-schema). |
| `questions` | object, the System One models, required | A map of question id to question. You choose the ids, and each answer comes back under its id. At least one question. On GLiNER, `questions` is a 400 that says the model takes a GLiNER `schema`. |
| `schema` | object, GLiNER only, required | GLiNER's own schema. See [GLiNER schema](#gliner-schema). On Jev, Nimble, Clef, Clef-flash, Decider 2B, Kev 4B, Perplexity Decider, GPT-6 Luna Decisions, Sage or ScaleDown Classify-1, `schema` is a 400 that says the model takes `questions`. |
| `threshold`, `include_confidence`, `include_spans` | GLiNER only | See [GLiNER options](#gliner-options). |
| `provider` | object | Routing controls: `only`, `order`, `ignore`, `require_parameters` and the rest. The gateway reads them and never forwards them. See [Routing policies](/docs/routing-policies). The provider ids are in the next table. |

Model names that are not ids, and provider ids:

| Model | Send as `model` | Not an id here | `provider` id |
|---|---|---|---|
| Jev | `typesafe/jev-1.13` | | `typesafe` |
| Nimble | `bespokelabs/nimble-v4`, `bespokelabs/nimble-v4-max` | Bespoke's names `nimble-v4`, `nimble-v4-max`, `nimble-latest`. `bespokelabs/nimble-latest` is accepted but not listed: the gateway sends it to Bespoke as `nimble-latest`, and Bespoke picks the model (see [Bespoke Nimble](#bespoke-nimble)). | `bespokelabs` |
| Clef, Clef-flash | `cloudflare/clef`, `cloudflare/clef-flash` | `clef`, `clef-flash`, `@cf/cloudflare/clef`, `@cf/cloudflare/clef-flash` | `workers-ai` (not `cloudflare`, which matches no route) |
| Decider 2B, Kev 4B | `routerplus/decider-2b`, `routerplus/kev-4b` | RouterPlus's own short names (`decider-2b`, `kev-4b`) are not ids here | `routerplus` |
| Perplexity Decider | `perplexity/pplx-decider-v1-27b` | `pplx-decider-v1-27b` | `perplexity-decisions` (not `perplexity`, which matches no route) |
| GPT-6 Luna Decisions | `openai/gpt-6-luna-decisions` | OpenAI's own name `gpt-6-luna` is our id for OpenAI's chat model GPT-6 Luna, so here it is a 400 that names the chat routes. `openai/gpt-6-luna` and `gpt-6-luna-decisions` without `openai/` are a 404. | `openai-decisions` for GPT-6 Luna Decisions (not `openai`, which is our OpenAI chat route) |
| Sage | `levanto/sage-1.2` | `sage-latest`, `levanto-sage-v1.3` | `levanto` |
| GLiNER | `fastino/gliner-2.5-decide` | | `fastino` |

### System One questions

Nine models take the same question format, TypeSafe's **System One**: Jev, the two Nimble models, Clef, Clef-flash, Decider 2B, Kev 4B, Perplexity Decider and GPT-6 Luna Decisions. This section holds for each of the nine. Some models add limits of their own: see [Bespoke Nimble](#bespoke-nimble), [Clef and Clef-flash](#clef-and-clef-flash), [Decider 2B and Kev 4B](#decider-2b-and-kev-4b), [Perplexity Decider v1 27B](#perplexity-decider-v1-27b), [GPT-6 Luna Decisions](#gpt-6-luna-decisions) and [Sage 1.2](#sage-12).

The examples use Jev. To run them on another System One model, change `model`.

**State.** A non-empty string, or any JSON object or array (a chat log, a record, application state). A question can point at a field by name: `` "Is `ticket.body` urgent?" ``.

**Questions.** Every question has a `type`, `instructions` and, for most types, `criteria`. `instructions` is a non-empty string, or a JSON object or array: put the question in one field and the data it refers to in others.

| `type` | `criteria` | The answer |
|---|---|---|
| `choice` | Required. An object of option name to description, 1 to 255 options. A description can be a string, JSON, or `null` when the name says enough. | `choice` (the most likely option), `probabilities` (every option), `confidence` |
| `score` | Required. An ordered array of level descriptions, low to high, 2 to 10 levels. | `score` (probability-weighted, can fall between levels), `legend`, `probabilities`, `confidence` |
| `noul` | Optional. `{"true": "...", "false": "..."}`: what yes and no mean. | `noul`: the probability that the answer is yes, 0 to 1 |

- A question takes only `type`, `instructions` and `criteria`. Any other field is a 400 that names it.
- A question with `kind` (the format of Levanto's own `/decide` API) is a 400 that says the model takes System One questions.
- Ask many questions in one call. The model reads the state once and answers every question against it, so one call with ten questions costs less and returns sooner than ten calls.
- Exceptions: Perplexity Decider and Sage read and bill the state once per question. GPT-6 Luna Decisions bills the state once per call, but each question adds about 150 input tokens.
- `confidence` is not the chance that the answer is right. Some models compute it their own way (see each model's Numbers row). Tune a threshold on the model you use, or use the probabilities.

```json
{
  "model": "typesafe/jev-1.13",
  "state": { "ticket": "Help! My payouts have been failing for 3 days." },
  "questions": {
    "team": {
      "type": "choice",
      "instructions": "Which team should handle `ticket`?",
      "criteria": { "billing": "Payments, invoices, refunds", "technical": "Bugs, outages, integrations", "sales": null }
    },
    "frustration": {
      "type": "score",
      "instructions": "How frustrated is the customer?",
      "criteria": ["Calm", "Annoyed", "Angry"]
    },
    "urgent": {
      "type": "noul",
      "instructions": "Is this urgent?",
      "criteria": { "true": "Explicitly time-sensitive", "false": "No urgency expressed" }
    }
  }
}
```

The sections below list the limits and behavior of each System One model. The gateway checks the rules above. It does not check each model's limits. The Playground's Decision mode does check them, before a run. If a body breaks a model's limits, the provider refuses it, and the gateway relays the refusal as an `upstream_error` with the provider's message, at no cost. [Limits and failures](#limits-and-failures) covers the rules for every provider: pool limits, relayed 429s, the health circuit and refusals of our account.

#### Bespoke Nimble

The gateway calls Bespoke Labs' own API.

| | Bespoke Nimble v4 and Nimble v4 Max |
|---|---|
| Catalog ids | `bespokelabs/nimble-v4` and `bespokelabs/nimble-v4-max`. Bespoke no longer serves Nimble v3: `bespokelabs/nimble-v3` is a 404. |
| Models | Nimble v4 is the faster, lower-cost model. Nimble v4 Max is Bespoke's most accurate model. It is larger and slower, and it costs more. The two return different probabilities for the same request, so check your thresholds when you change models. |
| `bespokelabs/nimble-latest` | Accepted, but no list shows it. Bespoke picks the model: Nimble v4 today, and a newer model when Bespoke releases one. It costs $0.04 per million input tokens, Bespoke's price for `nimble-latest`. The answer's `model` is `bespokelabs/nimble-latest`. To stay on one model, send that model's id. |
| Route | Bespoke Labs' API only, as `nimble-v4`, `nimble-v4-max` or `nimble-latest`. One deployment serves all of them. |
| Questions | 1 to 64 questions per call. A `choice` takes 2 to 255 options, so a one-option choice (which Jev takes) is refused. A `score` takes 2 to 10 levels through the gateway (Nimble itself takes up to 255). Bespoke refuses a body above these limits with a 422. |
| Structured content | `instructions` and a `choice` option's description may be a JSON object or array, as on Jev. Bespoke's published schema types them as strings, but a probe with both answered 200 (2026-10-13 on Nimble v4 and v4 Max). |
| Context | 262,144 tokens for each question's prompt on Nimble v4, Nimble v4 Max and `bespokelabs/nimble-latest`, the state included. Nimble refuses a longer prompt and never cuts one. Bespoke counts the state and the questions once per call. |
| Busy | Bespoke answers 503 with `Retry-After` while Nimble starts, 529 when it is busy (Bespoke says to retry after about one second) and 502 when the model fails. Each is a retryable `upstream_error` that counts against the health circuit, and one circuit covers all the Nimble models. Retry after a second or two, with backoff. The gateway does not relay Bespoke's `Retry-After` on a 503. |
| Numbers | Full precision. Jev's numbers have 2 decimals, and Nimble's are not rounded. A very small probability can come back in exponent form, for example `3.7e-06`. `confidence` is 1 when one option has all the probability and 0 when every option is equally likely. |
| Our limit | The pool takes 7 requests in flight at once, and each organization at most 5 of them. Bespoke allows our account 8, and our test environment uses one. The pool also takes 600 requests a minute (450 per organization) and 15,000,000 tokens a minute (11,250,000 per organization). The Nimble models share these limits. |
| Bespoke's limit | At most 8 requests at once for our whole account, over all the Nimble models. Above that Bespoke answers 429 with `Retry-After`, which the gateway relays (see [Limits and failures](#limits-and-failures)). |
| Retention | Bespoke keeps request content for up to 30 days, to debug the service and check for abuse, and then deletes it. The deployment declares `prompt_logging: "retained"`. Bespoke does not train on API content unless an organization opts in, and ours does not. See [Data policy](/docs/data-policy). |

#### Clef and Clef-flash

The gateway calls Cloudflare's own API, Workers AI. Clef-flash is the smaller model (9 billion parameters, against Clef's 27 billion). It costs $0.09 per million input tokens, against $0.24 for Clef.

| | Clef and Clef-flash |
|---|---|
| Route | Workers AI's REST API only, as `clef` and `clef-flash`. The path picks the model, and the gateway sets the same model in the body. It removes Cloudflare's envelope (`result`, `success`, `errors`, `messages`), so you get Jev's answer shape. A 200 whose envelope does not say `success: true` is a 502 `upstream_error` and costs nothing. |
| Questions | 1 to 64 questions per call. Each question id (a key of `questions`) must match `^[A-Za-z0-9_.-]{1,100}$`. A `choice` takes 2 to 255 options, so a one-option choice (which Jev takes) is refused. A `score` takes 2 to 10 levels. Cloudflare refuses a body outside these limits with a 422 (or 400). |
| Structured content | `instructions`, a `score` level and a `choice` option's description may be a JSON object or array, as on Jev. An option's description may be `null`. Cloudflare's schema allows both, and a probe with JSON `instructions` and JSON option descriptions answered 200 (2026-10-02). |
| Context | 65,536 tokens, the state and every question together. Before the model runs, Cloudflare estimates the request's tokens at about 4 characters a token. It refuses a request estimated above 65,536 tokens (about 262,000 characters) with a 413. The gateway returns it as 413 `context_overflow`, and it costs nothing. Cloudflare's schema says a long state is cut to fit, but in our test (2026-10-02) a request of 522,286 characters was refused, not cut. |
| Images | Not accepted through the gateway. Clef's `images` field, Cloudflare's addition to System One, is dropped and recorded in `x-tm-dropped-params`. With `"provider": {"require_parameters": true}` it is a 400. |
| `request_id` | Do not send a top-level `request_id`. The gateway drops and records it. Workers AI reads that field as a lookup in its queue of async requests, and answers 404. |
| Numbers | 4 decimals. Cloudflare reports `output_tokens: 0` on every answer. `confidence` is on Clef's own scale, which Cloudflare describes only as derived from the probabilities. On one live answer (three options, the top one at 0.8274), Clef's `confidence` was 0.5654, where Jev's formula gives 0.7411. A threshold that you tuned on Jev's `confidence` does not transfer to Clef. |
| Busy | When Workers AI is busy, Cloudflare answers 429 with code 3040, "Capacity temporarily exceeded, please try again." The gateway relays it with Cloudflare's words. Without a `Retry-After`, the relayed 429 has no `retry-after` either, and the pool pauses for 1 s. A 5xx (for example Cloudflare's 500, "Model execution failed") counts against the health circuit. A 408, Cloudflare's own timeout, is a retryable 504 `upstream_error` that does not count. |
| Our limit | Clef and Clef-flash share one pool. It takes 200 requests a minute (150 per organization), at most 8 requests at a time (6 per organization) and 2,000,000 tokens a minute (1,500,000 per organization). |
| Cloudflare's limit | Cloudflare publishes 300 requests a minute for Clef's task type, Text Generation. It does not say whether Clef and Clef-flash share that limit, so our pool stays below it. Cloudflare's 429 with code 3036 means that our account's free daily allocation is used up. The gateway returns 503 `model_unavailable` with `metadata.retryable: false`. This is our account, never yours, and it never counts against the health circuit. |
| Retention | Cloudflare says that it does not read, store or train on requests to Clef, or on their responses. The deployment declares `prompt_logging: "none"`. The gateway calls Workers AI directly, never through Cloudflare's AI Gateway, which logs prompts and responses by default. See [Data policy](/docs/data-policy). |

#### Decider 2B and Kev 4B

RouterPlus runs its own decision-model service. Both models run on our own GPUs in the United States, and each reads English.

| | Decider 2B | Kev 4B |
|---|---|---|
| Catalog id | `routerplus/decider-2b` | `routerplus/kev-4b` |
| Name at RouterPlus | `decider-2b` | `kev-4b` |
| Input, per million tokens | $0 | $0 |
| Context | 25,600 tokens. A longer input is cut (see below). | 8,192 tokens. A longer input is refused. |

Rules for both:

| | Decider 2B and Kev 4B |
|---|---|
| Route | RouterPlus's endpoint only. One deployment serves both models, so one pool, one health circuit and one pause after a 429 cover both. |
| Price | Free: input and output are $0, and `usage.cost` is `0` on every call. RouterPlus runs these models itself and charges nothing for them. See [Billing](#billing). |
| Questions | The gateway checks the System One rules above. Both models take every form that Jev takes: `instructions` as a string or as a JSON object or array, `score` levels as strings or as JSON objects (the answer's `legend` gives the levels as you sent them), and a `choice` option's description as a string, JSON or `null`. In probes on 2026-10-02, each model also answered a `choice` with one option, a `choice` of 255 options, a `score` of 2 and of 10 levels, a yes/no with described `true` and `false`, a JSON object or array as the state, and 200 questions in one request. A question that RouterPlus calls malformed is its 400, relayed with RouterPlus's reason for each question id (see [Errors](#errors)). It costs nothing. |
| Long input | Decider 2B reads up to 25,600 tokens and cuts a longer input. It keeps the question and its options first, then the start of the state, and drops the rest without an error. Kev 4B never cuts: above 8,192 tokens it refuses the request with a 400. The gateway bills RouterPlus's `usage.input_tokens`, which counts only the tokens the model read: at most the model's context. In a test on 2026-10-02, a Decider 2B request of 30,053 tokens billed 25,600. RouterPlus can refuse a long input instead of cutting it (its `truncate` field), but the gateway drops that field. Through the gateway, a long input on Decider 2B is always cut. Put what matters at the start of the state. Use Kev 4B for an input up to 8,192 tokens and Decider 2B up to 25,600. |
| Response cache | RouterPlus keeps each answer, with its usage, for 600 s (10 minutes). The cache key is the RouterPlus API key, the model and the exact request body, byte for byte. The gateway sends every request with our one RouterPlus key, so an identical request from any customer within 600 s can get the cached answer: the same `answers` and the same input count in `usage`. Your response keeps its own `id`. RouterPlus marks a hit with `usage.cached: true` and the header `X-Cache: HIT` in its own response. A cached answer costs $0: the listing prices cached input at $0. The gateway's response marks it with `usage.cached_input_tokens`, equal to `usage.input_tokens`. RouterPlus skips its cache for a request with `"cache": false` or a `Cache-Control: no-cache` header. The gateway forwards neither, so you cannot skip the cache through the gateway. |
| Request id | RouterPlus sends `X-Request-Id` (`req_…`) on every answer. The gateway stores it with the request's record as the provider's id. Your response's `id` and its `x-request-id` header are the gateway's own. |
| Cold start | After a quiet period, the first request starts the model on a GPU. This takes 15 to 20 s. The gateway waits up to 60 s for a decision, so a cold start is a slow 200. |
| Our limit | The pool takes 11,400 requests a minute, 15,000,000 tokens a minute and 59 requests in flight at once. Each organization may use 75 % of each: 8,550 requests a minute, 11,250,000 tokens a minute, 44 at once. The 59 in flight is the burst limit, since a pool has no limit per second. At RouterPlus's typical 0.31 s a call, 59 in flight carry about 190 a second, just above the pool's 11,400 a minute. With our test environment's 10 a second, that stays within RouterPlus's own limit. Your organization's own limits bind first. |
| RouterPlus's limit | About 200 requests a second for the whole endpoint, shared with our test environment. A burst of repeated requests can reach it, because cached answers return sooner. Above it RouterPlus answers 429. The gateway relays it and pauses the pool for both models. |
| Busy or failed | A 5xx from RouterPlus counts against the health circuit: two in a row open it for 30 s for both models. A RouterPlus 401, 402 or 403 refuses our key, never yours: you get 503 `model_unavailable`. |
| Retention | The RouterPlus model service writes no request logs. Its answer cache keeps each answer and its usage for 600 s, under a key made from our RouterPlus key and the request. The cache is all it keeps, so the deployment declares `prompt_logging: "none"`. Nothing is used for training. See [Data policy](/docs/data-policy). |

#### Perplexity Decider v1 27B

The gateway calls Perplexity's own API. This model reads and bills the state once per question, so the cost grows with the number of questions.

| | Perplexity Decider v1 27B |
|---|---|
| Route | Perplexity's API only, as `pplx-decider-v1-27b`. Perplexity answers in Jev's shape, with no envelope. The gateway stores Perplexity's `x-request-id` with the request's record as the provider's id. |
| The state, once per question | Each question runs its own prompt, which holds and bills the whole state. In a test on 2026-10-02, a state of 1,843 tokens billed 1,843 tokens with one question and 9,215 with five. One call with ten questions saves round trips, not tokens: it costs about what ten calls cost. The question ids are not billed. `output_tokens` is one per question, at $0. A `choice` with one option runs no prompt and costs nothing: a request of only such questions returns 0 tokens and costs $0. |
| Questions | 1 to 128 questions per call. Above 128 is Perplexity's 400, "Each request needs between 1 and 128 questions". A `choice` takes 1 to 255 options, as on Jev. A `score` takes 2 to 10 levels. A question id is any key the gateway takes. |
| Structured content | `instructions`, a `score` level and a `choice` option's description may be a JSON object or array, as on Jev. An option's description may be `null`. A probe with JSON `instructions`, JSON option descriptions and JSON score levels answered 200 (2026-10-02). |
| Context | 262,144 tokens for each question's prompt: the state and that one question. A request with several questions can bill more than that in all: a state of 52,348 tokens with six questions billed 314,088 tokens (2026-10-02). Above the limit, Perplexity refuses the request with a 400, "Input length (262144) exceeds or equals model's maximum context length (262144)". The gateway returns it as 400 `context_overflow`, with `metadata.retryable: false`, and it costs nothing. Perplexity does not cut a long state. |
| Image parts | Decision models take text and JSON. An object with `"type": "image_url"`, anywhere in the state or in a question, is a 400 from the gateway before any money is reserved, for example `state[1]: an object with "type": "image_url" is an image part, and model "perplexity/pplx-decider-v1-27b" takes text and JSON only`. Other JSON, including a field named `type` with another value, goes as you sent it. |
| Numbers | Full precision, as Nimble's. On a `choice`, `confidence` is Jev's: `(p_max − 1/n) / (1 − 1/n)`, so a threshold that you tuned on Jev's choice confidence transfers. On a `score` it does not. Perplexity's score confidence is `1 − Σ pᵢ·|i − m| / ((1/L)·Σⱼ |j − m|)`, where `m` is the most likely level and `L` the number of levels. Jev measures the second sum about the central level, so the two agree only when the most likely level is the central one. On one live answer (three levels at 0.756, 0.193 and 0.051), Perplexity's `confidence` was 0.7051, where Jev's formula gives 0.5577. Tune a score threshold on Perplexity Decider. |
| Busy | Perplexity answers 504 when the model does not answer in about a minute. The body is an HTML page. The gateway returns a retryable 504 `upstream_error` that counts against the health circuit. In Perplexity's tests, a few hundred input tokens answered in under 2 s, and a prompt near the context in 23 s. |
| Our limit | The pool takes 540 requests a minute, at most 8 requests at a time and 15,000,000 tokens a minute. Each organization may use 75 % of each: 405 requests a minute, 6 at a time, 11,250,000 tokens a minute. Tokens are held at the gateway's estimate while a request runs, with the state counted once per question, and corrected to Perplexity's count when it settles. A long state with many questions can be larger than a token limit on its own. Such a request is a 429 `rate_limit` with `x-tm-limit-kind: tpm` before anything reaches Perplexity, and it cannot pass as it is. Ask fewer questions per call, or send a shorter state. |
| Perplexity's limit | Perplexity allows our organization 10 requests a second, over a rolling second, shared with our test environment. Our pools keep to that as a minute budget (600 a minute in all) and do not count seconds, so a burst inside one second can get Perplexity's 429. Perplexity also limits large bursts of tokens, with no published number. Above a limit Perplexity answers 429, "Request rate limit exceeded, please try again later.", with `Retry-After: 1`, which the gateway relays. |
| Retention | Perplexity says that it does not retain query data sent through its API and does not train on it: its API has zero day retention of prompt data by default. Its compute runs on AWS in North America. The deployment declares `prompt_logging: "none"`. See [Data policy](/docs/data-policy). |

#### GPT-6 Luna Decisions

The gateway calls OpenAI's own API. OpenAI's API has its own format, so the gateway translates your System One body on the way out and OpenAI's answers on the way back.

| | GPT-6 Luna Decisions |
|---|---|
| Catalog id | `openai/gpt-6-luna-decisions`. Released 2026-10-06, the day OpenAI's Decisions API opened to every developer as a public beta. |
| Route | OpenAI's API only, as `gpt-6-luna`. One provider, no failover. The gateway stores OpenAI's `x-request-id` (`req_…`) with the request's record as the provider's id. |
| Each question's prompt | OpenAI bills the state once per call, but it runs a prompt for each question, and each question adds about 150 input tokens. In tests on 2026-10-07, one yes/no about a two-character state billed 156 tokens, two billed 307 and five billed 760. With a state of 2,795 tokens, each added question cost 151 tokens more. The question ids are not billed. One call with many questions saves round trips and the state's tokens, but each question still costs about 150 tokens. The gateway holds 192 tokens for each question (see Our limit below). |
| Questions | 1 to 200 questions per call. A `choice` takes 2 to 255 options, so a one-option choice (which Jev takes) is refused. A `score` takes 2 to 10 levels. A question id is any key the gateway takes: a 300-character id and an id with a space and an accent answered 200 (2026-10-07). OpenAI refuses a body outside these limits with a 400. |
| Translation | The state and `instructions`: a string goes as you sent it, and a JSON object or array goes as compact JSON text. A `choice`: each option becomes one of OpenAI's choices, with the option name as its value and the description beside it (a JSON description as JSON text, and no description for `null`). A `score`: each level becomes one of OpenAI's levels, with the level's text as its label (a JSON level as JSON text). The answer's `legend` gives the levels as you sent them. A `noul`: OpenAI's predicate, the probability that the statement is true. A predicate has no criteria, so the gateway writes your `true` and `false` descriptions under the instructions, as two lines: `true: …` and `false: …`. In a live test the descriptions moved the probability the way they say: from 0.82 to 0.90 on an urgent ticket, and from 0.10 to 0.02 on a calm one. |
| Error names | OpenAI names a question in an error by its place in OpenAI's list. The gateway puts your question id (at most its first 64 characters) and System One's field name in its place: OpenAI's `choices` and `levels` are both `criteria`. |
| Refusal | OpenAI can decline one question and answer the rest. That question's answer is `{"type": "refusal"}`, under its id, with no value. No other System One model sends this type, so check each answer's `type` before you read `choice`, `score` or `noul`. The call is still billed: OpenAI bills the input of every question, the declined one too. In our tests (2026-10-07), OpenAI declined a question with empty instructions, and a `choice` of 255 options whose names meant nothing. The Playground shows such an answer as **Refused**. |
| Numbers | 2 decimals, as Jev's: OpenAI rounds them. `confidence` is OpenAI's own, and OpenAI does not document it as Jev's formula. A threshold that you tuned on Jev's `confidence` does not transfer to GPT-6 Luna Decisions. |
| Context | 272,000 tokens, the state and every question together. OpenAI's listed price holds up to there. OpenAI bills input above 272,000 tokens at twice the rate. The model itself reads up to 1,050,000 tokens. The request body is at most 1 MB. |
| Images | Not through the gateway. The state goes to OpenAI as text, so an object such as `{"type": "image_url", ...}` in the state is read as text, not as an image. Decision models take text and JSON. |
| Busy | In our probes a call took about 0.1 to 0.3 s. A 5xx from OpenAI counts against the health circuit. OpenAI's 404 `model_not_found` means that our access to the model was withdrawn. You get 404 `model_unavailable` with OpenAI's words, ``The model `gpt-6-luna` does not exist or you do not have access to it.`` |
| Our limit | The pool takes 24,000 requests a minute, 150,000,000 tokens a minute and 64 requests in flight at once. Each organization may use 75 % of each: 18,000 requests a minute, 112,500,000 tokens a minute, 48 at once. Tokens are held at the gateway's estimate while a request runs, with 192 tokens for each question, so a request with many questions is held near what OpenAI bills. The hold is corrected to OpenAI's count when the request settles. |
| OpenAI's limit | OpenAI allows our organization 30,000 requests and 180,000,000 tokens a minute on the model. Our test environment uses the same OpenAI key, and its pool takes 600 requests a minute, 6,000,000 tokens a minute and 8 in flight. Above OpenAI's limit, OpenAI answers 429. The gateway relays it with OpenAI's `retry-after` when it sends one. |
| Retention | OpenAI keeps abuse-monitoring logs of `/v1/decisions` requests for up to 30 days. Zero Data Retention needs an eligible account and an agreement with OpenAI, and our organization does not have it. So the deployment declares `prompt_logging: "retained"`. OpenAI does not train on API data by default. See [Data policy](/docs/data-policy). |

#### Sage 1.2

The gateway calls Levanto's System One API. Like Perplexity Decider, Sage reads and bills the state once per question.

| | Sage 1.2 |
|---|---|
| Route | Levanto's API only, as `sage-latest`. Levanto answers in Jev's shape. |
| The state, once per question | Levanto reads and bills the state once for each question, so one call with ten questions saves round trips, not tokens. The gateway holds the state once per question too. |
| Questions | A `choice` takes 2 to 120 options and a `score` 2 to 26 levels. Every question gets an answer, never `null`. Levanto refuses a request outside these limits. |
| Not offered | Levanto's own `/decide` format (`kind`, sort, tags), images, grounding and `reasoning`. A question with `kind` is a 400. `reasoning` and `latency_mode` are dropped and recorded. |
| Retention | The deployment declares `prompt_logging: "retained"`. See [Data policy](/docs/data-policy). |

### GLiNER schema

GLiNER-2.5-Decide is a small encoder model. It reads one text and fills in a schema. It classifies the text against your labels, and it can extract entities, structured fields and relations.

**State.** Text only: a non-empty string, or `{"kind": "text", "value": "..."}`. A list, an image or any other state is a 400 that names the field. To judge a record, send it as text, for example the JSON as a string.

**Schema.** GLiNER takes no `questions`. It takes `schema`, Fastino's own schema object, which the gateway checks and forwards unchanged. `schema` has only these four keys. Send at least one, and each key you send must be non-empty (an empty list or object is a 400 that names the key). A flat list in place of the object is a 400 (Fastino has deprecated it).

| Key | Form | Limits |
|---|---|---|
| `classifications` | A list of `{"task", "labels", "multi_label"?, "top_k"?, "cls_threshold"?}` | 1 to 50 tasks. `task` is 1 to 256 characters, unique in the schema, and not `__proto__`. `labels` is 1 to 100 unique non-empty strings. `multi_label` is a boolean. `top_k` is an integer, 1 or more. `cls_threshold` is a number from 0 to 1. Any other key is a 400 that names it. |
| `entities` | A list of entity names, or of `{"name", "description"?}` | 1 to 50 entities. Each name is a non-empty string, unique in the list. |
| `structures` | An object of structure name to a list of fields, each `"field::type::description"` | 1 to 50 structures. A name is 1 to 256 characters and not `__proto__`. 1 to 50 non-empty field strings per structure. |
| `relations` | A list of relation names, or an object of name to `{"description"?, "threshold"?}` | 1 to 50 unique non-empty names. `threshold` is a number from 0 to 1. A `head` or `tail` key is a 400. |

- **Labels are plain strings.** A label cannot be an object with a description. To describe a label, put the description in the label itself: `"credit: store credit"`. Descriptions change results. On the same refund ticket, the labels `refund`, `credit`, `deny` chose `refund`, and the same labels with descriptions chose `credit`. The answer carries the label text as you sent it.
- **The task name is the question.** A classification has no `instructions` field. GLiNER reads the task name, so a task named `"Does the customer ask for money back?"` with the labels `["yes", "no"]` is a yes/no question.
- **Result names share one namespace.** GLiNER puts every result at the top level of its answer: each task under its task name, each structure under its structure name, the entities under `entities` and the relations under `relation_extraction`. At Fastino, two results with one name overwrite each other without an error. The gateway refuses such a collision with a 400 that names both, for example a task named `entities` in a request that also asks for entities.

```json
{
  "model": "fastino/gliner-2.5-decide",
  "state": "I was charged twice for my March invoice. Please refund the order from Acme Corp. Signed, Jane Doe.",
  "schema": {
    "classifications": [
      { "task": "action", "labels": ["refund", "credit", "deny"] },
      { "task": "issues", "labels": ["double_charge", "angry_customer", "fraud"], "multi_label": true }
    ],
    "entities": ["person", { "name": "organization", "description": "business or institution name" }]
  }
}
```

#### GLiNER options

Three optional top-level fields go to Fastino unchanged. Leave them out to use Fastino's defaults.

| Field | Values | What it does |
|---|---|---|
| `threshold` | A number from 0 to 1. Default 0.5. | The confidence a result needs to be returned. A task's own `cls_threshold` wins for that task. Lower values return more results. Higher values return fewer, surer ones. |
| `include_confidence` | Boolean. Default `true`. | With `false`, a single-label answer is the bare label string, and a multi-label answer is a list of label strings. |
| `include_spans` | Boolean. Default `true`. | With `true`, each entity carries `start` and `end`: its character offsets in the text. |

- A single-label task returns its top label only. `top_k` does not add more labels to the answer.
- When no label reaches the threshold, the task's answer is `null`. Set `"cls_threshold": 0` on the task to always get the top label and its confidence.
- A task with `multi_label: true` returns every label at or above the threshold, so `"cls_threshold": 0` returns every label with its confidence.

#### Store

Fastino keeps each inference unless the request says `store: false`. **The gateway always sends `store: false`.** A `store` field that you send is dropped and recorded, never forwarded. Our Fastino account also has Zero Data Retention on, so Fastino does not train on your text. See [Data policy](/docs/data-policy).

#### Not offered yet

The gateway does not reach these parts of Fastino's API:

- Fine-tuned GLiNER models (Fastino's training-job ids).
- Fastino's own batch and async routes.
- Several messages. The gateway sends Fastino one user message: the state. GLiNER does not read a system message.

### Parameters that do not apply

Decision models take no sampling or output controls. The gateway forwards no `temperature`, `max_tokens`, `stream`, `seed`, `user` or `metadata` to them. None offers prompt caching, so `cache_control` does nothing. RouterPlus's answer cache (see [Decider 2B and Kev 4B](#decider-2b-and-kev-4b)) is not prompt caching, and `cache_control` does not reach it.

A top-level field that the model does not take is **dropped and recorded**, never forwarded. RouterPlus's own `truncate` and `cache` fields are dropped in this way:

| Model | Top-level fields it takes | Examples of fields dropped |
|---|---|---|
| Jev, Nimble, Clef, Clef-flash, Decider 2B, Kev 4B, Perplexity Decider, GPT-6 Luna Decisions, Sage, ScaleDown Classify-1 | `model`, `state`, `questions`, `provider` | `temperature`, `reasoning`, `latency_mode`, `images`, `request_id`, `truncate`, `cache` |
| GLiNER | `model`, `state`, `schema`, `provider`, `threshold`, `include_confidence`, `include_spans` | `store`, `temperature`, `messages` |

The dropped names are in the `x-tm-dropped-params` response header and on the attempt's ledger row. To refuse instead, send `"provider": {"require_parameters": true}`. A field that would be dropped is then a 400 before any money is reserved.

## Response

HTTP 200, `application/json`. Every decision model uses the same envelope: `id`, `object`, `model`, `answers` and `usage`.

### System One answers

Every System One model returns this shape. The differences:

| Model | `model` | Numbers | `output_tokens` |
|---|---|---|---|
| Jev | `typesafe/jev-1.13` | 2 decimals | As reported |
| Nimble | its catalog id, for example `"model": "bespokelabs/nimble-v4"` | full precision | As reported |
| Clef, Clef-flash | `"model": "cloudflare/clef"` or `"model": "cloudflare/clef-flash"` | 4 decimals | `"output_tokens": 0` |
| Decider 2B, Kev 4B | their catalog ids | As reported | As reported |
| Perplexity Decider | `"model": "perplexity/pplx-decider-v1-27b"` | full precision | one per question |
| GPT-6 Luna Decisions | `"model": "openai/gpt-6-luna-decisions"` | 2 decimals | `"output_tokens": 0` |

```json
{
  "id": "5d0c1c4e-3f7a-4c55-9d1b-2f0e7f6f2a10",
  "object": "decision",
  "model": "typesafe/jev-1.13",
  "answers": {
    "team": { "type": "choice", "choice": "billing",
              "probabilities": { "billing": 0.88, "technical": 0.12, "sales": 0 }, "confidence": 0.81 },
    "frustration": { "type": "score", "score": 1.05,
                     "legend": { "0": "Calm", "1": "Annoyed", "2": "Angry" },
                     "probabilities": { "0": 0, "1": 0.95, "2": 0.05 }, "confidence": 0.92 },
    "urgent": { "type": "noul", "noul": 0.95 }
  },
  "usage": { "input_tokens": 436, "output_tokens": 71, "cost": 0.000018 }
}
```

`answers` holds one typed answer per question, under the ids you sent, as the provider returned them. GPT-6 Luna Decisions can also return `{"type": "refusal"}` for a question that OpenAI declined (see [GPT-6 Luna Decisions](#gpt-6-luna-decisions)). Check each answer's `type` before you read its value.

### GLiNER answers

`answers` is GLiNER's result, unchanged. Fastino returns it as a JSON string in `choices[0].message.content`, and the gateway parses it into an object. For the request in [GLiNER schema](#gliner-schema):

```json
{
  "id": "0b7e4c2a-6f1d-4a39-9c85-3e2d1f7a8b64",
  "object": "decision",
  "model": "fastino/gliner-2.5-decide",
  "answers": {
    "action": { "label": "refund", "confidence": 0.9327635765075684 },
    "issues": [ { "label": "double_charge", "confidence": 0.872347354888916 } ],
    "entities": {
      "person": [ { "text": "Jane Doe", "confidence": 0.99609375, "start": 90, "end": 98 } ],
      "organization": [ { "text": "Acme Corp", "confidence": 0.98828125, "start": 71, "end": 80 } ]
    }
  },
  "usage": { "input_tokens": 33, "output_tokens": 91, "cost": 0 }
}
```

| You asked | Key in `answers` | Value |
|---|---|---|
| A single-label task | The task name | `{"label", "confidence"}`: the top label. `null` when no label reaches the threshold (0.5 unless you set one; `"cls_threshold": 0` always returns the top label). With `include_confidence: false`, the bare label string. |
| A multi-label task | The task name | `[{"label", "confidence"}, ...]`: every label at or above the threshold, possibly none. With `include_confidence: false`, a list of label strings. |
| Entities | `entities` | An object of entity name to `[{"text", "confidence", "start", "end"}, ...]`. `start` and `end` are there when `include_spans` is on. |
| A structure | The structure name | A list of records, each field as `{"text", "confidence"}`, for example `{"order": [{"id": {"text": "4411", "confidence": 1.0}}]}`. |
| Relations | `relation_extraction` | `{"relations": [...]}`. |

- **A confidence is not a calibrated probability.** It is GLiNER's score for that label or span, from 0 to 1. Multi-label confidences are independent and do not sum to 1. Compare them within one model, not with System One's probabilities.
- GLiNER has no "not sure" answer. A low confidence shows that it is unsure.
- The gateway accepts a 200 only when the content parses to a JSON object with every task and structure name you asked for, plus `entities` and `relation_extraction` when you asked for them. Anything else is an `upstream_error` and costs nothing.

### Usage and headers

| Field | Meaning |
|---|---|
| `id` | The gateway's request id, also in the `x-request-id` header. Look the request up with `GET /v1/generation?id=`. |
| `model` | The catalog id that was billed. The provider's own name for the model is in the `x-tm-upstream-model` header. |
| `usage.input_tokens` | Input tokens, billed at the model's input rate. The provider's count (Fastino's `prompt_tokens` for GLiNER). On Decider 2B, at most the model's context. On Perplexity Decider and Sage, the state once per question. |
| `usage.output_tokens` | Output tokens, billed at the model's output rate. Every decision model prices output at $0. Sage, Clef, Clef-flash and GPT-6 Luna Decisions report 0, and Perplexity Decider one per question. For GLiNER, Fastino's `completion_tokens`. |
| `usage.cached_input_tokens` | Present only on an answer from RouterPlus's cache (Decider 2B, Kev 4B). Equal to `usage.input_tokens`, billed at the cached-input price, $0. |
| `usage.cost` | USD, the full debit for the request after any discount, including any attempt that failed over before this one. `0` on BYOK. Absent on static dev keys. |
| `usage.cost_before_discount` | Present only when a discount applied. What the request costs at the list price. |
| `usage.discount_percent` | Present only when a discount applied. The percent taken off. |

| Header | Value |
|---|---|
| `x-request-id` | The gateway's request id. |
| `x-tm-provider` | `typesafe`, `bespokelabs`, `workers-ai`, `routerplus`, `perplexity-decisions`, `openai-decisions`, `levanto` or `fastino` |
| `x-tm-upstream-model` | The provider's own name for the model, for example `clef` or `clef-flash` from `workers-ai`, `decider-2b` from `routerplus`, `pplx-decider-v1-27b` from `perplexity-decisions`, `gpt-6-luna` from `openai-decisions` |
| `x-tm-attempts` | The attempts made. |
| `x-tm-upstream-status` | The provider's status, when a provider answered. |
| `x-tm-discount-percent` | When a discount applied. |
| `x-tm-dropped-params` | When something was dropped. |

## Billing

A decision is metered like any other request: key limits, the reservation, the ledger and `usage.cost` all work as on the chat routes. Every decision model is priced per million tokens. Each charge rounds down to the micro-dollar.

Decider 2B and Kev 4B are free. The other models charge for input only. On Perplexity Decider and Sage the cost grows with the number of questions as well as with the state.

| Model | Input, per million tokens | Cached input, per million tokens | Output, per million tokens | What one call costs |
|---|---|---|---|---|
| Jev | $0.042 | – | $0 | A three-question call of about 400 input tokens costs about $0.000017. |
| Nimble v4 | $0.04 | – | $0 | A three-question call of 178 input tokens costs $0.000007. Bespoke's own price, with no markup. `bespokelabs/nimble-latest` costs the same: $0.04 is Bespoke's price for `nimble-latest`. |
| Nimble v4 Max | $0.09 | – | $0 | The same call costs $0.000016. Bespoke's own price, with no markup. |
| Clef | $0.24 | – | $0 | A call of 400 input tokens costs $0.000096. Cloudflare's own price, with no markup. |
| Clef-flash | $0.09 | – | $0 | A call of 400 input tokens costs $0.000036. Cloudflare's own price, with no markup. |
| Decider 2B | $0 | $0 | $0 | Nothing. The call is still metered, limited and recorded in `GET /v1/generation`, and it still needs an organization with credit. |
| Kev 4B | $0 | $0 | $0 | Nothing, as on Decider 2B. |
| Perplexity Decider | $0.04 | – | $0 | Five questions about a state of 1,843 tokens bill 9,215 input tokens: $0.000368. Perplexity's own price, with no markup. |
| GPT-6 Luna Decisions | $0.10 | – | $0 | A three-question call (a choice, a score and a yes/no about a short complaint) billed 396 input tokens: $0.000039. OpenAI's own price, with no markup. |
| Sage | $0.05 | – | $0 | Three questions about a short ticket read 204 input tokens: $0.00001. Levanto's own price, with no markup. |
| GLiNER | $0.03 | – | $0 | A call of 1,000 input tokens costs $0.00003. A call of 33 input tokens, as in the example in [GLiNER answers](#gliner-answers), rounds down to $0. |

A dash means the model has no cached-input price.

If a provider answers 200 without a usage object, the gateway bills its own estimate (the amount it reserved) and marks the attempt `estimated`:

- For GLiNER, the estimate comes from the size of the text and from the tasks, labels and extraction keys in the schema.
- For Perplexity Decider and Sage, it counts the state once per question.
- For GPT-6 Luna Decisions, it counts 192 tokens for each question.

These 200s are an `upstream_error` and cost nothing:

- A 200 that does not answer every question.
- A GLiNER 200 whose content is not the object described in [GLiNER answers](#gliner-answers).
- A GPT-6 Luna Decisions 200 that answers a question in another type, or with an option or a level that was not asked.

After a provider answers 200, the gateway never sends the same request to a second provider.

### Discount

A discount may apply to some or all decision models, each at its own percent, from every key, including the playground's. The list price does not change. The discount can change or end, so read it from each response. A discounted model's row in `/api/models.json` carries `discount_percent`, and its page shows the percent beside the struck list price.

- `usage.cost` is what you pay, after the discount.
- `usage.cost_before_discount` is the list price of the request.
- `usage.discount_percent` and the `x-tm-discount-percent` header give the percent.
- When no discount applies, these three are absent.

A discounted charge is the list charge × (100 − percent) / 100, rounded down to the micro-dollar. At 100 % it is 0, and the request is still metered, limited and recorded in `GET /v1/generation`. The discount is for organizations with credit. A balance of $0 is refused with `429 insufficient_quota` whatever the discount. A new account must complete verified browser sign-in and buy credits before its first decision.

On Decider 2B and Kev 4B the list price is already $0, so the discount changes nothing: `usage.cost` and `usage.cost_before_discount` are both `0`. When a discount covers one of them, the percent is still reported, so your code can read one shape for all twelve.

## Limits and failures

These rules hold for every decision model. Each model's section gives its own numbers.

**Pool limits.** Each deployment has a pool with limits on requests a minute, tokens a minute and requests in flight. Each organization may use 75 % of each. Your organization's own limits bind first: by default 300 requests a minute, 1,000,000 tokens a minute and 8 in flight (see [Rate limits & spend caps](/docs/limits)). A burst above any limit is a 429 `rate_limit` from the gateway, before anything reaches the provider. It carries `retry-after` and the `x-tm-limit-*` headers. `x-tm-limit-kind` names the limit: `rpm`, `tpm`, `concurrency`, or `cooldown` for the pause after a relayed provider 429.

**A provider's 429.** The gateway relays it as 429 `rate_limit` with `x-tm-upstream-status: 429`, the provider's words, and its `Retry-After` when it sends one. The gateway then pauses the deployment's pool for that `retry-after`, at least 1 s and at most 60 s. A request inside the pause gets the gateway's own 429 `rate_limit`, with `x-tm-limit-kind: cooldown`, `x-tm-limit-id: pool:…` and no `x-tm-upstream-status`. A relayed 429 never counts against the deployment's health circuit.

**The health circuit.** A provider 5xx is a retryable `upstream_error` that counts against the deployment's health circuit: two in a row open it for 30 s, and in that window every request but one probe gets 503 `gateway_error`, "all deployments cooling down". Retry after a few seconds, with backoff. A 408, a provider's own timeout, is a retryable 504 `upstream_error` that does not count.

**Our account.** A 401, 402 or 403 from any System One provider refuses our account, never yours. You get 503 `model_unavailable`, "out of capacity at the provider". Each refusal counts against the circuit, so after two of them most requests get the 503 `gateway_error` above.

**A provider's refusal.** A 4xx that says the request is wrong is relayed as an `upstream_error` with the provider's words and `x-tm-upstream-status`. It is not tried on a second provider, and it costs nothing.

### Context

| Model | Context | A longer request |
|---|---|---|
| Jev | 64 000 tokens per request through TypeSafe (32 000 for the state plus the longest question) | Refused by TypeSafe |
| Nimble | 262,144 tokens per question's prompt, state included | Refused by Bespoke |
| Clef, Clef-flash | 65,536 tokens per request, as Cloudflare estimates them | Refused with a 413 `context_overflow` |
| Decider 2B | 25,600 tokens | Cut, and only the tokens read are billed |
| Kev 4B | 8,192 tokens | Refused with a 400 |
| Perplexity Decider | 262,144 tokens per question's prompt, state included | Refused with a 400 `context_overflow` |
| GPT-6 Luna Decisions | 272,000 tokens per request at the listed price, up to 1,050,000 at twice the rate | The request body is at most 1 MB |
| Sage | 32 768 tokens per request | Refused by Levanto |
| GLiNER-2.5-Decide | 8,192 tokens | Refused by Fastino |

## Errors

Errors use the gateway's normal shape. See [Errors](/docs/errors).

| Status | `error.code` | When |
|---|---|---|
| 200 | none | GPT-6 Luna Decisions: OpenAI declined one question and answered the rest. That answer is `{"type": "refusal"}`, and the call is billed. |
| 400 | `invalid_request` | The body failed a rule above, such as a question in another model's format or a field a question does not take. The message names the field, for example `questions.team.criteria: at most 255 options`. |
| 400 | `invalid_request` | The request does not fit the model's grammar: `schema` on Jev, Nimble, Clef, Clef-flash, Decider 2B, Kev 4B, Perplexity Decider, GPT-6 Luna Decisions, Sage or ScaleDown Classify-1 (the message says the model takes `questions`), or `questions` on GLiNER (the message says the model takes a GLiNER `schema`). |
| 400 | `invalid_request` | GLiNER only: a state that is not text, a flat-list schema, a schema key or classification key GLiNER does not take, a limit above, two tasks with one name, or two results with one name (the message names both). |
| 400 | `invalid_request` | The model is not a decision model. The message names the route that serves it. |
| 400 | `invalid_request` | Perplexity Decider: an object with `"type": "image_url"` anywhere in the state or in a question. The message names where. |
| 400 | `context_overflow` | Perplexity Decider: one question's prompt (the state and that question) is above 262,144 tokens. The message is Perplexity's, prefixed `upstream perplexity-decisions returned 400:`. `metadata.retryable` is `false`: make the state shorter. |
| 400 | `upstream_error` | GPT-6 Luna Decisions: the request is outside OpenAI's limits, for example more than 200 questions, or a `choice` with one option. OpenAI's 400, with `x-tm-upstream-status: 400` and your question id in place of OpenAI's index. For example `upstream openai-decisions returned 400: Invalid 'questions.team.criteria': array too short. Expected an array with minimum length 2, but got an array with length 1 instead.` or `upstream openai-decisions returned 400: Invalid 'questions': array too long. Expected an array with maximum length 200, but got an array with length 201 instead.` It does not count against the circuit. |
| 404 | `model_unavailable` | No decision model has this id. `gpt-6-luna-decisions` without `openai/` is such an id: send `openai/gpt-6-luna-decisions` (`gpt-6-luna` is the chat model, a 400). |
| 404 | `model_unavailable` | GPT-6 Luna Decisions: OpenAI answered 404 `model_not_found`, because our access to the model was withdrawn. It is our account, never yours. |
| 413 | `request_too_large` | The body is larger than 1 MB. |
| 413 | `context_overflow` | Clef and Clef-flash: Cloudflare estimated the request above 65,536 tokens (its 413, code 5021), for example `upstream workers-ai returned 413: The estimated number of input and maximum output tokens (130569) exceeded this model context window limit (65536).` `metadata.retryable` is `false`: make the state shorter. |
| 422 and other 4xx | `upstream_error` | The provider refused the request as wrong. The message has the provider's reason, and `x-tm-upstream-status` its status. Cases by model follow this table. |
| 429 | `rate_limit` | Nimble: more than its pool allows, or Bespoke already runs 8 requests for our account, over all the Nimble models (Bespoke's 429). |
| 429 | `rate_limit` | Clef and Clef-flash: more than their shared pool allows, or Workers AI is busy (Cloudflare's 429 with code 3040). The pool pauses for at most 60 s (1 s when Cloudflare sends no `Retry-After`). Every other Cloudflare 429 is relayed the same way, except code 3036 (a 503). |
| 429 | `rate_limit` | Decider 2B and Kev 4B: more than their shared pool allows (59 in flight at once, 44 per organization), or RouterPlus's 429 at about 200 requests a second. |
| 429 | `rate_limit` | Perplexity Decider: more than its pool allows (a long state with many questions can exceed the token limit on its own, `x-tm-limit-kind: tpm`), or Perplexity's limit of 10 requests a second for our organization. |
| 429 | `rate_limit` | GPT-6 Luna Decisions: more than its pool allows, or OpenAI's limit for our organization (30,000 requests and 180,000,000 tokens a minute). |
| 429 | `rate_limit` | GLiNER: a Fastino 429. |
| 503 | `model_unavailable` | The provider refused our account. The message says the model is out of capacity at the provider. It is our account, never yours. See the cases after this table. |
| 503 | `gateway_error` | "all deployments cooling down", with `retry-after: 5`: the deployment's health circuit is open. While a provider refuses our account, only one request in each 30 s reaches it and gets the 503 `model_unavailable`. A provider's 429 never counts, so Nimble's limit of 8 requests at once, Cloudflare's busy answer on Clef, RouterPlus's limit of about 200 a second, Perplexity's limit of 10 a second and OpenAI's limit of 30,000 a minute stay 429s, and Cloudflare's code 3036 stays a 503 `model_unavailable`. |
| 504 | `upstream_error` | A System One provider answered 408, its own timeout, for example `upstream workers-ai returned 408: Request timeout` on Clef. Retry after a few seconds. A 408 does not count against the circuit. The gateway's own wait for a decision (60 s) also ends in a 504. |
| 5xx (and 529) | `upstream_error` | The provider failed. Retryable. Cases by model follow this table. |
| 401, 402, 403 | `auth` | The provider refused our own account. This is never your key's fault. For GLiNER a 402 or 403, and for every System One model (Jev, Nimble, Clef, Clef-flash, Decider 2B, Kev 4B, Perplexity Decider, GPT-6 Luna Decisions, Sage and ScaleDown Classify-1) a 401, 402 or 403, is the 503 above. |

**Provider refusals (422 and other 4xx), by model.** Each of these costs nothing.

- GLiNER. A Fastino 404 (an unknown upstream model id, which is our misconfiguration, never yours) is a 502 `upstream_error`, with `x-tm-upstream-status: 404`.
- Nimble: a request above Nimble's own limits (more than 64 questions, a `choice` with one option, or a question's prompt with the state above 262,144 tokens on Nimble v4, Nimble v4 Max or `bespokelabs/nimble-latest`) is Bespoke's 422, relayed with Bespoke's words.
- Clef and Clef-flash: a request outside Clef's own limits (more than 64 questions, a question id that does not match `^[A-Za-z0-9_.-]{1,100}$`, or a `choice` with one option) is Cloudflare's 422 (code 5012) or 400 (code 5006), relayed with Cloudflare's words, for example `upstream workers-ai returned 422: Request body failed validation: questions: Dictionary should have at most 64 items after validation, not 65`.
- Decider 2B and Kev 4B: a request that RouterPlus refuses is RouterPlus's 400, relayed here with RouterPlus's reason and `x-tm-upstream-status: 400`. RouterPlus refuses a malformed question (`invalid questions`, then the reason for each question id, for example `score needs criteria: a list of 2 to 10 levels`) and a Kev 4B request above 8,192 tokens (the question's id, then `ContextOverflow: branch too long`).
- Perplexity Decider: a request with more than 128 questions is Perplexity's 400, relayed with Perplexity's words (`Each request needs between 1 and 128 questions`).

**Refusals of our account (503 `model_unavailable`), by model:**

- Sage: Levanto refused our account (its 401, 402 or 403, for example when our month's usage is used up).
- GLiNER: Fastino refused our account for credit or billing (its 402 or 403).
- Nimble: Bespoke refused our account for credit (its 402). `metadata.retryable` is `false`.
- Clef and Clef-flash: Cloudflare refused our account, for our token or plan (its 401 or 403), or because the account's free daily allocation is used up (its 429 with code 3036, until 00:00 UTC). `metadata.retryable` is `false`.
- A 401, 402 or 403 from any System One provider is handled the same way. That covers TypeSafe (Jev), RouterPlus (Decider 2B and Kev 4B), Perplexity (Perplexity Decider) and OpenAI (GPT-6 Luna Decisions).

On a connection with your own provider key, that provider's 401, 402 or 403 is your account, and it keeps its status. Other models are not affected.

**Provider failures (5xx and 529), by model.** Each 5xx counts against the circuit, as in [Limits and failures](#limits-and-failures).

- Sage: Levanto answers 503 while Sage loads. Retry after a few seconds.
- GLiNER: Fastino answers 425 or 503 while GLiNER starts (a cold start). A cold start can also outlast the gateway's wait for a decision (60 s, a 504). Both are a retryable `upstream_error`, so retry after a few seconds.
- Nimble, Clef, Decider 2B and Kev 4B, Perplexity Decider and GPT-6 Luna Decisions: see the Busy row in each model's section. A Perplexity 504 is an HTML page, so the message has no words of Perplexity's.

## Examples

The examples at the top of this page use Jev. The examples below send the same System One body to other models, then a GLiNER schema.

### Nimble

Send the Jev body under a Nimble model's id, or `bespokelabs/nimble-latest` to let Bespoke pick. Keep to 1 to 64 questions, and 2 or more options in a choice.

```bash
curl https://demo-api.routerplus.com/v1/decisions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"bespokelabs/nimble-v4",
       "state":"Help! My payouts have been failing for 3 days.",
       "questions":{"urgent":{"type":"noul","instructions":"Is this urgent?"}}}'
```

Python:

```python
import os, httpx

r = httpx.post(
    "https://demo-api.routerplus.com/v1/decisions",
    headers={"Authorization": f"Bearer {os.environ['TM_API_KEY']}"},
    json={
        "model": "bespokelabs/nimble-v4",
        "state": "Help! My payouts have been failing for 3 days.",
        "questions": {
            "team": {"type": "choice", "instructions": "Which team?",
                     "criteria": {"billing": "Payments", "technical": "Bugs"}},
            "urgent": {"type": "noul", "instructions": "Is this urgent?"},
        },
    },
)
answers = r.json()["answers"]
print(answers["team"]["choice"], answers["urgent"]["noul"])  # e.g. billing 0.997817283712868
```

Node:

```js
const r = await fetch("https://demo-api.routerplus.com/v1/decisions", {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.TM_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({
    model: "bespokelabs/nimble-v4",
    state: { ticket: "Help! My payouts have been failing for 3 days." },
    questions: { urgent: { type: "noul", instructions: "Is `ticket` urgent?" } },
  }),
});
const { answers, usage } = await r.json();
console.log(answers.urgent.noul, usage.input_tokens, usage.cost);
```

### Clef

Send the Jev body under Clef's or Clef-flash's id, within [Clef's limits](#clef-and-clef-flash). The Node example uses Clef-flash.

```bash
curl https://demo-api.routerplus.com/v1/decisions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"cloudflare/clef",
       "state":"Checkout has been failing for every customer for the last hour.",
       "questions":{"urgent":{"type":"noul","instructions":"Is this support request urgent?"}}}'
```

Python:

```python
import os, httpx

r = httpx.post(
    "https://demo-api.routerplus.com/v1/decisions",
    headers={"Authorization": f"Bearer {os.environ['TM_API_KEY']}"},
    json={
        "model": "cloudflare/clef",
        "state": "Checkout has been failing for every customer for the last hour.",
        "questions": {
            "urgent": {"type": "noul", "instructions": "Is this support request urgent?"},
            "team": {"type": "choice", "instructions": "Which team should handle this request?",
                     "criteria": {"billing": "Payments and invoices", "technical": "Bugs and outages",
                                  "sales": "Plans and upgrades"}},
            "severity": {"type": "score", "instructions": "How severe is the customer impact?",
                         "criteria": ["No impact", "Minor", "Major", "Critical"]},
        },
    },
)
answers = r.json()["answers"]
team = answers["team"]
print(team["choice"], team["probabilities"][team["choice"]], team["confidence"])  # e.g. technical 0.8274 0.5654
```

Node:

```js
const r = await fetch("https://demo-api.routerplus.com/v1/decisions", {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.TM_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({
    model: "cloudflare/clef-flash",
    state: { ticket: "Checkout has been failing for every customer for the last hour." },
    questions: { urgent: { type: "noul", instructions: "Is `ticket` urgent?" } },
  }),
});
const { answers, usage } = await r.json();
console.log(answers.urgent.noul, usage.input_tokens, usage.output_tokens); // output_tokens is always 0
```

### RouterPlus models

Send the Jev body under Decider 2B's or Kev 4B's id. A cold start can take 15 to 20 s, so give your client a timeout above 60 s, the gateway's own wait.

```bash
curl https://demo-api.routerplus.com/v1/decisions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"routerplus/decider-2b",
       "state":"Help! My payouts have been failing for 3 days.",
       "questions":{"urgent":{"type":"noul","instructions":"Is this urgent?"}}}'
```

Python:

```python
import os, httpx

r = httpx.post(
    "https://demo-api.routerplus.com/v1/decisions",
    headers={"Authorization": f"Bearer {os.environ['TM_API_KEY']}"},
    json={
        "model": "routerplus/decider-2b",
        "state": "Help! My payouts have been failing for 3 days.",
        "questions": {
            "team": {"type": "choice", "instructions": "Which team?",
                     "criteria": {"billing": "Payments", "technical": "Bugs"}},
            "urgent": {"type": "noul", "instructions": "Is this urgent?"},
        },
    },
    timeout=70,
)
answers = r.json()["answers"]
print(answers["team"]["choice"], answers["urgent"]["noul"], r.headers["x-tm-upstream-model"])  # e.g. billing 0.97 decider-2b
```

Node:

```js
const r = await fetch("https://demo-api.routerplus.com/v1/decisions", {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.TM_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({
    model: "routerplus/decider-2b",
    state: { ticket: "Help! My payouts have been failing for 3 days." },
    questions: { urgent: { type: "noul", instructions: "Is `ticket` urgent?" } },
  }),
  signal: AbortSignal.timeout(70_000),
});
const { answers, usage } = await r.json();
console.log(answers.urgent.noul, usage.input_tokens, usage.cost);
```

### Perplexity

Send the Jev body under Perplexity Decider's id, with 1 to 128 questions. Each question is billed with the whole state, so ask only the questions you need.

```bash
curl https://demo-api.routerplus.com/v1/decisions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"perplexity/pplx-decider-v1-27b",
       "state":"Help! My payouts have been failing for 3 days.",
       "questions":{"urgent":{"type":"noul","instructions":"Is this urgent?"}}}'
```

Python:

```python
import os, httpx

r = httpx.post(
    "https://demo-api.routerplus.com/v1/decisions",
    headers={"Authorization": f"Bearer {os.environ['TM_API_KEY']}"},
    json={
        "model": "perplexity/pplx-decider-v1-27b",
        "state": "Help! My payouts have been failing for 3 days.",
        "questions": {"urgent": {"type": "noul", "instructions": "Is this urgent?"}},
    },
)
body = r.json()
print(body["answers"]["urgent"]["noul"], body["usage"]["input_tokens"])  # e.g. 0.9297849172276498 95
```

Node:

```js
const r = await fetch("https://demo-api.routerplus.com/v1/decisions", {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.TM_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({
    model: "perplexity/pplx-decider-v1-27b",
    state: { ticket: "Help! My payouts have been failing for 3 days." },
    questions: {
      team: { type: "choice", instructions: "Which team should handle `ticket`?", criteria: { billing: "Payments", technical: "Bugs" } },
      urgent: { type: "noul", instructions: "Is `ticket` urgent?" },
    },
  }),
});
const { answers, usage } = await r.json();
// Two questions: Perplexity bills the state twice.
console.log(answers.team.choice, answers.urgent.noul, usage.input_tokens, usage.cost);
```

### GPT-6 Luna Decisions

Send the Jev body under GPT-6 Luna Decisions' id, with 1 to 200 questions and 2 or more options in a choice. Check each answer's `type`, because OpenAI can decline one question.

```bash
curl https://demo-api.routerplus.com/v1/decisions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"openai/gpt-6-luna-decisions",
       "state":"Help! My payouts have been failing for 3 days.",
       "questions":{"urgent":{"type":"noul","instructions":"Is this urgent?"}}}'
```

Python:

```python
import os, httpx

r = httpx.post(
    "https://demo-api.routerplus.com/v1/decisions",
    headers={"Authorization": f"Bearer {os.environ['TM_API_KEY']}"},
    json={
        "model": "openai/gpt-6-luna-decisions",
        "state": {"ticket": "Help! My payouts have been failing for 3 days."},
        "questions": {
            "team": {"type": "choice", "instructions": "Which team should handle `ticket`?",
                     "criteria": {"billing": "Payments, invoices, refunds",
                                  "technical": "Bugs, outages, integrations", "sales": None}},
            "frustration": {"type": "score", "instructions": "How frustrated is the customer?",
                            "criteria": ["Calm", "Annoyed", "Angry"]},
            "urgent": {"type": "noul", "instructions": "Is this urgent?",
                       "criteria": {"true": "Explicitly time-sensitive", "false": "No urgency expressed"}},
            "business": {"type": "noul", "instructions": "Is the customer a business?"},
        },
    },
)
for qid, answer in r.json()["answers"].items():
    if answer["type"] == "refusal":
        print(qid, "declined by OpenAI")  # billed, but no value
    else:
        print(qid, answer)
```

Node:

```js
const r = await fetch("https://demo-api.routerplus.com/v1/decisions", {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.TM_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({
    model: "openai/gpt-6-luna-decisions",
    state: { ticket: "Help! My payouts have been failing for 3 days." },
    questions: {
      team: { type: "choice", instructions: "Which team should handle `ticket`?", criteria: { billing: "Payments", technical: "Bugs" } },
      urgent: { type: "noul", instructions: "Is `ticket` urgent?" },
    },
  }),
});
const { answers, usage } = await r.json();
// A declined question comes back as { type: "refusal" }, with no value.
for (const [id, a] of Object.entries(answers)) console.log(id, a.type === "refusal" ? "declined" : a);
// Two questions: about 150 input tokens each, on top of the state.
console.log(usage.input_tokens, usage.output_tokens, usage.cost); // output_tokens is always 0
```

A sample answer to the Python request:

```json
{
  "id": "8c1f4b2e-7d3a-4e90-a6b5-1f2e3d4c5b6a",
  "object": "decision",
  "model": "openai/gpt-6-luna-decisions",
  "answers": {
    "team": { "type": "choice", "choice": "billing",
              "probabilities": { "billing": 1, "technical": 0, "sales": 0 }, "confidence": 1 },
    "frustration": { "type": "score", "score": 1.02,
                     "legend": { "0": "Calm", "1": "Annoyed", "2": "Angry" },
                     "probabilities": { "0": 0.03, "1": 0.92, "2": 0.05 }, "confidence": 0.88 },
    "urgent": { "type": "noul", "noul": 0.96 },
    "business": { "type": "noul", "noul": 0.49 }
  },
  "usage": { "input_tokens": 561, "output_tokens": 0, "cost": 0.000056 }
}
```

### Sage

Send the Jev body under Sage's id. Each question is billed with the whole state, so ask only the questions you need.

```bash
curl https://demo-api.routerplus.com/v1/decisions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"levanto/sage-1.2",
       "state":"Help! My payouts have been failing for 3 days.",
       "questions":{"urgent":{"type":"noul","instructions":"Is this urgent?"}}}'
```

### GLiNER

Each answer sits under its task name.

```bash
curl https://demo-api.routerplus.com/v1/decisions \
  -H "Authorization: Bearer $TM_API_KEY" -H "content-type: application/json" \
  -d '{"model":"fastino/gliner-2.5-decide",
       "state":"Help! My payouts have been failing for 3 days.",
       "schema":{"classifications":[{"task":"Is this urgent?","labels":["yes","no"]}]}}'
```

Python:

```python
import os, httpx

r = httpx.post(
    "https://demo-api.routerplus.com/v1/decisions",
    headers={"Authorization": f"Bearer {os.environ['TM_API_KEY']}"},
    json={
        "model": "fastino/gliner-2.5-decide",
        "state": "Help! My payouts have been failing for 3 days.",
        "schema": {"classifications": [{"task": "team",
                                        "labels": ["billing: payments", "technical: bugs"]}]},
    },
)
answer = r.json()["answers"]["team"]
print(answer["label"], answer["confidence"])  # a confidence, not a calibrated probability
```

Node:

```js
const r = await fetch("https://demo-api.routerplus.com/v1/decisions", {
  method: "POST",
  headers: { authorization: `Bearer ${process.env.TM_API_KEY}`, "content-type": "application/json" },
  body: JSON.stringify({
    model: "fastino/gliner-2.5-decide",
    state: "Help! My payouts have been failing for 3 days.",
    schema: {
      classifications: [
        { task: "topics", labels: ["billing", "outage", "fraud"], multi_label: true, cls_threshold: 0 },
      ],
    },
  }),
});
const { answers, usage } = await r.json();
console.log(answers.topics, usage.input_tokens, usage.output_tokens, usage.cost);
```

## Model discovery

```bash
curl -s "https://demo-api.routerplus.com/v1/models?output_modalities=decisions" \
  -H "Authorization: Bearer $TM_API_KEY"
```

Decision models carry `architecture.output_modalities: ["decisions"]` in `GET /v1/models`, and `output_modalities: ["decisions"]` in the public feed `https://demo.routerplus.com/api/models.json`. The Anthropic shape of `GET /v1/models` never lists them.
