Chat completions
POST https://api-staging.heimdallm.com/api/v1/chat/completions
The primary inference endpoint. OpenAI-compatible request and response; requires a bearer API key. Heimdallm routes the request to one of the model's live endpoints — see Models & routing for how, and how to steer it.
Request body
| Field | Type | Notes |
|---|---|---|
model | string | Model id, optionally with :free / :nitro / :floor. Required unless models is given. |
models | string[] (≤ 20) | Fallback model list, tried in order after model. |
messages | object[] (≥ 1) | The conversation; message shapes below. Required. |
stream | boolean | true switches the response to server-sent events. |
stream_options | object | include_usage is accepted for compatibility; the final usage chunk is sent either way. include_tool_dispatch: true opts into call-ready events. The options are omitted upstream. |
temperature | number | 0 to 2. |
top_p | number | Greater than 0 and at most 1. Zero returns 400; it is not replaced with a default. |
top_k | integer | ≥ −1; -1 disables this filter where the endpoint declares it. |
min_p | number | 0 to 1. |
top_a | number | Accepted for compatibility; omitted from upstream chat requests. |
frequency_penalty | number | −2 to 2. |
presence_penalty | number | −2 to 2. |
repetition_penalty | number | Greater than 0 and at most 2; requires endpoint declaration. |
seed | integer | Best-effort determinism where the endpoint supports it. |
stop | string / string[] | Up to 8 stop sequences. |
max_tokens | integer | Completion ceiling, 1 to 1,048,576. Also bounds the credit hold. |
max_completion_tokens | integer | OpenAI's newer name for the same ceiling, 1 to 1,048,576. |
logit_bias | object | Token id → bias in [-100, 100]. |
logprobs | boolean | Return log-probabilities where supported. |
top_logprobs | integer | 0 to 20. |
response_format | object | {"type":"text"}, {"type":"json_object"} or {"type":"json_schema", ...} — see Structured outputs. |
structured_outputs | boolean | Advertised capability flag used with provider.require_parameters. |
tools | object[] | Up to 1,536 function tool definitions; names match [a-zA-Z0-9_-]{1,128} — see Tool calling. |
tool_choice | string / object | "none", "auto", "required", or {"type":"function","function":{"name":"..."}}. |
parallel_tool_calls | boolean | Allow multiple tool calls in one turn. |
reasoning | object | { "effort": "none"/"minimal"/"low"/"medium"/"high"/"xhigh"/"max", "max_tokens": n, "exclude": bool, "enabled": bool }. |
reasoning_effort | string | Shorthand for reasoning.effort. |
include_reasoning | boolean | Accepted compatibility flag; the generic OpenAI-compatible adapter does not forward it. |
verbosity | string | Accepted for compatibility; omitted from upstream chat requests. |
n | integer | Only 1 is accepted. |
user | string (≤ 256) | Opaque end-user identifier, forwarded to the provider. |
session_id | string (≤ 256) | The conversation this turn belongs to — see Session affinity. Outranks the X-Session-Id header. |
prompt_cache_key | string (≤ 256) | Cache affinity hint used when session_id is absent. Outranks X-Session-Id; delivered to the provider as that header, not as a body parameter. |
metadata | object | Opaque request metadata. |
modalities | string[] | What the reply may carry: text, image, audio — see Generated images and audio. |
image_config | object | How a generated image is drawn: n, resolution, aspect_ratio, size, quality, output_format, background, seed. |
audio | object | { "voice": "...", "format": "wav" } — both members required for generated speech. |
provider | object | Provider preferences. |
route | string | Deprecated routing alias: fallback groups by model; sort orders across models. Explicit provider.sort.partition takes precedence. |
transforms | string[] | ["middle-out"] asks for middle-out compression; [] declines it, as an absent member does. |
plugins | object[] | The same ask written as a plugin, [{ "id": "context-compression" }]. Any other id returns 400 naming it. |
usage | object | { "include": true }, accepted for OpenRouter compatibility; usage.cost is returned either way. |
The OpenAI-compatible adapter sends known chat fields. Standard sampling fields, output limits,
tools, their modifiers and response_format are forwarded when supplied. The extensions
top_k, min_p and repetition_penalty are sent only when the endpoint declares them in
supported_parameters. Unknown body members are accepted for compatibility and omitted upstream.
reasoning.effort becomes reasoning_effort when the endpoint declares reasoning or
reasoning_effort; the shorthand takes precedence when both spellings are supplied. For Anthropic
endpoints, reasoning.max_tokens supplies the thinking budget. The generic OpenAI-compatible
adapter does not forward reasoning.enabled, reasoning.max_tokens or reasoning.exclude; accepting
those fields does not establish that it controls thinking or hides reasoning. The top-level reasoning,
include_reasoning, structured_outputs, top_a and verbosity members are never sent upstream.
Marketplace model selection, routing preferences, metadata and response usage preferences are
consumed by the gateway. Streaming requests always ask the upstream for usage.
Two of them are routing inputs on their own. A request using tools or structured_outputs is
only ever sent to an endpoint whose supported_parameters declares it, because an endpoint that
ignores them answers the wrong kind of question — prose where you needed a tool call or JSON —
and you would only find out after paying for the answer. Both declarations are verified rather than
taken: an endpoint is asked to call a tool, and asked for a reply conforming to a schema, and one
whose latest check failed has that promise withheld until a later check passes. Every explicitly
requested optional setting requires an endpoint declaration. If no endpoint advertises all the
requested settings, routing refuses the request before contacting a provider.
provider.options carries a provider's own knobs, keyed by provider slug, for the ones the normalized
vocabulary has no name for. Only the key matching the provider that served the request is read, so
naming options for several candidates is not an error. Each name must be one that endpoint declares as a
passthrough parameter, and the value must satisfy the descriptor it declared for the name; an
undeclared name or an inadmissible value is a 400 naming it rather than a request sent on and
rejected upstream. A declared name reaches the provider unchanged.
reasoning_effort is matched against each endpoint's declared effort domain. A known domain that
omits the requested value excludes that endpoint before dispatch; the gateway does not map it to a
nearby level. Each declared value is checked separately, and a current declared level whose latest
check failed is also excluded. If no endpoint remains eligible, the request returns 503.
A declared level without a verdict remains eligible. An unknown domain does not claim all seven
values: the endpoint must declare the parameter, but its unspecified value domain does not prove
every effort works. reasoning.effort and its reasoning_effort shorthand route
identically. Check the endpoint's reasoning metadata for its declared levels and defaults.
reasoning_effort: "none" is held to the reply rather than to the status. An engine that accepts
none and thinks anyway spends that thinking from the same budget that has to hold your answer, and
bills you for it, so an endpoint whose latest none check found reasoning in the reply stops
receiving requests that name none. This is visible only where an engine reports its reasoning on a
channel of its own: one that writes deliberation into the answer text is indistinguishable from one
that is simply verbose.
tool_choice: "required" (or a named function) is enforced as a contract: a response without a
tool call cannot satisfy the request, so an endpoint answering prose to one fails its attempt and
the request moves to the next endpoint. When no endpoint produces a tool call, the request fails
with a 502 naming the contract — never silent prose. With tool_choice: "auto" a prose answer is
often the right answer, and nothing is enforced.
max_tokens is a routing input too. An endpoint that can emit that many tokens at all — its
declared max_completion_tokens, or its context window when it declares none — is preferred over
one that cannot. Where no live endpoint of the model reaches the ceiling, the request is served by
one that does not, with the ceiling lowered to that endpoint's own: a ceiling bounds the reply, so a
coding harness asking for 64,000 tokens of a model whose endpoints emit at most 32,768 gets a reply
of up to 32,768 rather than a 503. max_tokens and max_completion_tokens are one ceiling, and an
endpoint declaring either name is sent the ceiling under the name it declared.
Within an eligible endpoint the ceiling is trimmed, not refused. An engine reserves the
completion budget before it starts, so your prompt plus max_tokens has to fit in the window; most
endpoints publish the same number for their window and their output ceiling, which makes their
advertised maximum reachable only beside an empty prompt. Asking for it with a real prompt gives you
the room that is actually there instead of a rejection. usage reports what was generated, as always.
Your prompt, for that arithmetic, is everything the engine tokenises: the messages and the tools
schemas together. A coding harness sending forty tool declarations beside a short question is sending
most of a window before the question, and the ceiling is trimmed against the whole of it. The same
holds on /v1/messages and /v1/responses, whose max_tokens and max_output_tokens reach the same
plan.
Asking for compression changes which side gives way. transforms: ["middle-out"] says your history
should shorten so the ceiling survives, so an endpoint whose window cannot hold the part of the
conversation compression can never drop beside the full ceiling is not a candidate for it. See
middle-out prompt compression.
Messages
| Role | Shape |
|---|---|
system / developer | { "role", "content", "name"? } |
user | { "role", "content", "name"? } — content is a string or an array of content parts. |
assistant | { "role", "content"?, "tool_calls"?, "reasoning"?, "reasoning_details"?, "refusal"?, "name"? } |
tool | { "role", "content", "tool_call_id" } — a tool result, answering one tool_calls entry. |
Array content parts: {"type":"text","text":"..."},
{"type":"image_url","image_url":{"url":"...","detail":"auto"}} (see
Images & vision),
{"type":"video_url","video_url":{"url":"..."}},
{"type":"audio_url","audio_url":{"url":"..."}},
{"type":"input_audio","input_audio":{"data":"...","format":"wav"}} and
{"type":"file","file":{"filename":"...","file_data":"..."}}.
A file part carries its document as file_data, or names one stored with the
Files API as {"type":"file","file":{"file_id":"file_..."}}, never both. The
marketplace reads the stored bytes under your account before routing and sends the endpoint a data URL
in their place, so an id is never forwarded; see Documents & files. An id your
account cannot read, whether another account's, deleted or expired, is answered 404.
A *_url part carries either a URL the endpoint fetches or a data: URL carrying the bytes
themselves, so the same part shape covers media you point at and media you have in hand. Each part is
routed on what it needs: only endpoints declaring an input for that modality are eligible, and where
an endpoint declares which sources (url, base64) and which containers it takes, a part outside
them is not sent there. A part behind an https: URL names no container, so a container rule has
nothing to judge and withholds nothing.
input_audio and audio_url are the same audio under two spellings, and engines differ over which
they read. Send either: an endpoint that takes audio as inline bytes is handed an input_audio part
rewritten as an audio_url data URL, with the same bytes in it. An endpoint that fetches its audio
cannot be handed bytes at all, and a turn carrying them is refused rather than sent to fail.
When no endpoint of the model accepts a part the turn carries, the request answers 503 and the
rejection names the modality — This endpoint declares no video input.
Reasoning details
Beside the flattened reasoning string, an endpoint that reports structured deliberation carries
it in reasoning_details[] — on message in a non-streamed reply, on delta per chunk in a
streamed one — as one of three entry types:
| Type | Shape | Meaning |
|---|---|---|
reasoning.text | { "type", "text", "signature"? } | Reasoning text, with a provider's signature over it where one is issued. |
reasoning.summary | { "type", "summary" } | A condensed account in place of the reasoning text itself. |
reasoning.encrypted | { "type", "data" } | An opaque block a provider hands back without exposing its content. |
Every entry may also carry format (the originating dialect, e.g. anthropic-claude-v1) and
index (its position within the reply) wherever the adapter that produced it has one to give.
Streamed entries share an index; concatenating same-index reasoning.text fragments and taking
whichever fragment carries a signature reconstructs the entry a non-streamed reply sends whole.
reasoning_details is absent, never an empty array, wherever the endpoint reported nothing
structured.
To continue a conversation without losing a provider's ability to verify or replay a turn's
deliberation, send the previous reply's reasoning_details back unchanged on the assistant
message that carried it — the exact sequence, unmodified and unreordered:
{
"role": "assistant",
"content": "…",
"reasoning_details": [{ "type": "reasoning.text", "text": "…", "signature": "…" }]
}
Generated images and audio
modalities names what the reply may carry. Absent, it takes the endpoint's own default, which for a
text model is prose. Naming image or audio asks for generated media beside (or instead of) the
text, and the reply carries it in members of its own:
| Member | Shape | Meaning |
|---|---|---|
message.images[] | { "type", "image_url": { "url" }, "index"? } | One generated picture, as a data URL. |
message.audio | { "id", "data", "expires_at", "transcript" } | Generated speech: base64 data in the container you asked for. |
delta.images[] / delta.audio | the same members, per chunk | Streamed. A picture arrives whole; audio arrives fragment by fragment. |
An image URL is a data URL wherever the endpoint handed back bytes: the marketplace stores no media, so there is no object it could put behind a URL of its own. Where the endpoint answered with a URL of its own, that URL is carried through unchanged rather than fetched.
audio requires both a voice and a format (wav, mp3, flac, opus, pcm16 or aac) —
neither has a default, because filling one in would pick a voice you never chose. Which voices and
which containers an endpoint offers is the endpoint's own; one that cannot speak refuses the request
rather than answering it with prose.
A reply whose whole answer was the picture carries content as the empty string, never null: the
turn was answered, and usage bills it the way it bills any other. Streamed, an image rides in one
chunk's delta.images and nothing on that chunk's text channel; the audio's data and transcript
accumulate across chunks, so concatenating each fragment's slice reconstructs what a non-streamed
reply sends whole.
Routing. modalities is a routing promise, not a hint. A turn naming image or audio reaches
only endpoints whose declared output modalities include it, so a request nobody serving the model can
draw or speak comes back as a 503 naming the modality no endpoint declares — rather than as prose from
an engine that accepted the ask and had no tower for it. Endpoints of one model differ here: a
provider declares what each of its deployments produces, and the model's own list answers for a
deployment whose document declares nothing.
Billing. Generated media is charged beside the tokens, on the endpoint's published price for it:
image_megapixel per megapixel drawn, audio_second per second spoken. Neither figure comes from the request or from
the endpoint's own summary of its work — the reply's bytes are read, so a picture pays for the pixels
its container states and speech pays for the seconds its container accounts for. usage.cost carries
the total as always, and the pixels and seconds behind it are on the generation record your account's
activity reads. A unit an endpoint publishes no price for is recorded as unpriced and charged nothing;
it is never billed at a price the marketplace chose on the provider's behalf.
{
"modalities": ["image", "text"],
"image_config": { "aspect_ratio": "16:9" },
"messages": [{ "role": "user", "content": "Draw a lighthouse at dusk." }]
}
Example
curl https://api-staging.heimdallm.com/api/v1/chat/completions \
-H "Authorization: Bearer $HEIMDALL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "deepseek/deepseek-v4-flash-0731-abliterated",
"messages": [
{ "role": "system", "content": "You are terse." },
{ "role": "user", "content": "Name three prime numbers." }
],
"temperature": 0.2,
"max_tokens": 100
}'
{
"id": "gen-9f2c7e1a4b6d",
"object": "chat.completion",
"created": 1755300000,
"model": "deepseek/deepseek-v4-flash-0731-abliterated",
"provider": "ImpCC",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "2, 3, 5." },
"finish_reason": "stop",
"native_finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 21,
"completion_tokens": 9,
"total_tokens": 30,
"cost": 0.0000057
}
}
Response fields
| Field | Type | Notes |
|---|---|---|
id | string | The generation id (gen-…); also sent as the X-Generation-Id header. |
object | string | chat.completion. |
created | integer | Unix seconds. |
model | string | The model that served the request (matters with fallbacks). |
provider | string | The serving provider's display name. (Heimdallm) |
choices[].index | integer | Always 0 — n is fixed at 1. |
choices[].message | object | role, content, and optionally reasoning, reasoning_details, refusal, tool_calls, images, audio. |
choices[].finish_reason | string | stop, length, tool_calls, content_filter or error. |
choices[].native_finish_reason | string | The provider's raw finish reason, unnormalised. |
usage.prompt_tokens | integer | Prompt tokens, cached ones included — omitted where the endpoint reported no count. |
usage.completion_tokens | integer | Completion tokens, reasoning included — omitted on the same terms. |
usage.total_tokens | integer | Sum of the two, present only where both are. |
usage.prompt_tokens_details | object | cached_tokens, cache_write_tokens — present when caching occurred. |
usage.completion_tokens_details | object | reasoning_tokens — present when the endpoint reported a count. |
usage.cost | number | Exact charge in USD. (Heimdallm) |
Headers
Optional request headers attribute traffic to your app:
| Header | Meaning |
|---|---|
X-OpenRouter-Title | Your app's name; recorded on the generation and returned as app_id by GET /generation. |
HTTP-Referer | Your app's URL, recorded alongside it. |
X-Session-Id | An opaque conversation id (up to 256 characters) keying session affinity: requests carrying the same id prefer the endpoint that served the conversation, keeping its prompt cache warm. The session_id body member outranks it. |
A non-empty X-OpenRouter-Title takes precedence over X-Title. The X-Title alias supplies the app name when X-OpenRouter-Title is absent or empty.
Response headers:
| Header | Meaning |
|---|---|
X-Generation-Id | The generation id, sent on every response — including errors, so failures can be correlated. |
X-Provider-Name | The serving provider (non-streaming responses; a stream carries provider in each chunk). |
Streaming
With "stream": true the response is text/event-stream. Frame anatomy, in order:
: HEIMDALL PROCESSING
data: {"id":"gen-9f2c7e1a4b6d","object":"chat.completion.chunk","created":1755300000,"model":"deepseek/deepseek-v4-flash-0731-abliterated","provider":"ImpCC","choices":[{"index":0,"delta":{"role":"assistant","content":"2, 3"},"finish_reason":null}]}
data: {"id":"gen-9f2c7e1a4b6d","object":"chat.completion.chunk","created":1755300000,"model":"deepseek/deepseek-v4-flash-0731-abliterated","provider":"ImpCC","choices":[{"index":0,"delta":{"content":", 5."},"finish_reason":"stop"}]}
data: {"id":"gen-9f2c7e1a4b6d","object":"chat.completion.chunk","created":1755300000,"model":"deepseek/deepseek-v4-flash-0731-abliterated","provider":"ImpCC","choices":[],"usage":{"prompt_tokens":21,"completion_tokens":9,"total_tokens":30,"cost":0.0000057}}
data: [DONE]
- Lines starting with
:are keep-alive comments, sent about every 15 seconds while the model is thinking. Ignore them. - Each unnamed
data:line except the last is achat.completion.chunk: content arrives inchoices[0].delta, and the chunk that ends generation carries a non-nullfinish_reason. - The final data frame before
[DONE]has an emptychoicesarray and the completeusageobject, includingcost. data: [DONE]terminates the stream.
Call-ready tool dispatch
With "stream": true, a non-empty tools array and
"stream_options": { "include_tool_dispatch": true }, the router selects only endpoints with a
current positive tool_call_dispatch capability check. A qualifying upstream may send a named event
before generation ends:
event: tool_call_dispatch
data: {"choice_index":0,"tool_call":{"index":0,"id":"call-1","type":"function","function":{"name":"calculator","arguments":"{\"expression\":\"42 * 17\"}"}}}
The tool_call is complete and its function.arguments is a JSON string. Ordinary delta.tool_calls
fragments still arrive. Match choice_index and the call's index and id to treat the event and
deltas as one logical call; do not concatenate the complete arguments onto streamed fragments or run
the same call twice. Duplicate dispatch events for one identity are suppressed. Malformed, partial or
dispatch payloads over 64 KiB are ignored. The gateway does not execute tools or guarantee exactly-once
effects outside the stream. A failure after dispatch ends the stream without replaying the request on
another endpoint. Usage and billing follow the ordinary streamed completion.
Clients without the opt-in receive only standard completion chunks. The Messages and Responses routes reject this extension.
Errors while streaming
A failure before any frame is written is an ordinary HTTP error with a real status code. Once
the stream has started (first frame, or the first keep-alive after roughly one keep-alive period),
the status is fixed at 200 and a failure arrives in-band as a final chunk with an error
member, followed by data: [DONE]. A provider that fails before delivering any of the answer is
retried on the model's other endpoints instead of surfacing here, so an in-band error means the
answer had already begun, or that no endpoint could produce one:
data: {"id":"gen-9f2c7e1a4b6d","object":"chat.completion.chunk","created":1755300000,"model":"deepseek/deepseek-v4-flash-0731-abliterated","provider":"ImpCC","error":{"code":502,"message":"Provider disconnected","metadata":{"error_type":"provider_error"}},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}
data: [DONE]
Treat any chunk with an error member as terminal. The full parsing recipe is in the
streaming guide.
Errors
400 invalid body · 401 bad key · 402 insufficient credits or spend limit · 404 unknown
model · 413 body over 8 MB · 429 rate limited · 502/503/504 provider-side failure after
all eligible endpoints were tried. Envelope and full status table:
Errors & rate limits.