# Chat completions

Send a conversation and get the model's next message back. The endpoint is compatible with the OpenAI Chat Completions API, so the official OpenAI SDKs work once you point the base URL at `https://kenari.id/v1`.

`POST /v1/chat/completions`

Authenticate with `Authorization: Bearer kn-...`. See [Authentication and API keys](/en/docs/authentication).

## Request

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `model` | string | Yes | What to run. See [Choose a model](#choose-a-model). |
| `messages` | array | Yes | The conversation so far. See [Messages](#messages). |
| `stream` | boolean | No | Set `true` to receive Server-Sent Events. Default `false`. See [Streaming](/en/docs/streaming). |
| `stream_options` | object | No | Passed to providers served in the OpenAI format. It does not change kenari's own usage chunk. See [Streaming](/en/docs/streaming). |
| `max_tokens` | integer | No | Upper limit on generated tokens, reasoning included. Default 65,536. |
| `max_completion_tokens` | integer | No | Alias of `max_tokens`. `max_tokens` wins if you send both. |
| `temperature` | number | No | Sampling temperature. |
| `top_p` | number | No | Nucleus sampling. |
| `stop` | string or array | No | Text that ends generation. |
| `n` | integer | No | Only `1` is supported. A larger value returns 400. |
| `user` | string | No | An id for your end user. It is passed on as request metadata where the provider accepts one. |
| `tools` | array | No | Function tools, and kenari server tools such as `{"type": "kenari:web_search"}`. See [Function calling](/en/docs/function-calling) and [Server tools](/en/docs/server-tools). |
| `tool_choice` | string or object | No | `auto`, `none`, `required`, or `{"type": "function", "function": {"name": "..."}}`. Any other shape is treated as `auto`, except `{"type": "function"}` without `function.name`, which returns 400. |
| `response_format` | object | No | Ask for JSON output. See [Structured output](/en/docs/structured-output). |
| `reasoning_effort` | string | No | `none`, `minimal`, `low`, `medium`, `high`, `xhigh` or `max`. See [Reasoning](/en/docs/reasoning). |
| `reasoning` | object | No | `{"effort", "enabled", "max_tokens", "exclude"}`. Wins over `reasoning_effort` when both are set. `{"enabled": true}` with no effort or `max_tokens` means `medium`, and `{"enabled": false}` turns reasoning off. See [Reasoning](/en/docs/reasoning). |
| `plugins` | array | No | Optional. `file-parser` picks how attached documents are read: `auto`, `pdf-text`, `native` or `ocr`. See [Read documents](/en/docs/documents). |
| `prompt_cache_key` | string | No | A stable id for one conversation, so follow-up requests reuse its cached prefix. Up to 512 bytes. Longer or blank values are ignored. See [Prompt caching](/en/docs/prompt-caching). |

kenari does not interpret other OpenAI fields such as `frequency_penalty`, `presence_penalty` or `seed`. It passes them to the provider unchanged when the model is served in the OpenAI format and drops them otherwise, so do not depend on them. The same goes for `response_format` when you also declare a kenari server tool.

The routing fields `provider`, `models` and `route` are the exception. kenari passes them on only when the request runs on your own key, such as a BYOK id. On every other model it does not pass them on, so they have no effect. `plugins` is read by kenari's file parser and is never passed on.

### Choose a model

| `model` value | Example | What runs |
| --- | --- | --- |
| Catalog id | `claude-sonnet-5-5` | The model, billed per token. List ids with [Models and pricing](/en/docs/models). |
| `:free` id | `step-3-7-flash:free` | The free version of a model. See [Free models](/en/docs/free-models). |
| Route name | `kenari-free` | A route you created, or the built-in `kenari-free`. See [Routing and routes](/en/docs/routing). |
| BYOK id | `byok/deepseek/deepseek-v4-flash` | The model at one of your own providers or connected subscriptions, in the form `byok/<provider>/<model>`, such as `byok/chatgpt/gpt-5.5`. kenari does not charge tokens for it. See [Providers](/en/docs/byok). |

The `<model>` part of a BYOK id is the provider's own id, copied exactly. It is case sensitive and can contain slashes.

### Messages

`messages` is an array of `{"role", "content"}` objects.

| Role | Notes |
| --- | --- |
| `system`, `developer` | Instructions. Leading ones are merged into one system prompt. One that appears after a user message stays where it is. |
| `user` | `content` is a string or an array of parts. |
| `assistant` | Earlier model replies. May carry `tool_calls`, and `reasoning_content` or `reasoning` to hand earlier reasoning back to the model. |
| `tool` | A function result. Set `tool_call_id` to the id of the call it answers. |

The legacy `function` role returns 400.

A `user` message can mix these content parts. Unknown part types are ignored.

| Part | Shape | See |
| --- | --- | --- |
| `text` | `{"type": "text", "text": "..."}` | |
| `image_url` | `{"type": "image_url", "image_url": {"url": "https://example.com/a.png"}}` | [Images and files as input](/en/docs/vision) |
| `file` | `{"type": "file", "file": {"filename": "a.pdf", "file_data": "data:application/pdf;base64,..."}}` | [Images and files as input](/en/docs/vision) |

`url` is an https URL or a data URL such as `data:image/png;base64,...`.

A `file` part carries a document (PDF, text or image) and works on any model. kenari reads it before routing, and the model receives its text and page images, billed as normal input tokens, so a plan that covers the model covers the document. A request can carry several documents. The optional `file-parser` plugin changes how documents are read, for example `"plugins": [{"id": "file-parser", "pdf": {"engine": "ocr"}}]` for paid OCR, which takes one document per request and is billed per page. See [Read documents](/en/docs/documents).

## Response

The response is an OpenAI `chat.completion` object with one choice.

```json
{
  "id": "4f1c9a2e7b3d5a60",
  "object": "chat.completion",
  "created": 1790000000,
  "model": "step-3-7-flash:free",
  "choices": [
    {
      "index": 0,
      "message": { "role": "assistant", "content": "Hello! How can I help?" },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 12,
    "completion_tokens": 9,
    "total_tokens": 21,
    "prompt_tokens_details": { "cached_tokens": 0 }
  }
}
```

| Field | Description |
| --- | --- |
| `choices[0].finish_reason` | `stop`, `length` (hit `max_tokens`), `tool_calls`, or `content_filter`. |
| `choices[0].message.tool_calls` | Present when the model calls functions. `arguments` is a JSON string. |
| `choices[0].message.reasoning` and `reasoning_content` | The model's reasoning text, under both names with the same content. Present only when the model reasoned and you did not set `reasoning.exclude`. |
| `choices[0].message.sources` | Present when a kenari server tool searched the web. Lists `{url, title}` for each source. |
| `choices[0].message.annotations` | `url_citation` entries for those sources, and one `file` entry for a document read with the `ocr` engine. See [Read documents](/en/docs/documents). |
| `usage.prompt_tokens` | Input tokens, cached ones included. |
| `usage.completion_tokens` | Output tokens. Reasoning tokens are billed as output. |
| `usage.prompt_tokens_details.cached_tokens` | Input tokens served from cache. In a non-streaming response it is always present, `0` when none. In a stream, `prompt_tokens_details` appears on the final chunk only when cached tokens are above 0. See [Prompt caching](/en/docs/prompt-caching). |

The `id` is the same value as the `x-request-id` response header. Quote it when you contact support.

## Examples

### curl

```bash
curl https://kenari.id/v1/chat/completions \
  -H "Authorization: Bearer $KENARI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "step-3-7-flash:free",
    "messages": [
      { "role": "system", "content": "You answer briefly." },
      { "role": "user", "content": "Hello!" }
    ]
  }'
```

### Python

```python
import os
from openai import OpenAI

client = OpenAI(base_url="https://kenari.id/v1", api_key=os.environ["KENARI_API_KEY"])

response = client.chat.completions.create(
    model="step-3-7-flash:free",
    messages=[
        {"role": "system", "content": "You answer briefly."},
        {"role": "user", "content": "Hello!"},
    ],
)
print(response.choices[0].message.content)
```

### JavaScript

```js
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://kenari.id/v1",
  apiKey: process.env.KENARI_API_KEY,
});

const response = await client.chat.completions.create({
  model: "step-3-7-flash:free",
  messages: [
    { role: "system", content: "You answer briefly." },
    { role: "user", content: "Hello!" },
  ],
});
console.log(response.choices[0].message.content);
```

## Billing

Each request is billed per token at the model's Input, Output, Cache read and Cache write prices. `:free` models cost nothing. Before a paid request runs, kenari checks your balance against the worst case for `max_tokens`, so set a smaller limit when you call an expensive model on a low balance. See [How billing works](/en/docs/billing).

## Errors

Errors use the OpenAI envelope: `{"error": {"message", "type", "param", "code"}}`. Only the errors specific to this endpoint are listed here. The rest are in [Errors](/en/docs/errors).

| Status | Code | When |
| --- | --- | --- |
| 400 | `bad_request` | The body is not valid JSON or lacks `model` or `messages`. `n` is above 1. A role is unknown. A `file` part has no `file` object. A document has an unsupported type, is too large, is damaged or has too many pages. A PDF has more than 20 pages without a text layer. A document makes the request longer than the model's context. The `file-parser` engine is not `auto`, `pdf-text`, `native` or `ocr`. With `native`, the model does not read that kind of file, or the part carries an image. With `ocr`, the request carries more than one `file` part. |
| 400 | `model_not_found` | The `model` is not in the catalog and is not one of your routes. |
| 400 | `bad_request` | The model is not a chat model, such as an image or speech model. The message names the endpoint to use instead. |
| 400 | `context_length_exceeded` | The request is longer than the model's context window, shown as `context_length` in [Models and pricing](/en/docs/models). |
| 400 | `byok_malformed_model_id` | A `byok/` id is not in the form `byok/<provider>/<model>`. |
| 400 | `byok_provider_not_found` | You saved no key or connection for the provider named in a `byok/` id. |
| 403 | `subscribers_only` | The model is only for accounts with an active subscription. See [Subscriptions](/en/docs/subscriptions). |
| 403 | `subscription_required` | A `byok/chatgpt/...` or `byok/grok/...` id from an account without an active plan. See [Providers](/en/docs/byok#members-only). |
| 403 | `byok_credential_unavailable` | You have a key or connection for that provider, but none can serve right now. |
| 403 | `byok_spend_capped_key` | The API key has a spend cap, which BYOK requests cannot use. |

## See also

- [Streaming](/en/docs/streaming)
- [Function calling](/en/docs/function-calling)
- [Reasoning](/en/docs/reasoning)
- [Messages](/en/docs/messages)
