# Endpoint TTS tương thích OpenAI

Source: https://docs.vieneu.io/vi/docs/cloud-api/openai-compatible

API công khai gốc của VieNeu chạy bất đồng bộ (`POST /v1/tts` trả về một `jobId`
để bạn poll). `POST /v1/audio/speech` là **bản thay thế nguyên chỗ cho endpoint
cùng tên của OpenAI**: trỏ một SDK OpenAI vào base URL của VieNeu, đưa nó khoá
VieNeu của bạn, và nó chạy mà không cần sửa gì thêm.

Chuyện này quan trọng hơn vẻ ngoài của nó. Rất nhiều phần mềm đã nói được đúng
một endpoint này — Open WebUI, SillyTavern, LobeChat, LiteLLM, plugin OpenAI của
LiveKit và của Pipecat, cùng vô số script lẻ — và tất cả đều cho phép đặt
`base_url` riêng. Hỗ trợ đúng hình dạng request này chính là thứ khiến VieNeu
dùng được trong chúng mà không cần plugin, không cần adapter và không tốn công
gì ở phía chúng tôi.

## Endpoint {#endpoint}

```
POST /api/v1/audio/speech
Authorization: Bearer <VieNeu API key>      # vn_sk_... (X-API-Key also accepted)
Content-Type: application/json
```

Trả về **byte audio ngay trong phản hồi** — không cần poll.

## Thân request {#request-body}

