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.

BASEhttps://aelius.live/api/ext/v1

What it is good at, concretely:

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.

  1. Create an account and verify your email address.
  2. 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.
  3. 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.
  4. 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.

ControlTypical defaultEnforced perOn breach
Rate limit3/minute, 60/dayKey 429 with Retry-After
Requests / day60Key 429 quota_exceeded
Requests / month800Key 429 quota_exceeded
Tokens / day150,000Key 429 quota_exceeded
Tokens / month2,000,000Key 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

POST/api/ext/v1/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

FieldTypeNotes
promptstringThe user message. Aliases: text, user_prompt.
system_promptstringReplaces the system message entirely. Alias: system.
messagesarrayFull OpenAI-style override for multi-turn or few-shot. Wins over prompt.
imagesstring | object | arraySee Images. Alias: image.
modelstringAccepted and echoed. The backend serves one model, so this is a label, not a selector.
max_tokensint1–8192. Default 2048.
temperature, top_p, top_k, min_p, typical_pnumberSampling.
repeat_penalty, repeat_last_n, presence_penalty, frequency_penaltynumberPenalties.
mirostat*, dry_*, xtc_*, samplersPassed through to the backend.
seedintSame seed + same parameters gives the same output.
stopstring | arrayUp to 4 stop strings.
json_schemaobjectJSON Schema, compiled to a grammar. Alias: schema.
response_formatobjectRaw OpenAI response_format, if you would rather build it yourself.
grammarstringRaw GBNF, when a schema is not expressive enough.
thinkingboolfalse disables chain-of-thought. Much faster on extraction — see Performance.
streamboolServer-Sent Events instead of one JSON body.
image_max_dimint256–4096. Longest edge before encoding. Default 2048.
timeoutintSeconds, 5–600.
raw_responseboolInclude the backend's untouched response as raw.
extraobjectMerged 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

GET/api/ext/v1/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

GET/api/ext/v1/models

The served model and its context length.

GET/api/ext/v1/health

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.

GET/api/ext/v1/

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.

LimitDefault
Images per request8 (your key may be lower)
Bytes per image20 MB
Whole request body20 MB
Longest edge2048 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:

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)." } }
StatusCodeMeaning
400empty_request, bad_image, bad_messages, bad_parameter, too_many_images, no_image, bad_jsonFix the request.
400backend_rejectedThe model backend refused it — usually an uncompilable schema or grammar. Its own message is included.
401missing_api_key, invalid_api_keyNo key, or an unknown one.
403revoked_api_keyThe key, or the grant behind it, was revoked.
403expired_api_keyPast its expiry. Rotate it from /developers.
403scope_deniedThe key is not scoped for this endpoint.
413image_too_largeOver the per-image byte cap.
429Rate limited. Retry-After is set.
429quota_exceededDaily or monthly ceiling reached. The body carries resets_at.
503model_busyCapacity did not free up in time. Retry.
503model_offlineThe 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.

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:

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:

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.