Skip to content

Chat completions

Create a completion, stream it, and use tool calling.

EndpointPermalink to Endpoint

POST/v1/chat/completions

Create a chat completion. Request and response follow the OpenAI chat-completions shape.

Authentication: Required

Request bodies are limited to 10 MB. A larger body is rejected before it is parsed.

Request fieldsPermalink to Request fields

FieldTypeNotes
modelstringRequired. An id from GET /v1/models.
messagesarrayRequired. Ordered conversation messages.
streambooleanStream the response as server-sent events.
temperaturenumberSampling temperature.
top_pnumberNucleus sampling parameter.
max_tokensintegerCap on generated tokens. The legacy name; used only when max_completion_tokens is absent.
max_completion_tokensintegerCap on generated tokens. Wins over max_tokens when both are sent.
toolsarrayTool definitions the model may call.
tool_choicestring or objectAccepts "auto", "none", "required", or an object naming one function.
web_search_optionsobjectRequests hosted web search. Admission is capability-gated; an entry that does not support it never receives the signal.
reasoningobjectReasoning configuration for entries that expose it.
include_reasoningbooleanReturn reasoning content alongside the answer.
providerobjectRouting preferences.
stream_optionsobjectOptions that apply when stream is true.
Every field a caller may set, complete as of the pinned backend commit. Fields the server sets internally are not listed because a client cannot send them.
FieldTypeNotes
rolestringRequired. The speaker for this message.
contentstring or arrayRequired. Plain text, or an array of content parts.
namestringOptional name for the speaker.
tool_callsarrayTool calls this message is making.
tool_call_idstringThe call this message is a result for.
reasoning_contentstringReasoning text carried alongside the message.
Fields of a message inside messages. Complete.

Two request fields are server-controlled and cannot be set by a caller

The backend carries an internal task identifier and a tool-call ceiling on the same request type, but both are excluded from JSON entirely — they neither deserialize from your request nor reach any upstream body. Sending them has no effect; they are named here only so their absence from the table is not read as an omission.

tool_choice accepts both shapes

Both the string form and the object form that pins a specific function are accepted. A client that sends the object form does not need a workaround.

A basic completionPermalink to A basic completion

curl
"tk-cmd">curl https://api.relane.ai/v1/chat/completions \
  "tk-flag">-H "Authorization: Bearer rl_sk_live_YOUR_KEY" \
  "tk-flag">-H "Content-Type: application/json" \
  "tk-flag">-d '{
    "model": "MODEL_ID",
    "messages": [
      { "role": "user", "content": "Summarise this changelog entry in one sentence." }
    ]
  }'
