Chat completions

POST /v1/chat/completions, in OpenAI's format, because OpenAI specifies that format and we do not. A field this page does not name is relayed to the engine untouched.

The credential is an API key on the Authorization header, and the route is on the Max plan. See authentication. A request body is read up to 32 MiB, and a longer one is cut there and fails to parse.

What we honour

ParameterBehaviour
messagesRequired, and non-empty. A system message of yours is merged with ours rather than replacing it.
streamSSE, in the OpenAI chunk format.
stream_optionsKept, with include_usage turned on: see below.
reasoning_effortThe reasoning switch. Any value but "none" turns thinking on.
chat_template_kwargs.enable_thinkingThe same switch, said in the engine's own terms, and it wins when both are present.
temperature, top_p, stop, penaltiesYours.
max_tokensYours, except with reasoning on: see below.
tools, tool_choiceYours, and then ours are not served. A client with its own tools executes them itself.

What we override, and why

  • model selects the model, capped to your plan. drael-v1 is the full model and drael-v1-lite the lighter one the free tiers are served and a paid key may pick; both are public aliases over weights that are ours to change, so naming some other model does not reach it, and a plan that does not include the full model is answered by the lite one rather than refused. GET /v1/models lists the ids your key may use.
  • priority is discarded, and ours is set from your plan. It orders the queue every account shares, so a client that named its own would put itself ahead of other accounts by editing one JSON field.
  • The date is repeated on your last message. A system preamble carries it already, and a model reads it worst exactly there: measured on a stand-in model, n=20 per arm, on a question whose answer depends on knowing the year, the date at the top alone got it right in 32 of 40 runs and the same line repeated at the end of the live user turn got 40 of 40 (Fisher p=0.005). Moving it instead of repeating it does nothing. Your message is not changed anywhere you can see it: what a conversation stores and returns is what you sent, and the line exists only in the body handed to the engine. It never appeared in an answer across 100 measured completions.
  • repetition_penalty gets a floor of 1.1 when you send none. Without it the served model collapses into degenerate repetition. Send your own, including 1.0, and yours is kept.
  • max_tokens is dropped when reasoning is on. A ceiling bounds the reasoning and the answer together, so it truncates the answer to empty rather than shortening it. Measured: thinking on with max_tokens: 4000 returned an empty answer in 22 of 24 runs, and at 8000 in 12 of 24, every time with finish_reason: "length", no error and no content. This is not configurable, because the configurable version is worse.
  • stream_options.include_usage is turned on for streams. You get one extra chunk carrying usage, which every OpenAI client already ignores, and we get the number the account is billed on.

Reasoning

Reasoning is per request rather than a separate model id: the two ids are two models, the full one and the lite one, not two reasoning levels of the same weights, and either reasons on request.

  • chat_template_kwargs.enable_thinking is read first, being the underlying template switch.
  • reasoning_effort is read next. "none" means do not think; every other value the served stack accepts means the opposite. When it is present nothing else is injected: a template kwarg alongside it would be a second, contradictory instruction about the same thing.
  • A request that names neither gets the deployment's default, written explicitly into the upstream request rather than left to the template.

Reasoning text arrives on its own channel, reasoning_content, beside content. Both are generated tokens and both are charged.

The stream

Frames are forwarded as the engine emits them, so your parser sees exactly the OpenAI chunk format. A streamed answer ends with one chunk carrying usage and then data: [DONE].

data: {"choices":[{"delta":{"reasoning_content":"..."},"index":0}]}

data: {"choices":[{"delta":{"content":"Abliteration "},"index":0}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"completion_tokens":128,"total_tokens":128}}

data: [DONE]

When Drael runs its own tool loop the client still sees one continuous stream. What the model says between tool calls passes through; the tool-call frames and each intermediate turn's terminal frame are held back, and the request ends with a single terminal frame whose usage.completion_tokens covers every turn it took. The finish_reason is the real one, so a length is never reported as a clean stop.

Without stream, you get one JSON document: the answering turn's, not the intermediate ones.

Images in the conversation

The served model reads text. An image_url part is not relayed to it and is not dropped either: it is read first, and the reading replaces the part, in place, as text the model can answer about. Dropping it answers a question about an image nobody looked at, and relaying it is rejected or silently ignored depending on the model loaded.

  • Both an http(s) address and a data: URI are read, which is what an editor pasting a screenshot sends. OpenAI's own detail hint is passed through.
  • Reading an image is cash at a provider, so it is held and settled like a tool call, per image.
  • An image that could not be read says so in its own place, rather than vanishing. The model then knows one was there and does not answer from the filename.
  • Your own message array is not modified: the conversation you sent is the conversation you can retry.

The session header

X-Drael-Session names the workspace a request's tool calls share. The OpenAI format has no session and the sandbox needs one: files the model wrote in an earlier call are readable in a later one only within the same session.

  • The value must match [a-zA-Z0-9][a-zA-Z0-9_-]{0,63}. Anything else is treated as absent rather than refused.
  • A request that sends none gets a workspace for that request alone, which is the correct default for a stateless API client.
  • If the value is the id of a conversation on your own account, the turn is also recorded into it, with the trace of what the tools did. Whether the conversation is yours is checked against the store, so the header grants nothing.

Asking what ran

X-Drael-What-Ran: 1 adds one field to the terminal frame: the names of the tools that did work on this request.

data: {"choices":[...],"usage":{...},"drael":{"used":["web_search","run_code"]}}

Names only. No arguments, no results, no counts, and nothing about a call that failed or was refused: a tool that refused did not do the thing its name says. The trace itself, with what each call was given and what it returned, belongs to a conversation you named with X-Drael-Session and is streamed only there.

It is opt-in for the same reason the session header is: a client that asked for an answer gets the stream it has always had.

Tools

With no tools in your request, Drael serves its own catalogue and runs the loop itself: code execution, image generation, web search and fetch, and two credentialed lookups. You receive the answer, not the intermediate turns. See tools.

When the prompt is too long

A request whose prompt plus max_tokens crosses the serving ceiling has its max_tokens refitted to the room the engine itself reported, and is sent again, once. A prompt that leaves less than 512 tokens of room answers context_overflow, and only your client can compact its own history: chat completions is stateless and we hold no conversation of our own. See limits.

What a request costs

Credit is held before the model is touched, so an account without it cannot spend the GPU, and the hold is closed against what the model actually produced rather than against the estimate. Chat is metered on output tokens only, reasoning included; the prompt is not charged.

A stream you abandon mid-answer still settles what had been generated when you left. An answer that produced nothing releases the hold instead of charging it.

Errors

Errors are the OpenAI envelope and are branched on code, never on message text.

CodeHTTPMeaning
invalid_json400The body is not JSON.
invalid_request400No usable messages array.
context_overflow400The conversation fills the served context. Compact it.
unauthorized401Missing or invalid credential.
insufficient_credit402The plan's usage is spent.
spend_limit_reached402The daily extra-usage limit you set would be passed.
plan_required403The API surface is on the Max plan.
upstream_unavailable502The model is not reachable. Nothing was charged.

An upstream rejection that is none of these is relayed with its own status and its own document, unedited. Every code the whole API can answer with is in errors.

The model list

GET /v1/models answers with the one published id, and carries context_length and max_model_len when the engine reports a window. It takes the same credential as chat, and it does not proxy the upstream list: the served model's real name is not published.