API
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.ladderwith 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. Everything except model and messages is optional:
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. |
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
- Start from every model the registry carries, and keep only the ones your key can call, as the gateway reports for that key.
- 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. - 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.
- 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
toolsorfunctions; and models whose window holds the whole thread plus the usual reply reserve.alphaneural/autonever drops turns to fit a cheaper model. - Keep only models whose estimated billed cost for this request is under
max_spendon its own. - 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.
alphaneural/auto is listed first in the catalogue, 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:
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:
- 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_tokensormax_completion_tokensif you set one and otherwise assumed to be 1,024 tokens. If that is more than what is left ofmax_spend, the ladder stops withstopped_for_budget: true. - 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. - 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.
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):
{
"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. |
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:
{
"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.
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. |
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.