# Chat Completions

Page: https://docs.sonecentral.com/en/chat-completions
Markdown: https://docs.sonecentral.com/en/chat-completions.md

> Send messages using OpenAI Chat Completions. The sone fields carry conversation state and rich messages.

```http
POST https://pb.sonecentral.com/api/v1/chat/completions
```

Uses OpenAI Chat Completions request and response formats. OpenAI SDKs, LangChain and compatible clients work by changing `baseURL`.

## Request

```json
{
  "model": "default",
  "messages": [
    { "role": "user", "content": "Are you open this weekend?" }
  ],
  "metadata": { "conversation_id": "conv_x8k2m9p4q7w1r3t" },
  "stream": false
}
```

| Field | Type | Description |
| --- | --- | --- |
| `model` | string | Assistant ID or "default". See Assistants and models for IDs. Required. |
| `messages` | array | Conversation messages. User messages after the final assistant message are new, up to 12. Earlier messages are used to find the conversation or initialize a new one. Required. |
| `stream` | boolean | Stream the reply using server-sent events (SSE). Default: false. |
| `stream_options` | object | include_usage: true adds usage at the end of the stream. |
| `conversation_id` | string | Sonecentral extension: ID of the conversation to continue. |
| `metadata` | object | Only metadata.conversation_id is read, as an alternative to conversation_id. Other metadata is ignored and not stored. |
| `user` | string | Server keys only. An internal visitor identifier; the same value finds the same conversation. |
| `safety_identifier` | string | Same function as user; takes precedence when both are supplied. |

The first non-empty conversation ID is used in this order:

1. Body `conversation_id`.
2. Body `sone.conversation_id`.
3. `X-Sone-Conversation-Id` header.
4. `metadata.conversation_id`.
5. `sone.conversation_id` from an assistant message returned unchanged in history, using the most recent if several exist.