JavaScript (fetch)
const res = await fetch(class="tk-str">"https:class="tk-commentclass="tk-str">">//api.relane.ai/v1/chat/completions", {
  method: class="tk-str">"POST",
  headers: {
    Authorization: class="tk-str">`Bearer ${process.env.RELANE_API_KEY}`,
    class="tk-str">"Content-Type": class="tk-str">"application/json",
  },
  body: JSON.stringify({
    model: modelId, class=class="tk-str">"tk-comment">// from GET /v1/models
    messages: [{ role: class="tk-str">"user", content: class="tk-str">"Summarise this changelog entry in one sentence." }],
  }),
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(class="tk-str">`${error.type} (${error.code}): ${error.message}`);
}

const completion = await res.json();
console.log(completion.choices[0].message.content);

StreamingPermalink to Streaming

Set stream to true and the response arrives as server-sent events: each event is a data: line, events are separated by a blank line, and the stream ends with a [DONE] sentinel.

Do not parse the stream chunk by chunk

A network chunk is not an event. One chunk can split a JSON object in half, carry several events at once, end mid multi-byte character, or use CRLF framing. Splitting each chunk on newlines appears to work locally and corrupts output under real load, so buffer across chunks instead.
A buffered SSE parser
function createSseParser() {
  const decoder = new TextDecoder();
  let buffer = class="tk-str">"";

  function parseEvent(raw) {
    class=class="tk-str">"tk-comment">// An SSE event may carry several data: lines; the spec joins them with a newline.
    const data = raw
      .split(class="tk-str">"\n")
      .filter((line) => line.startsWith(class="tk-str">"data:"))
      .map((line) => line.slice(5).trimStart())
      .join(class="tk-str">"\n");
    if (!data || data === class="tk-str">"[DONE]") return null;
    return JSON.parse(data);
  }

  function drain(flush) {
    class=class="tk-str">"tk-comment">// Normalise framing so a CRLF stream parses identically to an LF one. Safe on the payload:
    class=class="tk-str">"tk-comment">// JSON escapes newlines inside strings, so a raw CR LF here is always framing.
    buffer = buffer.replace(/\r\n/g, class="tk-str">"\n");

    const events = [];
    let sep;
    while ((sep = buffer.indexOf(class="tk-str">"\n\n")) !== -1) {
      const raw = buffer.slice(0, sep);
      buffer = buffer.slice(sep + 2);
      const event = parseEvent(raw);
      if (event) events.push(event);
    }
    class=class="tk-str">"tk-comment">// At end of stream, accept a final event that never got its blank line.
    if (flush && buffer.trim()) {
      const raw = buffer;
      buffer = class="tk-str">"";
      const event = parseEvent(raw);
      if (event) events.push(event);
    }
    return events;
  }

  return {
    class=class="tk-str">"tk-comment">// Feed one network chunk (Uint8Array); returns the events it completed.
    push(chunk) {
      class=class="tk-str">"tk-comment">// stream: true holds a split multi-byte character in the decoder until its remaining
      class=class="tk-str">"tk-comment">// bytes arrive, instead of emitting a replacement character.
      buffer += decoder.decode(chunk, { stream: true });
      return drain(false);
    },
    class=class="tk-str">"tk-comment">// Call once the body ends, to flush the decoder and any trailing event.
    end() {
      buffer += decoder.decode();
      return drain(true);
    },
  };
}

Feed every chunk to push, and call end once the body is finished so the decoder is flushed and a trailing event without its blank line is not lost.

Consuming the stream
const res = await fetch(class="tk-str">"https:class="tk-commentclass="tk-str">">//api.relane.ai/v1/chat/completions", {
  method: class="tk-str">"POST",
  headers: {
    Authorization: class="tk-str">`Bearer ${process.env.RELANE_API_KEY}`,
    class="tk-str">"Content-Type": class="tk-str">"application/json",
  },
  body: JSON.stringify({ model: modelId, messages, stream: true }),
});

class=class="tk-str">"tk-comment">// A non-200 fails before the stream opens.
if (!res.ok) {
  const { error } = await res.json();
  throw new Error(class="tk-str">`${error.type} (${error.code}): ${error.message}`);
}

const parser = createSseParser();
const reader = res.body.getReader();
let text = class="tk-str">"";

for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  for (const event of parser.push(value)) {
    class=class="tk-str">"tk-comment">// A 200 that later fails is still a failure. Raise rather than returning a truncated answer.
    assertNotStreamError(event);
    text += event.choices[0]?.delta?.content ?? class="tk-str">"";
  }
}

class=class="tk-str">"tk-comment">// Flush anything the stream ended on — including a final error event.
for (const event of parser.end()) {
  assertNotStreamError(event);
  text += event.choices[0]?.delta?.content ?? class="tk-str">"";
}

Errors can arrive after the stream opens

A streaming response can begin and then fail. Handle a mid-stream error as a failed request rather than assuming that a 200 status means the completion finished.

Search provenance arrives in a delta, usually the last one

When a streamed completion used hosted search, relane_web_search travels inside choices[0].delta like any other streamed field, and on some entries it appears only on the final chunk — the one that also carries finish_reason. A consumer that reads delta.content and discards the rest of the delta will show the answer and silently lose its sources, so accumulate the whole delta rather than one field of it.

Tool callingPermalink to Tool calling

Supply tools to let the model request a function call. If you send a tool call without declaring any tools, the request is rejected as a client error rather than reported as an upstream failure.

