# Routing and spend ceilings

> Send alphaneural/auto, or your own ladder of models, and the server tries a cheap model first, checks its answer, escalates only when the check fails, and never spends past your ceiling.

Routers guess how hard a prompt is before anything runs. This does not guess. It runs a cheap model, checks the answer, and moves to a stronger model only when the check fails, under a ceiling you set in dollars.

There are two ways to use it:

- **`alphaneural/auto`**, a model id. The server builds the ladder from the models your key can call. Start here.
- **`escalation.ladder`** with a concrete model. You write the ladder yourself, cheapest first, and the server never substitutes a model you did not name.

Both take the same `escalation.verify` block and the same `max_spend`, run the same way, and return the same `escalation` record.

## The simple path: alphaneural/auto

Send `alphaneural/auto` as the model on a [chat completion](https://app.alphaneural.io/docs/chat-completions). Everything except `model` and `messages` is optional:

```bash
curl https://backend.alfnrl.io/v1/chat/completions \
  -H "Authorization: Bearer $ALPHANEURAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "alphaneural/auto",
    "messages": [{"role": "user", "content": "Return the invoice number and total as JSON: Invoice INV-0042, total due 1,250.00 EUR."}],
    "response_format": {"type": "json_object"},
    "max_spend": 0.05,
    "max_tokens": 400,
    "escalation": {
      "verify": {
        "json_schema": {
          "type": "object",
          "required": ["invoice_number", "total"],
          "properties": {
            "invoice_number": {"type": "string"},
            "total": {"type": "number"}
          }
        }
      }
    }
  }'
```

| Field | |
|---|---|
| `max_spend` | A ceiling in US dollars, margin included, on what the request may be billed across every model tried. Omitted: the server's default applies, $0.10 unless the operator has changed it, and the response says so in `max_spend_source`. `0` or a negative amount gets `400`: `alphaneural/auto` never runs without a ceiling. |
| `escalation.verify.json_schema` | Optional. The schema an answer must satisfy to be accepted. See [What the check enforces](#what-the-check-enforces). |
| `escalation.max_rungs` | Optional. How many models the ladder may hold, 1 to 5. Default 3. |
| `escalation.ladder` | Not allowed here: `alphaneural/auto` builds its own ladder, and gets `400` if you send one. Name a concrete model to send your own. |

### How the ladder is built

1. Start from every model the registry carries, and keep only the ones your key can call, as the gateway reports for that key.
2. Keep only models on the operator's allow-list. The default is `openrouter/*`, the curated catalogue; community deployments are never used unless the operator allows them.
3. Keep only chat models with a registry price. A model without a price is left out, because a rung that cannot be priced cannot be held to a ceiling.
4. Keep only models that can take this request: models that accept images if an image appears anywhere in the thread, not only in this message; models that support tool calling if the request sends `tools` or `functions`; and models whose window holds the whole thread plus the usual reply reserve. `alphaneural/auto` never drops turns to fit a cheaper model.
5. Keep only models whose estimated billed cost for this request is under `max_spend` on its own.
6. Sort cheapest first and pick up to `max_rungs`: the cheapest, the most expensive, and the rest spaced evenly in price between them, so each rung is a different tier. Then drop rungs from the top until the whole ladder fits under the ceiling.

The response reports what the request needed in `escalation.requires` and how many models met all of it in `escalation.candidates`, which answers "why was the model I expected not tried".

`stream: true` is accepted with `alphaneural/auto`, but no rung is streamed: the ladder runs to completion first, then the accepted answer is sent in SSE framing. See [Streaming](https://app.alphaneural.io/docs/streaming#streaming-and-routing).

`alphaneural/auto` is listed first in the [catalogue](https://app.alphaneural.io/models), with `alphaneural` as its vendor and no price of its own: you pay for the model that answered.

## The advanced path: your own ladder

Name a concrete model and add `escalation.ladder`:

```bash
curl https://backend.alfnrl.io/v1/chat/completions \
  -H "Authorization: Bearer $ALPHANEURAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openrouter/openai/gpt-4o-mini",
    "messages": [{"role": "user", "content": "Return the invoice number and total as JSON: Invoice INV-0042, total due 1,250.00 EUR."}],
    "response_format": {"type": "json_object"},
    "max_tokens": 400,
    "escalation": {
      "ladder": [
        "openrouter/openai/gpt-4o-mini",
        "openrouter/anthropic/claude-haiku-4.5",
        "openrouter/anthropic/claude-sonnet-4.5"
      ],
      "verify": {
        "json_schema": {
          "type": "object",
          "required": ["invoice_number", "total"],
          "properties": {
            "invoice_number": {"type": "string"},
            "total": {"type": "number"},
            "currency": {"type": "string", "enum": ["EUR", "USD", "GBP"]}
          }
        }
      }
    },
    "max_spend": 0.05
  }'
```

| Field | |
|---|---|
| `escalation.ladder` | Model ids, tried in the order given. Put the cheapest first. The API never substitutes a model you did not name. An empty ladder gets `400` `escalation.ladder must contain at least one model`. |
| `escalation.verify.json_schema` | Optional. Without it, the first model that returns a usable answer is accepted, and later models are used only when a call fails or the model refuses. |
| `max_spend` | A ceiling in US dollars, margin included, on what the request may be billed across every model tried. `0` or omitted means no ceiling. |

`model` is still required. The thread is fitted to the context window of `model`, once, and that one rendering is sent to every model on the ladder, so set `model` to the ladder model with the smallest window, usually the first.

`stream` cannot be combined with your own ladder: an answer has to be complete before it can be checked. The combination gets `400` `stream cannot be combined with escalation: the output must be complete before it can be verified`.

Use this path when you need a model `alphaneural/auto` would not pick: one outside the operator's allow-list, one of your own deployments, or a model without a registry price.

## How the ladder runs

Both paths run the same loop. For each model in turn:

1. **The ceiling is checked first.** The API estimates what the attempt will be billed: the thread as it will be sent to this model, plus a 128-token allowance for the retry hint after the first attempt, at the model's input price; plus the reply at its output price, sized by your `max_tokens` or `max_completion_tokens` if you set one and otherwise assumed to be 1,024 tokens. If that is more than what is left of `max_spend`, the ladder stops with `stopped_for_budget: true`.
2. **The model is called.** If the call fails, a network error or an error status from the provider, the attempt is recorded with its reason, such as `upstream returned 429: ...`, it costs nothing, and the next model is tried.
3. **The answer is checked.** A refusal, a reply stopped by the provider's content filter, or an empty reply is rejected, billed, and the next model is tried. With `verify.json_schema`, an answer that fails the schema is rejected too, and the next model is also sent a system message saying what was wrong: `A previous attempt was rejected by an automated check: <reason>. Return output that satisfies the requested format.` (Nothing is sent after a refusal: "the last model refused" tells the next one nothing useful.) An answer that passes is returned.

> **Two ways the ceiling can be passed**
>
>
> **A model with no registry price is not estimated.** With your own ladder, such a rung is attempted whenever any of the ceiling is left, whatever it will cost, and only stops the ladder once its bill has been counted, so `billed` can end well above `max_spend`. `alphaneural/auto` never puts an unpriced model on a ladder. **Without `max_tokens`**, the estimate assumes a 1,024-token reply, and a longer one overshoots on the rung that produced it. Set `max_tokens`, and keep unpriced models off a ladder that has a ceiling.
>

Without a schema, a rung is never abandoned because of the quality of its answer: the first answer that comes back is accepted as it is. The response says so in `escalation.note` and `escalation.verified: false`.

**What a rung costs.** Each attempt's `cost` is the provider's own figure for the call, before our margin: `usage.cost` on its response, or failing that the cost the gateway computed for it, or failing that the reported token counts at the registry's prices. `billed` is the same figure with the margin included, the amount your balance is charged, and `max_spend` is compared against `billed`.

Only the answer that is returned is saved to the thread, recorded against the model that produced it. Rejected attempts and the retry message are not saved.

## The response

On success the model's completion comes back as usual, with one extra top-level field (abridged):

```json
{
  "id": "gen-1757502214-...",
  "object": "chat.completion",
  "model": "anthropic/claude-haiku-4.5",
  "choices": [
    {
      "index": 0,
      "message": {"role": "assistant", "content": "{\"invoice_number\": \"INV-0042\", \"total\": 1250.0, \"currency\": \"EUR\"}"},
      "finish_reason": "stop"
    }
  ],
  "usage": {"prompt_tokens": 162, "completion_tokens": 31, "total_tokens": 193, "cost": 0.000317},
  "escalation": {
    "model": "openrouter/anthropic/claude-haiku-4.5",
    "accepted": true,
    "rung": 2,
    "rungs": 3,
    "ladder": ["openrouter/openai/gpt-4o-mini", "openrouter/anthropic/claude-haiku-4.5", "openrouter/anthropic/claude-sonnet-4.5"],
    "attempts": [
      {"model": "openrouter/openai/gpt-4o-mini", "rung": 1, "cost": "0.0000431", "billed": "0.00004741", "accepted": false, "failure": "total: expected number, got string"},
      {"model": "openrouter/anthropic/claude-haiku-4.5", "rung": 2, "cost": "0.000317", "billed": "0.0003487", "accepted": true}
    ],
    "spent": "0.0003601",
    "billed": "0.00039611",
    "max_spend": "0.05",
    "max_spend_source": "request",
    "verified": true,
    "note": "Each answer was checked against escalation.verify.json_schema before it was accepted; keywords listed in unsupported_schema_keywords were not checked."
  }
}
```

| Field | |
|---|---|
| `router` | `alphaneural/auto` when the server built the ladder. Absent when you wrote it. |
| `model` | The model whose answer was returned. |
| `accepted` | Whether that answer passed. Always `true` on a `200`. |
| `rung`, `rungs` | The returned model's 1-based position on the ladder, and the ladder's length. |
| `ladder` | The models in the order they were tried. |
| `attempts` | Every model called, in order, each with `model`, `rung`, `cost`, `billed`, `accepted` and, when it was not accepted, `failure`: why the check rejected it, or the error that stopped the call. |
| `spent`, `billed` | The totals of `attempts[].cost` and `attempts[].billed`, in US dollars. |
| `max_spend`, `max_spend_source` | The ceiling the run was held to, and whether it came from your `request` or the server's `default`. Absent when there was no ceiling. |
| `stopped_for_budget` | `true` when the ceiling stopped the ladder. Absent otherwise. |
| `verified`, `note` | Whether answers were checked against a schema, and what that means for this answer, in words. |
| `unsupported_schema_keywords` | Keywords in your schema that the checker does not enforce. Absent when there are none. See [What the check enforces](#what-the-check-enforces). |
| `requires`, `candidates` | `alphaneural/auto` only: what the request needed of a model (`vision`, `tools`, `prompt_tokens`, `reply_tokens`), and how many models met all of it within the ceiling. |

Money fields are decimal numbers encoded as strings, so they keep their precision.

The same receipt is repeated in response headers, because a streamed body carries it only on its last frame: `X-Escalation-Model`, `X-Escalation-Rung`, `X-Escalation-Rungs` and `X-Escalation-Billed`. `X-Thread-Id` is set as on every completion.

### When nothing is accepted: 422

If every model was rejected, every call failed, or the ceiling stopped the ladder before an answer was accepted, the status is `422` and the body is an error plus the same `escalation` record, with `accepted: false` and an empty `model`:

```json
{
  "error": {
    "message": "stopped at the $0.05 max_spend ceiling before any model's answer was accepted; raise max_spend to let the ladder go further",
    "type": "ladder_exhausted",
    "code": "budget"
  },
  "escalation": {"accepted": false, "model": "", "stopped_for_budget": true, "attempts": [{"model": "openrouter/openai/gpt-4o-mini", "rung": 1, "cost": "0.0000431", "billed": "0.00004741", "accepted": false, "failure": "total: expected number, got string"}]}
}
```

| `error.code` | |
|---|---|
| `budget` | The ceiling stopped the ladder. Raise `max_spend` rather than rewrite the prompt. |
| `rejected` | Every model that answered was rejected; `escalation.attempts` says why each one was. |
| `upstream_errors` | Every call failed before producing an answer; `escalation.attempts` has each error. |
| `no_candidates` | `alphaneural/auto` only: no model your key can call could take this request within `max_spend`. `escalation.requires` says what it needed. |

A rejected answer is never returned as a completion and is never added to the thread. Your question stays in the thread, as it does whenever a model call fails, and `X-Thread-Id` is set so you can retry in the same conversation with an empty `messages` array. See [When a call fails](https://app.alphaneural.io/docs/threads#when-a-call-fails).

## What the check enforces

The checker takes the JSON out of the answer, from a fenced code block if there is one, otherwise from the first `{` or `[` to the last matching `}` or `]`, so a model wrapping its JSON in prose is not failed for formatting. It then enforces a deliberate subset of JSON Schema:

| Keyword | Enforced |
|---|---|
| `type` | `object`, `array`, `string`, `number`, `integer`, `boolean`, `null`. A single type only; a list of types is not checked. |
| `properties` | Each listed property is checked against its schema when present. **Only on a node that declares `"type": "object"`.** |
| `required` | Each named property must be present. **Only on a node that declares `"type": "object"`.** |
| `items` | Every array element is checked against one schema. **Only on a node that declares `"type": "array"`.** |
| `enum` | The value must be one of those listed, compared by its text form. A node with `enum` is checked for nothing else: its `type`, `properties` and `items` are not looked at. |

> **Always set type**
>
>
> A node without `type` is not checked at all, and this is not reported in `unsupported_schema_keywords`. A schema of `{"required": ["total"], "properties": {...}}` with no `"type": "object"` accepts any JSON whatsoever, `{}` and `[]` included, so the first model always passes. Put `"type": "object"` on every object and `"type": "array"` on every array, at every level.
>

These keywords are **not** enforced, and are listed back to you in `unsupported_schema_keywords` when your schema uses them: `$ref`, `allOf`, `anyOf`, `oneOf`, `not`, `pattern`, `minimum`, `maximum`, `minLength`, `maxLength`, `format`.

Every other keyword is ignored without being reported, including `additionalProperties`, `minItems`, `maxItems`, `uniqueItems`, `const`, `exclusiveMinimum`, `exclusiveMaximum` and `multipleOf`.

> **An unenforced keyword is not a guarantee**
>
>
> If your schema says `"pattern": "^INV-[0-9]+$"`, an answer with any string at all will pass. Check `unsupported_schema_keywords` in the response, and validate anything it lists yourself before you rely on it.
>