| Trường | OpenAI | Hành vi ở VieNeu |
|-------|--------|-----------------|
| `input` | bắt buộc | văn bản cần tổng hợp ✅ |
| `model` | bắt buộc | chọn engine — và từ 2026-09-24 chỉ còn một engine, `v4`. Các tên của OpenAI (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), `vieneu` và `vieneu-v4` đều trỏ về nó. Mọi giá trị khác vẫn được nhận rồi bỏ qua, như trước — trừ một tên `vieneu-…` trỏ tới engine không tồn tại hoặc đã ngừng, giá trị đó bị từ chối: `vieneu-v3` trả 400 kèm `Model 'vieneu-v3' is retired. Engine "v3" was retired on 2026-09-24. …`. |
| `voice` | `alloy`, … | một voice id của **VieNeu** — liệt kê bằng `GET /v1/audio/voices`. Tên giọng của OpenAI không được ánh xạ. Bỏ trống để dùng giọng mặc định. |
| `response_format` | `mp3` (mặc định) | `mp3` (mặc định), `wav`, `opus`, `pcm`, cộng thêm `ulaw` của VieNeu. `aac` và `flac` trả về 400. |
| `speed` | 0.25–4.0 | nhận trọn dải của OpenAI rồi **kẹp** về 0.5–2.0, khoảng mà engine còn giữ được chất lượng. Một giá trị hợp lệ theo OpenAI không bao giờ bị báo lỗi. |
| `stream_format` | `audio` \| `sse` | hỗ trợ cho `pcm` và `ulaw` — xem [Streaming](#streaming). |
| `instructions` | (gpt-4o-mini-tts) | được nhận rồi bỏ qua. Hãy dùng thẻ biểu cảm đặt thẳng trong văn bản thay cho nó. |
| `sample_rate` | — | phần mở rộng của VieNeu: 8000, 16000, 22050, 24000, 44100 hoặc 48000. |
| `emotion` | — | phần mở rộng của VieNeu còn sót lại từ `v3`: `natural` (mặc định) hoặc `storytelling`. Được nhận rồi **bỏ qua** trên `v4` — xem bên dưới. |
| `aiRefine` | — | phần mở rộng của VieNeu, **mặc định `false`** — xem [Tính phí](#billing). |

:::caution `emotion` không còn làm gì nữa
`emotion` vốn là tham số của `v3`, mà `v3` đã ngừng phục vụ trên Cloud API từ
2026-09-24. `v4` — giờ là engine duy nhất — **không có kiểu đọc nào cả**. Nó thể
hiện được ba thẻ biểu cảm đặt thẳng trong văn bản — `[cười]`, `[thở dài]` và
`[hắng giọng]` — còn **mọi thẻ khác thì bị xoá khỏi văn bản của bạn** chứ không
được đọc lên. Một request đặt `"emotion": "storytelling"` vẫn được nhận, vẫn bị
tính phí, và vẫn đọc bằng giọng bình thường; đó không phải lỗi, nên không có gì
trong phản hồi báo cho bạn biết trường đó đã rơi vào khoảng không. Nêu
`"model": "vieneu-v3"` để đòi lại hành vi cũ thì là 400.

`GET /v1/emotion-tags` trả về đúng những gì engine thực sự thể hiện được, và nó
không cần API key. Không truyền `engine` thì nó trả lời cho engine mặc định —
`v4` — tức là ba thẻ ở trên và một danh sách kiểu đọc rỗng; `?engine=v3` là 400.
Hãy hỏi nó thay vì đoán, nhất là khi bạn đang mang theo một danh sách thẻ viết
cho `v3`.
:::

### Định dạng {#formats}

`pcm` và `ulaw` **không có header**: chuỗi byte không mang theo sample rate, nên
hãy đọc nó từ header phản hồi `X-Sample-Rate`.

Về tần số: `pcm` mặc định **24000**, khớp với tài liệu của OpenAI, để client nào
tuân thủ đúng contract của họ sẽ phát ở đúng tốc độ. Mọi thứ còn lại mặc định
48000. `opus` luôn là 48 kHz (bản thân Opus vốn vậy) còn `ulaw` luôn 8 kHz, đúng
thứ một đường điện thoại cần — truyền vào một `sample_rate` mâu thuẫn thì bị từ
chối chứ không bị lặng lẽ bỏ qua.

> mp3 và opus cần có `ffmpeg` trên worker phục vụ request. Một node ra đời trước
> nó sẽ trả **503** kèm tên định dạng, thay vì trả về một thứ không phải định
> dạng bạn yêu cầu. Ngoại lệ duy nhất là request không hề nêu định dạng: vì mp3
> là mặc định của *chúng tôi* chứ không phải lựa chọn của bạn, những request đó
> lùi về wav thay vì báo lỗi.

## Ví dụ {#examples}

**curl**
```bash
curl https://api.vieneu.io/api/v1/audio/speech \
  -H "Authorization: Bearer $VIENEU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "model": "tts-1", "input": "Xin chào, đây là VieNeu.", "voice": "Ngọc Lan" }' \
  --output speech.mp3
```

**SDK Python của OpenAI** (nguyên bản, trỏ vào VieNeu)
```python
from openai import OpenAI
client = OpenAI(api_key="vn_sk_...", base_url="https://api.vieneu.io/api/v1")

resp = client.audio.speech.create(
    model="tts-1",
    voice="Ngọc Lan",             # a VieNeu voice id, from GET /v1/audio/voices
    input="Xin chào, đây là VieNeu.",
)
resp.stream_to_file("speech.mp3")
```

**Điện thoại (mu-law 8 kHz)**
```bash
curl https://api.vieneu.io/api/v1/audio/speech \
  -H "Authorization: Bearer $VIENEU_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "input": "Xin chào", "response_format": "ulaw" }' \
  --output line.raw     # raw G.711 mu-law, 8000 Hz, no header
```

## Streaming {#streaming}

Đặt `stream_format` để bắt đầu nhận audio ngay khi nó còn đang được sinh ra,
thay vì đợi trọn cả tệp.

- **`audio`** — các byte về theo chunked transfer encoding. Cứ nối chúng lại;
  kết quả đúng bằng đoạn audio bạn nhận được nếu lấy nguyên khối.
- **`sse`** — Server-Sent Events. Sự kiện `speech.audio.delta` mang audio dạng
  base64; luồng kết thúc bằng đúng một `speech.audio.done` (audio đã trọn vẹn)
  hoặc `speech.audio.error` (thì không).

```python
with client.audio.speech.with_streaming_response.create(
    model="tts-1", voice="Ngọc Lan", input="…", response_format="pcm",
    extra_body={"stream_format": "audio"},
) as resp:
    resp.stream_to_file("speech.pcm")   # raw s16le, 24 kHz
```

**`speech.audio.done` là bằng chứng duy nhất cho thấy audio còn nguyên vẹn.**
Nếu một worker chết giữa chừng thì luồng dừng lại, mà ngoài sự kiện đó ra, một
luồng bị cắt cụt không tài nào phân biệt được với một luồng vốn dĩ ngắn. Khi sự
kiện đó không tới, hãy bỏ đoạn audio đi — request được hoàn token tự động.

### Streaming chỉ có cho `pcm` và `ulaw` {#streaming-is-pcm-and-ulaw-only}

Audio phát theo luồng được sinh theo từng chunk và mỗi chunk được mã hoá độc
lập, nên ghép chúng lại chỉ cho ra kết quả hợp lệ với những định dạng không có
header:

| Định dạng | Stream được | Vì sao không |
|---|---|---|
| `pcm`, `ulaw` | ✅ | mẫu thô; nối lại *chính là* phát |
| `wav` | ❌ | chunk nào cũng lặp lại header 44 byte của nó ở giữa tệp |
| `mp3` | ❌ | mỗi chunk lại áp lại độ trễ của bộ mã hoá — đo được khoảng 24 ms im lặng chèn vào mỗi mối nối, kèm một tiếng "cụp" |
| `opus` | ❌ | mỗi chunk là một luồng Ogg hoàn chỉnh; hầu hết trình duyệt chỉ phát cái đầu tiên |

Yêu cầu một định dạng không stream được kèm `stream_format` sẽ nhận về 400, thay
vì được giao một đoạn audio thủng lỗ chỗ. Muốn một **tệp** mp3 hay opus hoàn
chỉnh thì bỏ `stream_format` đi — phản hồi đồng bộ không dính vấn đề nào trong
số này.

Danh sách sẽ nới ra khi worker giữ được một bộ mã hoá duy nhất suốt cả luồng;
hiện tại nó khởi tạo mới cho từng chunk.

## Giọng đọc {#voices}

```
GET /api/v1/audio/voices[?engine=v4]     →  { "voices": ["Ngọc Lan", …] }
```

Endpoint đi kèm, dành cho các client tương thích OpenAI — chúng tìm đúng route
này để đổ vào ô chọn giọng của mình. `GET /v1/voices` mới là bản đầy đủ hơn —
tên, giới tính, vùng miền, và cả những giọng bạn tự nhân bản.

## Lỗi {#errors}

Trả về theo hình dạng của OpenAI:
```json
{ "error": { "message": "...", "type": "invalid_request_error", "param": "input", "code": null } }
```
- `400 invalid_request_error` — thiếu `input`, một model `vieneu-…` không tồn
  tại hoặc đã ngừng (`vieneu-v3`, từ 2026-09-24), một `response_format` không
  được hỗ trợ, `stream_format` đi kèm định dạng không stream được, hoặc một
  `sample_rate` mâu thuẫn với định dạng.
- `401 authentication_error` — key thiếu, sai dạng hoặc đã bị thu hồi.
- `403 insufficient_quota` — token grant của khoá đã cạn hoặc hết hạn. **Không
  phải 402:** không chỗ nào trên `/v1` trả `402` cả. Ở đây chuyện "hết tiền" tách
  ra làm hai status, và chính chỗ tách đó mới là thứ đáng rẽ nhánh: grant cạn
  hoặc hết hạn là `403 insufficient_quota` và **tự nó không hết**, còn trần theo
  ngày hoặc theo tuần đã dùng hết là `429 rate_limit_exceeded` và sẽ tự hết. Nên
  `insufficient_quota` nghĩa là nạp thêm hoặc gia hạn, còn `rate_limit_exceeded`
  nghĩa là chờ.
- `422 content_policy_violation` — bị kiểm duyệt từ chối (chỉ khi `aiRefine` bật).
- `429 rate_limit_exceeded` — bị giới hạn tần suất, **hoặc** một trần token theo
  ngày/tuần đã dùng hết. Envelope này không phân biệt được hai thứ đó: các route
  gốc gọi tên cái trần trong `code` và nói khi nào nó nhả ra trong `resetAt`, mà
  cả hai trường ấy đều không sống sót khi chuyển sang hình dạng của OpenAI. Xem
  [Lỗi](./errors.md#the-openai-compatible-route).
- `503` — không có worker nào cho engine được yêu cầu, hoặc không worker nào mã
  hoá được định dạng được yêu cầu.
- `500 api_error` — tổng hợp thất bại (số token đã trừ được **hoàn lại**).

Một lưu ý về `403`. Cái `403` hạn mức ở trên là do chính handler gán type. Còn
một `403` phát sinh *trước khi* handler chạy — một guard từ chối vì gói của bạn
không có tính năng đó — thì được gán type chỉ theo status, và phép ánh xạ ấy gộp
`401` với `403` thành `authentication_error`. Vậy nên `insufficient_quota` thì
luôn là chuyện tiền, còn `authentication_error` có thể là chuyện khoá, cũng có
thể là chuyện gói.

Mọi phản hồi đều mang `X-Request-Id`; hãy kèm nó khi gửi yêu cầu hỗ trợ. Body thì
không lặp lại giá trị đó — envelope của OpenAI không có trường nào cho nó — nên
hãy đọc từ header.

## Tính phí {#billing}

Token bị trừ theo số ký tự **gửi lên**, nên chi phí một lời gọi có thể biết trước
chỉ từ chính request. Gói của API key trả trước; khi gói đó không trả được, một gói
khác còn hoạt động của tài khoản trả trọn lời gọi. Tổng hợp thất bại thì được hoàn
token tự động, về đúng gói đã trả.

Ở đây `aiRefine` mặc định **`false`**, khác với web app vốn bật sẵn. Khi tắt,
văn bản được tổng hợp đúng như lúc gửi lên: không kiểm duyệt bằng AI, không
chuẩn hoá cách đọc, không phụ phí, và bớt được một vòng gọi model nên bớt độ
trễ. Đặt `true` để có hành vi của web app — công thức, từ viết tắt và tiếng Anh
lẫn trong câu đều đọc đúng, nội dung được kiểm — và bị tính kèm phụ phí AI.

Phần chuẩn bị văn bản tất định (đánh vần công thức hoá học, hạ CHỮ HOA, lớp
phoneme sea-g2p) vẫn chạy trong cả hai trường hợp. `aiRefine` chỉ chi phối lời
gọi AI.

## Khác biệt so với OpenAI {#differences-from-openai}

- `voice` cần một voice id của VieNeu, không phải tên giọng của OpenAI.
- `aac` và `flac` không được hỗ trợ; `ulaw` và `sample_rate` là phần thêm vào.
- `stream_format` chỉ phủ `pcm` và `ulaw`, không phải mọi định dạng.
- `speed` nằm ngoài 0.5–2.0 bị kẹp lại chứ không được giữ đúng nguyên giá trị.
- Những tính năng phong phú hơn của VieNeu — `dialogue` nhiều người nói, `dub`,
  lồng tiếng theo SRT — không có thứ tương đương bên OpenAI. Hãy dùng các endpoint
  gốc `/v1/*`. Giọng nhân bản thì tạo trên Studio web rồi dùng như một `voiceId`
  bình thường ở `POST /v1/tts` (không dùng được ở route này — id `clone_…` gửi vào
  đây là 400).