curl — declare a tool
"tk-cmd">curl https://api.relane.ai/v1/chat/completions \
  "tk-flag">-H "Authorization: Bearer rl_sk_live_YOUR_KEY" \
  "tk-flag">-H "Content-Type: application/json" \
  "tk-flag">-d '{
    "model": "MODEL_ID",
    "messages": [{ "role": "user", "content": "What is the build status?" }],
    "tools": [
      {
        "type": "function",
        "function": {
          "name": "get_build_status",
          "description": "Return the status of the most recent build.",
          "parameters": {
            "type": "object",
            "properties": { "branch": { "type": "string" } },
            "required": ["branch"]
          }
        }
      }
    ],
    "tool_choice": "auto"
  }'

Nested request objectsPermalink to Nested request objects

Complete field lists for every object a request can nest. These are the shapes the server decodes; a field not listed here is not read.

FieldTypeNotes
typestringRequired. "function" for a callable tool; "web_search" requests hosted search.
functionobjectRequired for a function tool. See below.
tools[] entry.
FieldTypeNotes
namestringRequired. The name the model calls.
descriptionstringWhat the tool does. Omitted when empty.
parametersobjectJSON Schema for the arguments. Omitted when the tool takes none.
tools[].function.
FieldTypeNotes
idstringIdentifier this call is answered by, via tool_call_id.
typestringAlways "function".
functionobjectname (string) and arguments (a JSON-encoded STRING, not an object).
message.tool_calls[] entry.

tool_calls[].function.arguments is a string

It carries JSON encoded as text, not a nested object. Parse it before use, and be ready for it to be invalid — a model can emit malformed arguments.
FieldTypeNotes
typestringRequired. "text" or "image_url".
textstringThe text of a text part.
image_urlobjecturl (string) and optional detail (string).
cache_controlobjecttype (string). Marks the part for prompt caching.
message.content[] entry, when content is an array of parts rather than a string.
FieldTypeNotes
max_tokensintegerBound on reasoning tokens.
effortstringReasoning-effort hint. Commonly low, medium or high; some entries accept a wider ladder and reject values outside it with a 400.
reasoning.
FieldTypeNotes
sortstringRouting sort preference.
orderarrayOrdered routing preference.
allow_fallbacksbooleanWhether a fallback route may be used.
provider.
FieldTypeNotes
include_usagebooleanInclude a usage block in the stream.
stream_options.

Routing and fallbackPermalink to Routing and fallback

The provider object expresses how you would like the request routed when more than one route could serve it. Every field in it is a preference, forwarded to the route that ends up serving the request and honoured only where that route implements it. None of them is a guarantee, and none of them changes which model you asked for.

FieldAsks for
sortA ranking preference among the routes that could serve the request.
orderAn explicit order to try routes in, most preferred first.
allow_fallbacksSet it to false to be refused rather than served by a route you did not rank.
What each provider field asks for.

The response tells you what actually served it

model on the response is the entry that produced the completion, not an echo of what you asked for. If you care which one answered — for accounting, for reproducibility, or because you pinned an order — read it from the response rather than assuming your request was honoured.

Three outcomes are refusals rather than fallbacks, and the endpoint returns them instead of quietly answering from somewhere else:

codeStatusWhat happened
model_not_allowed403The key is restricted to an explicit list of models and this one is not on it. It is never rerouted to a permitted model.
model_not_found403The model is in the catalog but has been deactivated.
model_unavailable503The model is known but cannot currently be served. Retry; do not substitute a different model automatically without telling your own users.
When the endpoint refuses instead of rerouting.

No silent substitution across model families

A request that cannot be served as asked is refused. You will not receive an answer generated by a different model family than the one you named, so a successful response can be attributed to the entry in its model field without further checking.

A request asks for hosted search in either of two ways: by sending web_search_options, or by including a bare {"type": "web_search"} entry in tools. Both are treated as a request for search.

FieldTypeAccepted values
search_context_sizestringlow, medium, or high. Omit it to let the endpoint apply its default; any other value is a 400.
user_locationobjecttype must be "approximate". Then EITHER a nested approximate object OR the flat fields — never both.
web_search_options — complete.
FieldTypeNotes
countrystringISO-3166-1 alpha-2, exactly two letters.
citystringLength-bounded.
regionstringLength-bounded.
timezonestringA valid IANA zone, e.g. Europe/Istanbul.
user_location fields (flat form, or inside approximate).

user_location is strict-decoded

