API documentation
Send text and images to the Aelius model from your own application and get back free-form text or JSON constrained to a schema you define. One HTTP call, no session, no stored state.
Overview
The API is a single, stateless endpoint with full control over the generation: your own system prompt, your own sampling parameters, and optionally a JSON Schema that the model is grammatically prevented from violating. It is the same model that powers the chat product, reached without any of the chat product's behaviour.
What it is good at, concretely:
- Reading documents and photos. Forms, labels, screenshots, handwriting — anything where you want fields out of a picture.
- Producing data, not prose. Give it a JSON Schema and every response parses; there is no "sometimes it wraps the JSON in prose" failure mode to code around.
- Ordinary text generation. Summarise, classify, rewrite, answer — images are optional.
Capacity is shared and finite. Serving capacity is shared with the
website, and requests beyond what is free at that moment queue rather than
being run in parallel. Plan for a call to sometimes wait a few seconds before
it starts, handle 503 model_busy with a retry, and see
Performance.
Getting access
Access is granted by hand. There is no self-serve signup, and no route anywhere that mints a key without a human decision behind it.
- Create an account and verify your email address.
- Fill in the access questionnaire at /developers. It asks what you are building, how much traffic you expect, what data will pass through, and who sees the output.
- Wait for a decision. You get an email either way. An approval sets your terms: how many keys you may hold, your rate limit, and your quotas.
- Create your own keys from the same page. The secret is shown once, in that one response, and is never stored in recoverable form.
Answer the questionnaire properly. The single most common reason an application is declined is a one-line answer to "why do you want API access". We are not looking for a pitch — we are looking for enough detail to tell what the integration does and whether the quota you are asking for matches it.
Authentication
Every request carries your key in a header:
Authorization: Bearer ael_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
X-API-Key: <key> works identically. Multipart form posts may
also send it as an api_key form field, for clients that cannot set
headers.
Never put the key in browser JavaScript. The endpoints are CORS-enabled, which makes it technically possible and operationally a mistake — anyone who loads your page can read the key out of it and spend your quota. Call the API from your own server and proxy the result.
Passing the key in a query string is deprecated for the same class of
reason: it lands in server logs, proxy logs, browser history and any
Referer your page then sends. Requests that do it are flagged.
Keys are stored only as a SHA-256 hash. A database leak yields nothing usable, and neither we nor you can recover a lost key — rotate it instead, which mints a replacement carrying the same settings and disables the old one in the same operation.
Rate limits and quotas
These are two different mechanisms and your key is subject to both. Rate limits govern how fast; quotas govern how much in total.
| Control | Typical default | Enforced per | On breach |
|---|---|---|---|
| Rate limit | 3/minute, 60/day | Key | 429 with Retry-After |
| Requests / day | 60 | Key | 429 quota_exceeded |
| Requests / month | 800 | Key | 429 quota_exceeded |
| Tokens / day | 150,000 | Key | 429 quota_exceeded |
| Tokens / month | 2,000,000 | Key | 429 quota_exceeded |
Your actual figures are set when your application is approved and are visible
on /developers and from
GET /usage. Limits are per key, not
per IP, so two integrations behind the same NAT never share a bucket — and
issuing a second key genuinely gives you a second bucket, which is why the
number of keys you may hold is itself part of the grant.
Daily quotas reset at 00:00 UTC; monthly quotas on the 1st. A
quota_exceeded response tells you exactly when:
{
"error": {
"code": "quota_exceeded",
"message": "This key has used its dayly requests quota (60 of 60). It resets at 2026-08-02 00:00 UTC.",
"quota": "requests_day",
"used": 60,
"limit": 60,
"resets_at": "2026-08-02T00:00:00Z",
"retry_after": 32940
}
}
GET /usage and GET /models are exempt from quota, so
a throttled key can always find out when it recovers.
POST /vision
The general endpoint. /completions is an alias. Accepts
application/json or multipart/form-data; in
multipart, any file part is treated as an image whatever the field name, and
an options field may carry the whole JSON body as a string.
Quick start
curl -X POST https://aelius.live/api/ext/v1/vision \
-H "Authorization: Bearer $AELIUS_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "What is in this picture?",
"images": ["https://example.com/photo.jpg"],
"thinking": false
}'
import base64, requests
with open("document.jpg", "rb") as f:
b64 = base64.b64encode(f.read()).decode()
r = requests.post(
"https://aelius.live/api/ext/v1/vision",
headers={"Authorization": f"Bearer {KEY}"},
json={
"system_prompt": "You extract data from photos. JSON only.",
"prompt": "Extract the document's title and date.",
"images": [b64],
"json_schema": {
"type": "object",
"properties": {
"title": {"type": ["string", "null"]},
"date": {"type": ["string", "null"]},
},
"required": ["title", "date"],
},
"temperature": 0,
"thinking": False,
},
timeout=120,
)
r.raise_for_status()
print(r.json()["json"])
// From your SERVER, not the browser.
const form = new FormData();
form.append("image", file);
form.append("options", JSON.stringify({
prompt: "Describe this image in one sentence.",
thinking: false,
}));
const res = await fetch("https://aelius.live/api/ext/v1/vision", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.AELIUS_KEY}` },
body: form,
});
if (!res.ok) throw new Error((await res.json()).error.message);
const { text, usage, timings } = await res.json();
Parameters
| Field | Type | Notes |
|---|---|---|
prompt | string | The user message. Aliases: text, user_prompt. |
system_prompt | string | Replaces the system message entirely. Alias: system. |
messages | array | Full OpenAI-style override for multi-turn or few-shot. Wins over prompt. |
images | string | object | array | See Images. Alias: image. |
model | string | Accepted and echoed. The backend serves one model, so this is a label, not a selector. |
max_tokens | int | 1–8192. Default 2048. |
temperature, top_p, top_k, min_p, typical_p | number | Sampling. |
repeat_penalty, repeat_last_n, presence_penalty, frequency_penalty | number | Penalties. |
mirostat*, dry_*, xtc_*, samplers | — | Passed through to the backend. |
seed | int | Same seed + same parameters gives the same output. |
stop | string | array | Up to 4 stop strings. |
json_schema | object | JSON Schema, compiled to a grammar. Alias: schema. |
response_format | object | Raw OpenAI response_format, if you would rather build it yourself. |
grammar | string | Raw GBNF, when a schema is not expressive enough. |
thinking | bool | false disables chain-of-thought. Much faster on extraction — see Performance. |
stream | bool | Server-Sent Events instead of one JSON body. |
image_max_dim | int | 256–4096. Longest edge before encoding. Default 2048. |
timeout | int | Seconds, 5–600. |
raw_response | bool | Include the backend's untouched response as raw. |
extra | object | Merged into the raw backend request — an escape hatch for anything not listed. |
Response
{
"id": "chatcmpl-…",
"object": "vision.completion",
"created": 1785126067,
"model": "<the served model's identifier>",
"text": "…",
"json": { },
"reasoning": null,
"finish_reason": "stop",
"usage": { "prompt_tokens": 1308, "completion_tokens": 638, "total_tokens": 1946 },
"timings": { "seconds": 10.8, "tokens_per_second": 59.2 },
"images": [{ "width": 640, "height": 914, "original_size": [640, 914], "bytes": 178745 }]
}
Every response also carries an X-Request-Id header. Quote it if
you report a problem — it identifies the exact request in our logs, which
beats "some time on Tuesday".
GET /usage
What this key has spent and what it has left. Exempt from quota, so you can always call it — poll it on a slow timer and back off before you are throttled rather than discovering the ceiling as a 429 in production.
{
"key": { "label": "production backend", "prefix": "ael_sk_a1b2c3",
"expires_at": "2027-08-01T00:00:00Z", "scopes": null },
"rate_limit": "3 per minute;60 per day",
"quota": {
"requests_day": { "used": 12, "limit": 60, "remaining": 48, "percent": 20.0 },
"requests_month": { "used": 162, "limit": 800, "remaining": 638, "percent": 20.3 },
"tokens_day": { "used": 25500, "limit": 150000, "remaining": 124500, "percent": 17.0 },
"tokens_month": { "used": 330000, "limit": 2000000, "remaining": 1670000, "percent": 16.5 }
},
"lifetime": { "requests": 5120, "prompt_tokens": 6400000,
"completion_tokens": 910000, "last_used_at": "2026-08-01T14:22:09Z" }
}
GET /models and GET /health
The served model and its context length.
Liveness and backend reachability. The key is optional here, so you can wire it
into an uptime monitor without embedding a credential. Returns
200 when the model backend is reachable and 503
when it is not.
A self-describing index of everything above. Open it in a browser.
Images
All of these forms work, in any mix, in the images array:
"images": [
"data:image/jpeg;base64,/9j/4AAQ…", // data URL
"iVBORw0KGgoAAAANSUhEUg…", // bare base64
"https://example.com/photo.jpg", // fetched server-side
{ "base64": "…", "mime_type": "image/png" }, // object form
{ "type": "image_url", "image_url": { "url": "data:…" } } // OpenAI content part
]
Every image is re-encoded to a clean PNG before it reaches the model, with EXIF
rotation applied and the longest edge capped at image_max_dim.
That matters more than it sounds: the backend decodes images with a library
that has no WebP or AVIF support, so a phone upload named
photo.jpg that is actually WebP would otherwise fail deep in the
backend with an opaque error. Here it either normalises cleanly or returns a
clear 400.
| Limit | Default |
|---|---|
| Images per request | 8 (your key may be lower) |
| Bytes per image | 20 MB |
| Whole request body | 20 MB |
| Longest edge | 2048 px |
Image size drives cost. An image is roughly 1,100 prompt tokens at the
default 2048 px. Dropping image_max_dim to 1280 trims tokens and
latency noticeably, but small print on a dense document starts to go. Test before
lowering it.
Server-side fetches of https image URLs refuse private, loopback,
link-local, reserved and multicast addresses, so the endpoint cannot be used to
probe a LAN or a cloud metadata service.
Structured output
Pass a json_schema and the backend compiles it into a grammar. The
model then cannot emit anything off-schema — this is a constraint on
decoding, not an instruction in a prompt, so there is no "usually valid JSON"
failure mode.
{
"prompt": "Classify the sentiment and pull out any product names.",
"json_schema": {
"type": "object",
"properties": {
"sentiment": { "enum": ["positive", "neutral", "negative"] },
"products": { "type": "array", "items": { "type": "string" } }
},
"required": ["sentiment", "products"]
},
"temperature": 0,
"thinking": false
}
Two things worth knowing:
- Mark every key required, and make the values nullable
(
{"type": ["string", "null"]}). The grammar only forces the key to be present; a nullable value still lets the model say "I could not read this". Without it, a partially-legible document silently drops keys and your code has to guard every access. - The parsed result comes back in
json, extracted even through a code fence or leading prose. If you asked for JSON and it could not be parsed you get"json": nullplus ajson_errorstring, never a silent miss.
Streaming
Set "stream": true to get text/event-stream:
data: {"type":"start"}
data: {"type":"reasoning","text":"…"} // only when thinking is on
data: {"type":"delta","text":"…"} // answer fragments
data: {"type":"done","text":"…","json":{},"usage":{},"timings":{}}
data: {"type":"error","code":"…","message":"…"}
The done frame mirrors the non-streaming body, so a client can
ignore the deltas entirely and just use it. Streaming buys little for a
structured extraction — the output is a JSON blob nobody reads token by
token — but it is there for chat-style uses.
Errors
Uniform shape, always JSON:
{ "error": { "code": "unsafe_image_url",
"message": "Refusing to fetch '…' (non-public address)." } }
| Status | Code | Meaning |
|---|---|---|
| 400 | empty_request, bad_image, bad_messages, bad_parameter, too_many_images, no_image, bad_json | Fix the request. |
| 400 | backend_rejected | The model backend refused it — usually an uncompilable schema or grammar. Its own message is included. |
| 401 | missing_api_key, invalid_api_key | No key, or an unknown one. |
| 403 | revoked_api_key | The key, or the grant behind it, was revoked. |
| 403 | expired_api_key | Past its expiry. Rotate it from /developers. |
| 403 | scope_denied | The key is not scoped for this endpoint. |
| 413 | image_too_large | Over the per-image byte cap. |
| 429 | — | Rate limited. Retry-After is set. |
| 429 | quota_exceeded | Daily or monthly ceiling reached. The body carries resets_at. |
| 503 | model_busy | Capacity did not free up in time. Retry. |
| 503 | model_offline | The model backend is down. |
Retry on 429 and 503, not on 4xx. Honour Retry-After when
it is present and use exponential backoff when it is not. A 400
will fail identically forever — retrying it only burns your quota.
Performance
Figures from live traffic. Treat them as the shape of the thing, not a guarantee — they move with load.
- A single-image structured extraction takes roughly 10 seconds. That is about 1.3k prompt tokens, of which the image is ~1.1k, plus a few hundred completion tokens.
thinking: falseis the single biggest win. A short question took 0.4 s with thinking off versus 5.0 s with it on. Chain-of-thought buys essentially nothing on a grammar-constrained extraction, so turn it off for anything schema-shaped.- Concurrency is shared and limited. Calls beyond what is free queue
instead of running in parallel, so a burst finishes staggered rather than
together. A request that waits too long returns a clean
503 model_busywith aretry_after, never a hang. - Prompt size is the lever you control. Image dimensions dominate the
prompt cost — see Images. Trimming
image_max_dimis usually a bigger win than any sampling change. - Waiting time and generation time are measured separately. If your
calls feel slow, ask us with an
X-Request-Idand we can tell you which of the two it was.
Data and privacy
Nothing you send is stored. No prompts, no images, no responses are written to any database. The generation happens in memory and the request is gone.
What is recorded, for every request, is operational metadata:
- Timestamp, endpoint, HTTP status and error code
- The key used, and the account that owns it
- Client IP, country, user agent and referer
- Duration, split into queue wait and model time
- Token counts, image count and total image bytes — sizes, never content
This is what makes rate limiting, quotas, billing reconciliation and abuse investigation possible. It is retained for 90 days and then deleted. You can see your own slice of it on /developers.
If you need an audit trail of the content — the actual images, the actual answers — keep it on your side. We deliberately cannot provide one.
Acceptable use
By using the API you agree to these, and your application is assessed against them:
- Do not share your key. It identifies you; everything done with it is attributed to your account.
- Do not resell raw access or wrap the API as a general-purpose model endpoint for third parties. Build a product on it — that is the point.
- Stay inside your quota. Do not rotate keys, register extra accounts, or spread traffic across keys to get around a ceiling. Ask for a higher one instead; that request is free and usually granted if the use case supports it.
- Keep a human in the loop for anything consequential. Model output is not reliable enough to make medical, legal, financial, employment or safety decisions unreviewed, and the questionnaire asks about this for a reason.
- Do not send data you are not allowed to send. If you are processing personal data, that is your lawful basis and your disclosure to make, not ours.
- No unlawful, abusive, or deceptive use — including generating content that impersonates a real person or organisation, or presenting model output as human-authored where that matters.
Access can be revoked at any time for breaching these, and revoking a grant disables its keys immediately. We will tell you why.
Get started
Read the questions, answer them properly, and apply at /developers. If something here is unclear or you need a quota that does not fit the defaults, say so in the application — it is easier to grant up front than to change later.