Without an ID, other lookup methods apply: [Conversation continuity](https://docs.sonecentral.com/en/sohbet-surekliligi.md#sohbet-nasil-bulunur).

### Message roles

| Role | Behavior |
| --- | --- |
| `user` | Visitor message. `content` can be a string or an array of `text` parts. |
| `assistant` | Previous reply. Used for conversation lookup; with a server key, also imported when initializing a new conversation. |
| `system`, `developer` | With a server key, informational context, up to 2,000 characters. Ignored with publishable keys. See [Developer context](https://docs.sonecentral.com/en/kimlik-dogrulama.md#gelistirici-baglami). |
| `tool`, `function` | Ignored. |

Other roles return `400 invalid_role`. The final message must be a text-bearing `user` message, otherwise `400 missing_user_message`. A new message longer than 4,000 characters returns `400 message_too_long`.

Images, audio and files in new messages return `400 unsupported_content`. Such parts in previous messages are skipped, retaining only their text.

### Ignored and unsupported fields

Sonecentral's assistant configuration determines the model, tone and tools. These fields are **ignored** without an error: `temperature`, `top_p`, `max_tokens`, `max_completion_tokens`, `presence_penalty`, `frequency_penalty`, `seed`, `stop`, `logit_bias`, `logprobs`, `top_logprobs`, `reasoning_effort`, `verbosity`, `service_tier`, `store`, `parallel_tool_calls`, `tool_choice`, `function_call`, `audio`, `modalities`. Unknown fields are also ignored.

These fields are **rejected**:

| Field | Error |
| --- | --- |
| `tools`, `functions` | `400 tools_not_supported`; the assistant uses the business's own tools. |
| `n` other than 1 | `400 unsupported_parameter` |
| `response_format` other than `text` | `400 unsupported_parameter` |

## Response

```json
{
  "id": "chatcmpl-9f2c4a7e1b8d3f6a0c5e2b9d",
  "object": "chat.completion",
  "created": 1760866000,
  "model": "k3m9x2p7q1w8e4r",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Yes, we are open Saturday from 10:00 to 18:00. We are closed on Sunday.",
        "refusal": null,
        "annotations": [],
        "sone": {
          "conversation_id": "conv_x8k2m9p4q7w1r3t",
          "status": "answered",
          "messages": [
            {
              "id": "msg_p4q7w1x8k2m9a5z",
              "role": "assistant",
              "type": "text",
              "text": "Yes, we are open Saturday from 10:00 to 18:00. We are closed on Sunday.",
              "created_at": 1760866000
            }
          ]
        }
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": { "prompt_tokens": 7, "completion_tokens": 16, "total_tokens": 23 },
  "system_fingerprint": null,
  "sone": { "conversation_id": "conv_x8k2m9p4q7w1r3t", "status": "answered" }
}
```

`model` is the replying assistant's ID, even when the request uses `"default"`. The conversation ID is also in `X-Sone-Conversation-Id`.

`content` is the plain text of all messages in the turn, separated by blank lines (`\n\n`). A text-only client remains fully functional.

`usage` is informational, estimated from new input and output text at roughly four characters per token. Billing is not based on these values.

### The `sone` fields

These are additions to OpenAI's format. The complete object is at `choices[0].message.sone`. A summary without `messages` is also present at the response's top level.

| Field | Type | Description |
| --- | --- | --- |
| `conversation_id` | string | Conversation ID, prefixed with conv_. Send it on subsequent requests. |
| `status` | "answered" \| "handoff" \| "human" \| "silent" | Turn outcome, described below. |
| `messages` | SoneMessage[] | Messages sent in this turn, in order. See Rich messages for the format. |

| `status` | Meaning | `content` |
| --- | --- | --- |
| `answered` | The assistant replied. | Reply text |
| `handoff` | The conversation moved to the team this turn and a message was sent, usually explaining the handoff. | Handoff message |
| `human` | The team owns the conversation. The assistant did not reply; the visitor message reached the team. | Empty |
| `silent` | No reply needed, for example after “thanks”. | Empty |

For `handoff` and `human`, read [Human handoff and team replies](https://docs.sonecentral.com/en/insan-devri.md).

Define these fields in TypeScript:

```ts title="sone.ts"
import type { SoneMessage } from './sone-message'; // definition from the Rich messages page

export type SoneStatus = 'answered' | 'handoff' | 'human' | 'silent';

// Non-streamed response: choices[0].message.sone
export type SoneTurn = {
  conversation_id: string;
  status: SoneStatus;
  messages: SoneMessage[];
};

// Top-level sone on a stream chunk; only one field is populated per chunk
export type SoneChunk = {
  message?: SoneMessage; // a message completed
  activity?: string; // current assistant activity: "Ürünlere bakılıyor"
  conversation_id?: string; // on the final chunk
  status?: SoneStatus; // on the final chunk
};

export const soneOfMessage = (message: unknown) => (message as { sone?: SoneTurn }).sone;
export const soneOfChunk = (chunk: unknown) => (chunk as { sone?: SoneChunk }).sone;
```

OpenAI's Python models preserve unknown fields:

```python
sone = completion.choices[0].message.to_dict().get("sone", {})
print(sone.get("conversation_id"), sone.get("status"))
```

## Streaming

With `"stream": true`, the response is `text/event-stream`. Chunks arrive in this order:

1. **First chunk:** after key, limit and plan checks pass and the assistant starts, `delta` carries the role. Rejected requests return normal HTTP errors instead.
2. **Activity chunk:** while a tool runs, `delta` is empty and top-level `sone.activity` carries a short label, such as the Turkish “Ürünlere bakılıyor” (Checking products).
3. Text streams in word groups through `delta.content`. Second and later messages begin with `\n\n`. Each message's **final chunk** also includes the full `SoneMessage` in top-level `sone.message`. A message with empty text uses one chunk with an empty `delta`.
4. **Final chunk:** `finish_reason` is `"stop"`, `delta` is empty, and top-level `sone` contains `conversation_id` and `status`.
5. With `stream_options.include_usage`, an extra chunk has empty `choices` and populated `usage`; preceding chunks contain `"usage": null`.
6. The stream ends with `data: [DONE]`.

`sone` is always top-level on the chunk, never inside `choices[0].delta`. The conversation ID is available in `X-Sone-Conversation-Id` when streaming begins.

```txt
data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":null}],"sone":{"activity":"Çalışma saatlerine bakılıyor"}}

: ping

data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{"content":"Yes, we are open Saturday "},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{"content":"from 10:00 to 18:00. We are "},"logprobs":null,"finish_reason":null}]}

data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{"content":"closed on Sunday."},"logprobs":null,"finish_reason":null}],"sone":{"message":{"id":"msg_p4q7w1x8k2m9a5z","role":"assistant","type":"text","text":"Yes, we are open Saturday from 10:00 to 18:00. We are closed on Sunday.","created_at":1760866000}}}

data: {"id":"chatcmpl-9f2c","object":"chat.completion.chunk","created":1760866000,"model":"k3m9x2p7q1w8e4r","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"sone":{"conversation_id":"conv_x8k2m9p4q7w1r3t","status":"answered"}}

data: [DONE]
```

IDs in this example are shortened.

**OpenAI JS**

```ts
import type { SoneMessage } from './sone-message';
import { soneOfChunk } from './sone';

const stream = await client.chat.completions.create({
  model: 'default',
  messages: [{ role: 'user', content: 'Are you open this weekend?' }],
  stream: true,
});

const messages: SoneMessage[] = [];
for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta.content ?? '');
  const sone = soneOfChunk(chunk);
  if (sone?.message) messages.push(sone.message); // render the rich message here
  if (sone?.activity) console.log(`\n${sone.activity}…`);
  if (sone?.status) console.log('\nsohbet:', sone.conversation_id, sone.status);
}
```

**OpenAI Python**

```python
stream = client.chat.completions.create(
    model="default",
    messages=[{"role": "user", "content": "Are you open this weekend?"}],
    stream=True,
)

messages = []
for chunk in stream:
    if chunk.choices:
        print(chunk.choices[0].delta.content or "", end="", flush=True)
    sone = chunk.to_dict().get("sone") or {}
    if "message" in sone:
        messages.append(sone["message"])  # render the rich message here
    elif "activity" in sone:
        print(f"\n{sone['activity']}…")
    elif "status" in sone:
        print("\nsohbet:", sone["conversation_id"], sone["status"])
```

An error after streaming begins arrives as `data: {"error": {…}}`, followed by `data: [DONE]`. OpenAI SDKs throw it as an error. See [Errors](https://docs.sonecentral.com/en/hatalar.md#akis-sirasinda-hata).

For message-level delivery and keepalives, see [Streaming](https://docs.sonecentral.com/en/akis.md).

## Retries

Send a new `Idempotency-Key`, up to 200 characters, for every new message. Reuse it when retrying that message after a network failure. The message is stored once and the same reply is returned if already answered. Different text with the same key returns `422 idempotency_key_reused`.

With a publishable key, a retry without a conversation ID from a different network address, such as a first-message retry, opens a new conversation: [Retries](https://docs.sonecentral.com/en/sohbet-surekliligi.md#tekrar-denemek).

OpenAI SDKs do not add this header automatically. Pass it in request options so SDK retries reuse it:

**OpenAI JS**

```ts
const key = crypto.randomUUID(); // generate once per message and reuse on retries

const completion = await client.chat.completions.create(
  {
    model: 'default',
    metadata: { conversation_id: 'conv_x8k2m9p4q7w1r3t' },
    messages: [{ role: 'user', content: 'Are you open this weekend?' }],
  },
  { headers: { 'Idempotency-Key': key } },
);
```

**OpenAI Python**

```python
import uuid

key = str(uuid.uuid4())  # generate once per message and reuse on retries

completion = client.chat.completions.create(
    model="default",
    metadata={"conversation_id": "conv_x8k2m9p4q7w1r3t"},
    messages=[{"role": "user", "content": "Are you open this weekend?"}],
    extra_headers={"Idempotency-Key": key},
)
```

When every request includes the complete history and conversation ID, Sonecentral can also identify retries from the history tail after the first message. History alone without an ID or message identity cannot find an unanswered retry: it starts another conversation and replies again. An idempotency key is the safe path for first messages and clients sending only new text without saving an ID. Send one on every message. See [Retries](https://docs.sonecentral.com/en/sohbet-surekliligi.md#tekrar-denemek).

## Errors

| Status | `code` | When |
| --- | --- | --- |
| `400` | `missing_user_message` | The final message must be a user message containing text. |
| `400` | `invalid_role` | Unknown message role, or a Responses message item without a role. |
| `400` | `invalid_content` | Message content is not a string or an array of parts. |
| `400` | `unsupported_content` | A new message contains an image, audio or file. Send text only. |
| `400` | `message_too_long` | A new message exceeds 4,000 characters. Shorten it. |
| `400` | `too_many_new_messages` | More than 12 new visitor messages in one request. Include the assistant reply in history before continuing, or start a new conversation. |
| `400` | `tools_not_supported` | Remove tools, functions or function-call items. The assistant uses the business tools configured in Sonecentral. |
| `400` | `unsupported_parameter` | n is not 1, response_format or text.format is not text, or background is true. param identifies the field. |
| `400` | `conversation_model_mismatch` | The conversation belongs to another assistant. Use the same model or start a new conversation. |
| `404` | `model_not_found` | No published assistant with this ID is available to the key. GET /models lists available assistants. |
| `404` | `conversation_not_found` | Not an API conversation in this workspace, or its assistant is unavailable to the key. Clear the saved ID and start a new conversation. |
| `409` | `conversation_busy` | The previous turn is still running. This message was not stored. Wait and retry with the same Idempotency-Key. |
| `422` | `idempotency_key_reused` | The same Idempotency-Key was used with different text. Use a new key for each new message. |

All codes and recovery steps: [Errors](https://docs.sonecentral.com/en/hatalar.md).