Unknown fields are rejected rather than ignored, mixing the nested and flat forms is rejected, and every value is validated before the request goes anywhere. A typo is a 400, not a silently dropped preference.
curl — a text request with hosted search
"tk-cmd">curl https://api.relane.ai/v1/chat/completions \
  "tk-flag">-H "Authorization: Bearer rl_sk_live_YOUR_KEY" \
  "tk-flag">-H "Content-Type: application/json" \
  "tk-flag">-d '{
    "model": "MODEL_ID",
    "messages": [
      { "role": "user", "content": "What changed in the TLS 1.3 spec most recently?" }
    ],
    "web_search_options": {
      "search_context_size": "medium",
      "user_location": {
        "type": "approximate",
        "country": "TR",
        "timezone": "Europe/Istanbul"
      }
    }
  }'
JavaScript — an image and text request with hosted search
class=class="tk-str">"tk-comment">// content becomes an ARRAY of parts when a message carries more than text.
const res = await fetch(class="tk-str">"https:class="tk-commentclass="tk-str">">//api.relane.ai/v1/chat/completions", {
  method: class="tk-str">"POST",
  headers: {
    Authorization: class="tk-str">`Bearer ${process.env.RELANE_API_KEY}`,
    class="tk-str">"Content-Type": class="tk-str">"application/json",
  },
  body: JSON.stringify({
    model: modelId, class=class="tk-str">"tk-comment">// must be an entry whose supports_images is true
    messages: [
      {
        role: class="tk-str">"user",
        content: [
          { type: class="tk-str">"text", text: class="tk-str">"Which library produced this error, and is it patched?" },
          { type: class="tk-str">"image_url", image_url: { url: class="tk-str">"https:class="tk-commentclass="tk-str">">//example.com/screenshot.png" } },
        ],
      },
    ],
    web_search_options: { search_context_size: class="tk-str">"high" },
  }),
});

Check supports_images before sending an image part

The catalog reports it per entry. Sending image parts to an entry that does not accept them is a client error, and GET /v1/models is the only reliable way to know.

Some entries need room to search AND answer

On certain entries the tokens spent reasoning come out of the same budget as the answer, so a small output cap is consumed before any search happens and you get a truncated reply that never searched. Where that is the case the request is refused with a 400 naming the minimum, rather than being served as an ungrounded answer. The cap is never quietly raised for you: that would bill tokens you did not ask for and return a response shaped by a request you did not send. Send max_completion_tokens at or above the stated minimum, or omit the cap entirely — an unset budget cannot violate a minimum.
codeWhenmessage
web_search_not_supportedThe route cannot serve hosted search for this request.hosted web search is unavailable for this request
invalid_web_search_optionssearch_context_size is not low, medium or high.search_context_size must be one of: low, medium, high
invalid_web_search_optionsuser_location.type is not "approximate".user_location.type must be "approximate"
invalid_web_search_optionsNested and flat location fields are mixed.user_location must not mix nested approximate and flat fields
invalid_web_search_optionsuser_location carries an unknown or malformed field.user_location has unknown or malformed fields
web_search_output_budget_too_smallThe entry needs a larger output budget to search and still answer.hosted web search on this model requires an output budget of at least N tokens; M was requested
What a refused hosted-search request looks like. All are HTTP 400.

The budget refusal costs you nothing

It is decided before any usage is reserved and before the request reaches anything upstream, so a refused call is not billed. Read the minimum out of the message and resend — N is the requirement and M is what you sent.

Refusal is upfront, never partial

A request that cannot be served with hosted search is rejected before anything is sent onward. You never receive an answer that silently skipped the search you asked for, so treat a 400 as the definitive signal and retry without web_search_options if you want an answer regardless.

Reading sources and provenancePermalink to Reading sources and provenance

When a completion used hosted search, the assistant message carries provenance in two separate places. They are not interchangeable.

FieldContains
annotationsStandard url_citation entries: which span of the answer came from which page.
relane_web_searchThe search actions performed, as a namespaced extension. Never mixed into annotations.
Where provenance appears on choices[].message.
FieldTypeNotes
typestringAlways "url_citation".
url_citationobjecturl, optional title, start_index and end_index.
annotations[] entry.
FieldTypeNotes
urlstringHTTPS link to the cited page.
titlestringPage title. Omitted when unavailable.
start_indexintegerStart offset of the cited span in the final message text.
end_indexintegerEnd offset of that span.
url_citation.
FieldTypeNotes
typestringsearch, open_page, or find_in_page.
querystringThe single query searched for.
queriesarraySeveral queries, when the action carried more than one.
urlstringThe page opened. Absent on a search action, and absent on any entry whose source page cannot be attested.
patternstringThe text pattern searched for, on find_in_page.
relane_web_search[] entry.

