# OpenAI-compatible TTS endpoint

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

VieNeu's native public API is asynchronous (`POST /v1/tts` returns a `jobId` you
poll). `POST /v1/audio/speech` is a **drop-in for OpenAI's endpoint of the same
name**: point an OpenAI SDK at VieNeu's base URL, give it your VieNeu key, and it
works without any other change.

This matters more than it looks. A large amount of software already speaks this
one endpoint — Open WebUI, SillyTavern, LobeChat, LiteLLM, LiveKit's and
Pipecat's OpenAI plugins, a long tail of scripts — and all of them accept a
custom `base_url`. Supporting this shape is what makes VieNeu usable in them with
no plugin, no adapter and no work on our side.

## Endpoint

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

Returns the audio **bytes synchronously** — no polling.

## Request body

| Field | OpenAI | VieNeu behavior |
|-------|--------|-----------------|
| `input` | required | the text to synthesize ✅ |
| `model` | required | selects the engine — and since 2026-09-24 there is one, `v4`. OpenAI's names (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), `vieneu` and `vieneu-v4` all resolve to it. Any other value is accepted and ignored, as before — except a `vieneu-…` name for an engine that does not exist or has been retired, which is rejected: `vieneu-v3` answers 400 with `Model 'vieneu-v3' is retired. Engine "v3" was retired on 2026-09-24. …`. |
| `voice` | `alloy`, … | a **VieNeu** voice id — list them with `GET /v1/audio/voices`. OpenAI's voice names are not mapped. Omit for the default voice. |
| `response_format` | `mp3` (default) | `mp3` (default), `wav`, `opus`, `pcm`, plus VieNeu's `ulaw`. `aac` and `flac` return 400. |
| `speed` | 0.25–4.0 | accepted across OpenAI's full range and **clamped** to 0.5–2.0, where the engine holds quality. A valid OpenAI value never returns an error. |
| `stream_format` | `audio` \| `sse` | supported for `pcm` and `ulaw` — see [Streaming](#streaming). |
| `instructions` | (gpt-4o-mini-tts) | accepted and ignored. Use inline cue tags instead. |
| `sample_rate` | — | VieNeu extension: 8000, 16000, 22050, 24000, 44100 or 48000. |
| `emotion` | — | VieNeu extension left over from `v3`: `natural` (default) or `storytelling`. Accepted and **ignored** on `v4` — see below. |
| `aiRefine` | — | VieNeu extension, **default `false`** — see [Billing](#billing). |

:::caution `emotion` does nothing any more
`emotion` was a `v3` parameter, and `v3` was retired from the cloud API on
2026-09-24. `v4` — now the only engine — has **no reading styles**. It renders
three inline cue tags — `[cười]`, `[thở dài]` and `[hắng giọng]` — and
**deletes every other tag from your text** rather than voicing it. A request
that sets `"emotion": "storytelling"` is accepted, billed, and read in the
ordinary voice; it is not an error, so nothing in the response tells you the
field went nowhere. Naming `"model": "vieneu-v3"` to get the old behaviour back
is a 400.

`GET /v1/emotion-tags` returns what the engine can actually render, and it needs
no API key. With no `engine` it answers for the default — `v4` — so the reply is
the three cues above and an empty list of styles; `?engine=v3` is a 400. Ask it
rather than assuming, especially if you are carrying a tag list written against
`v3`.
:::

### Formats

`pcm` and `ulaw` are **headerless**: the bytes carry no sample rate, so read it
from the `X-Sample-Rate` response header.

Rates: `pcm` defaults to **24000**, matching what OpenAI documents so a client
following their contract plays it at the right speed. Everything else defaults to
48000. `opus` is always 48 kHz (Opus itself is) and `ulaw` always 8 kHz, which is
what a phone line wants — passing a conflicting `sample_rate` is rejected rather
than quietly ignored.

> mp3 and opus need `ffmpeg` on the worker that serves the request. A node that
> predates it answers **503** naming the format, rather than returning something
> that is not the format you asked for. The one exception is a request that never
> named a format: since mp3 is *our* default rather than your choice, those fall
> back to wav instead of failing.

## 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
```

**OpenAI Python SDK** (unmodified, pointed at 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")
```

**Telephony (8 kHz mu-law)**
```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

Set `stream_format` to start receiving audio while it is still being generated,
instead of waiting for the whole file.

- **`audio`** — the bytes arrive as chunked transfer encoding. Append them; the
  result is the same audio you would have got in one piece.
- **`sse`** — Server-Sent Events. `speech.audio.delta` events carry base64 audio;
  the stream ends with exactly one `speech.audio.done` (the audio is complete) or
  `speech.audio.error` (it is not).

```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` is the only proof the audio is whole.** If a worker dies
mid-generation the stream stops, and a truncated stream is otherwise
indistinguishable from a short one. When that event does not arrive, discard the
audio — the request is refunded automatically.

### Streaming is `pcm` and `ulaw` only

Streamed audio is generated in chunks and each chunk is encoded independently, so
joining them only produces a valid result for the headerless formats:

| Format | Streamable | Why not |
|---|---|---|
| `pcm`, `ulaw` | ✅ | raw samples; concatenation *is* playback |
| `wav` | ❌ | every chunk repeats its 44-byte header mid-file |
| `mp3` | ❌ | each chunk re-applies the encoder's delay — measured at ~24 ms of inserted silence per seam, plus a click |
| `opus` | ❌ | each chunk is a complete Ogg stream; most browsers play only the first one |

Asking for a non-streamable format with `stream_format` returns 400 rather than
shipping audio with gaps in it. For a complete mp3 or opus **file**, drop
`stream_format` — the synchronous response has none of these problems.

This will widen once the worker can hold a single encoder open across a whole
stream; today it starts a fresh one per chunk.

## Voices

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

A companion to this endpoint for OpenAI-compatible clients, which look for this
route to fill their voice picker. `GET /v1/voices` is the richer version — names,
gender, region, and your own cloned voices.

## Errors

Returned in OpenAI's shape:
```json
{ "error": { "message": "...", "type": "invalid_request_error", "param": "input", "code": null } }
```
- `400 invalid_request_error` — missing `input`, an unknown or retired
  `vieneu-…` model (`vieneu-v3`, since 2026-09-24), an unsupported
  `response_format`, `stream_format` with a format that cannot be streamed, or
  a `sample_rate` that contradicts the format.
- `401 authentication_error` — the key is missing, malformed or revoked.
- `403 insufficient_quota` — the key's token grant is exhausted or expired.
  **Not 402:** nothing on `/v1` answers `402` at all. Being out of credit splits
  across two statuses here, and the split is the part worth branching on: an
  exhausted or expired grant is `403 insufficient_quota` and **will not clear on
  its own**, while a spent daily or weekly cap is `429 rate_limit_exceeded` and
  will. So `insufficient_quota` means top up or renew; `rate_limit_exceeded`
  means wait.
- `422 content_policy_violation` — refused by moderation (only when `aiRefine` is on).
- `429 rate_limit_exceeded` — the rate limiter throttled you, **or** a daily or
  weekly token cap is spent. This envelope cannot tell those two apart: the
  native routes name the cap in `code` and say when it lifts in `resetAt`, and
  neither field survives the translation into OpenAI's shape. See
  [Errors](./errors.md#the-openai-compatible-route).
- `503` — no worker for the requested engine, or none that can encode the
  requested format.
- `500 api_error` — synthesis failed (the token charge is **refunded**).

One caveat on `403`. The quota `403` above is typed by the handler itself. A
`403` raised *before* the handler runs — a guard refusing a feature your plan
does not include — is typed from the status alone, which folds `401` and `403`
together as `authentication_error`. So `insufficient_quota` always means money,
but `authentication_error` can mean either the key or the plan.

Every response carries `X-Request-Id`; quote it in a support request. The body
does not repeat it — OpenAI's envelope has no field for it, so read the header.

## Billing

Tokens are deducted by the **submitted** character count, so the cost of a call
is predictable from the request alone. The API key's own plan pays first; when
it cannot, another active plan on your account pays the whole call. A failed
synthesis is refunded automatically, to the plan that paid.

`aiRefine` defaults to **`false`** here, unlike the web app, where it is on. With
it off the text is synthesized as submitted: no AI moderation, no pronunciation
normalization, no surcharge, and one less model round-trip of latency. Set it to
`true` to get the web app's behaviour — formulas, acronyms and mixed-in English
read correctly, content checked — billed with the AI surcharge.

Deterministic text preparation (chemistry spelling, ALL-CAPS folding, the sea-g2p
phoneme layer) runs either way. `aiRefine` controls only the AI call.

## Differences from OpenAI

- `voice` expects a VieNeu voice id, not an OpenAI voice name.
- `aac` and `flac` are not supported; `ulaw` and `sample_rate` are additions.
- `stream_format` covers `pcm` and `ulaw` only, not every format.
- `speed` outside 0.5–2.0 is clamped rather than honoured exactly.
- The richer VieNeu features — multi-speaker `dialogue`, `dub`, SRT dubbing —
  have no OpenAI equivalent. Use the native `/v1/*` endpoints. Cloned voices are
  made in the web Studio and then used as an ordinary `voiceId` on `POST /v1/tts`
  (not on this route — a `clone_…` id here is a 400).