A search action without a url is normal, not truncated data

Some entries report what they searched for but not which page the answer came from. Rather than publish a redirect wrapper as if it were the source, the endpoint omits url entirely — an action with a query and no url is the honest record of what happened. Treat url as optional on every entry and never key your rendering on it.
JavaScript — render the answer with its sources
const completion = await res.json();
const message = completion.choices[0].message;

class=class="tk-str">"tk-comment">// Citations index into the final message text, so slice with them rather than
class=class="tk-str">"tk-comment">// re-searching the string.
const body = typeof message.content === class="tk-str">"string" ? message.content : class="tk-str">"";
for (const a of message.annotations ?? []) {
  if (a.type !== class="tk-str">"url_citation" || !a.url_citation) continue;
  const { url, title, start_index, end_index } = a.url_citation;
  const quoted = body.slice(start_index, end_index);
  console.log(class="tk-str">`class="tk-str">"${quoted}" — ${title ?? url} (${url})`);
}

class=class="tk-str">"tk-comment">// What the model actually did, kept separate from the citations above.
for (const call of message.relane_web_search ?? []) {
  if (call.type === class="tk-str">"search") console.log(class="tk-str">"searched:", call.query ?? call.queries?.join(class="tk-str">", "));
  if (call.type === class="tk-str">"open_page") console.log(class="tk-str">"opened:", call.url);
  if (call.type === class="tk-str">"find_in_page") console.log(class="tk-str">"looked for:", call.pattern, class="tk-str">"in", call.url);
}

Indices are bounded to the final text

A citation is emitted only when its URL is a safe HTTPS link and its offsets fall inside the message. Slicing with them is safe; you do not need to clamp them yourself.

The responsePermalink to The response

FieldTypeNotes
idstringIdentifier for this completion.
objectstringObject type.
createdintegerUnix timestamp.
modelstringThe entry that produced the completion.
choicesarrayOne entry per returned completion.
usageobjectToken and cost accounting. Omitted when unavailable.
Top-level response — complete.
FieldTypeNotes
indexintegerPosition of this choice.
messageobjectThe assistant message.
finish_reasonstringWhy generation stopped. Omitted when not reported.
choices[] entry — complete.
FieldTypeNotes
rolestringAlways the assistant role.
contentstring or arrayThe answer.
tool_callsarrayTool calls the model wants performed.
reasoning_contentstringReasoning text, when the entry exposes it.
annotationsarrayurl_citation entries. Response-only.
relane_web_searcharraySearch actions performed. Response-only.
choices[].message — complete. Note this is NOT the same shape as a request message.

annotations and relane_web_search are response-only

They never appear on a request. Echoing a previous assistant message back verbatim is fine — the server does not read them — but do not construct them yourself expecting any effect.
FieldTypeNotes
prompt_tokensintegerInput tokens.
completion_tokensintegerGenerated tokens.
total_tokensintegerSum of the two.
prompt_tokens_detailsobjectcached_tokens and cache_write_tokens.
costnumberCost for this call. Omitted when unavailable.
cost_detailsobjectCost breakdown. Omitted when unavailable.
relane_web_search_callsintegerHosted-search actions that completed. Omitted when none.
usage — complete.
FieldTypeNotes
cached_tokensintegerInput tokens served from cache. Omitted when zero.
cache_write_tokensintegerCache-write tokens, additional to prompt_tokens. Omitted when zero.
usage.prompt_tokens_details — complete.
FieldTypeNotes
upstream_inference_costnumberInference cost component. Omitted when unavailable.
usage.cost_details — complete.

Count searches from usage, not from the annotations

relane_web_search_calls counts the actions that actually completed, de-duplicated. The relane_web_search array can be shorter, because entries that are not on the allowlist are dropped from it. If you are reconciling spend, read the usage number.