{"openapi": "3.1.0", "info": {"title": "voicekit API", "version": "0.1.0", "summary": "Local text generation, voice cloning and text-to-speech.", "description": "The voicekit API lets you **generate text** with a local LLM, **clone voices** from a short recording, and\n**synthesize speech** in those voices. Everything runs on the machine that hosts this server: no audio or\ntext is sent to third-party services.\n\n## Authentication\n\nEvery request (except `health`, `me`, `login` and this documentation) needs an API key:\n\n```\nAuthorization: Bearer vk_…\n```\n\n`X-API-Key: vk_…` works too. The server administrator creates keys in the admin panel (\"API keys\" tab)\nor on the host:\n\n```\nvoicekit keys create --name my-app --scope tts\nvoicekit keys list\nvoicekit keys revoke my-app\n```\n\n**Never put a key in browser code.** Call the API from your backend, or through a proxy that adds the\nheader. The server sends no CORS headers.\n\nA key is shown only once when it is created; the server stores only its SHA-256 hash. Revoking a key takes\neffect immediately, without a restart. Repeated failed attempts from one client are refused with\n`429 Too Many Requests` for a few minutes.\n\n### Scopes\n\n| Scope | Grants |\n|---|---|\n| `text` | Text generation with the local LLM (`POST /chat`, `POST /write`, `GET /llm`) |\n| `clone` | Creating, listing, inspecting and deleting voices (`/voices`, `/train`) |\n| `tts` | Speech synthesis, audio downloads and the history (`/speak`, `/audio`, `/history`) |\n| `admin` | Managing API keys (`/keys`). Only for trusted tools: an admin can create keys with any scope |\n\nListing voices is allowed with either `clone` or `tts`. A key can hold any combination of scopes.\n\n## Quick start for TTS clients\n\n1. **Handshake**: `GET /api/v1/handshake` once at startup. With the `tts` scope it returns the available\n   `voices`, all `languages` (with ISO code and native name), the `default_voice` / `default_language`\n   and the limits. Show the voices and languages in your UI and let the user choose.\n2. **Speak**: `POST /api/v1/speak` with the chosen `voice` and `language` (its `id` or `code`) and the text.\n3. **Download**: fetch the returned `url` (WAV) or `mp3_url`.\n\nEvery voice can speak every listed language (`cross_lingual: true`). It sounds most natural in the\nvoice's own `language`, so preselect that when the user picks a voice. Voices with `recommended: false`\ncarry `warnings` that are worth showing. Call the handshake again whenever you want a fresh voice list,\nfor example after cloning a new voice.\n\n## Jobs: asynchronous by default\n\nVoice cloning and speech synthesis run on a single GPU, one job at a time. Starting one returns\n`202 Accepted` with a job id. You then have three options:\n\n1. **Poll** `GET /api/v1/jobs/{id}` until `status` is `done` or `error`.\n2. **Stream** `GET /api/v1/jobs/{id}/events` (Server-Sent Events) for live progress.\n3. **Wait**: pass `\"wait\": true` (or `wait=true` in a form) and the request returns only when the job has\n   finished, with the same body as polling.\n\nIf the GPU is busy, starting a job returns `409 Conflict`: retry after a few seconds. Finished jobs are kept for one hour.\n\nThe first job after the server has been idle loads the TTS model (about 10 s). The model then stays\nloaded for 5 minutes. Synthesis takes roughly 1.5 s per second of generated speech.\n\n## Errors\n\nErrors use the usual HTTP status codes, with a JSON body:\n\n```\n{\"error\": \"A voice named 'anna' already exists.\", \"hint\": \"Pick another name, or replace it (overwrite=true).\"}\n```\n\n`400` invalid input · `401` missing or invalid key · `403` key lacks the scope (or the admin panel is used\nfrom a network that is not allowed) · `404` unknown voice, job or file · `409` name taken, GPU busy or text\ngenerator busy · `411` chunked body (send `Content-Length`) · `413` body too large · `429` hourly limit for\nthis key reached, or too many failed logins (see `Retry-After`) · `507` server disk almost full.\n\n## Limits\n\nText to speak: at most 5,000 characters per request (split longer documents). Voice reference audio: WAV,\nFLAC, MP3 or M4A, at most 32 MiB. 6–15 seconds of clean speech works best; longer references make the voice\nramble. Text-generation chat: at most 16 messages of history per request.\n\n## Running it on the internet\n\nAt least one account or API key must exist before the server listens on a network interface. Use HTTPS,\nso that keys and passwords are never sent in clear text.\n\n**Reverse proxy on another machine** (e.g. a gateway that routes traffic to the machines of a private\nnetwork): let voicekit listen on its private address, trust forwarding headers only from the proxy, and\ntell it the public URL:\n\n```\nvoicekit studio --host 192.168.1.20 --port 8765 \\\n    --trust-proxy 192.168.1.10 --allow-ip 192.168.1.10 \\\n    --public-url https://voice.example.com --admin-allow 192.168.1.0/24\n```\n\n- `--allow-ip`: accept connections only from the proxy (and, if needed, your webapp server).\n- `--admin-allow`: the admin panel and admin keys work only from your own network.\n\n- `--trust-proxy 192.168.1.10`: client IP (for the failed-login throttle), scheme and host are taken from\n  `X-Forwarded-For/-Proto/-Host`, but only when the request comes from that address. Other machines on the\n  network cannot fake them.\n- `--public-url`: the origin browsers see. The admin panel's changes are only accepted from that origin,\n  even when the proxy rewrites `Host` (nginx does by default). Session cookies are also marked `Secure`.\n- Firewall port 8765 so that only the proxy can reach it.\n- Per key: 120 jobs and 300 text generations per hour by default (`--jobs-per-hour`, `--writes-per-hour`);\n  exceeding them returns `429` with `Retry-After`. A disk that is almost full returns `507`.\n\nThe server operator's full checklist is in `docs/SECURITY.md` in the project.\n\nProxy configuration (Caddy, on the proxy machine; automatic Let's Encrypt certificates):\n\n```\nvoice.example.com {\n    reverse_proxy 192.168.1.20:8765 {\n        flush_interval -1\n    }\n}\n```\n\nnginx: `proxy_pass http://192.168.1.20:8765;`, `proxy_buffering off;` (live progress and streamed text),\n`proxy_read_timeout 1h;`, `client_max_body_size 40m;` (voice uploads), and set `X-Forwarded-For`,\n`X-Forwarded-Proto` and `X-Forwarded-Host`.\n\n**Built-in TLS** without a proxy: `voicekit studio --host 0.0.0.0 --port 8443 --tls-cert fullchain.pem --tls-key privkey.pem`.\n\nThe \"local use without a login\" mode applies only to direct loopback requests without any credentials\nconfigured. It never applies to proxied requests.\n\n## Admin panel and key management\n\nPeople sign in to the web admin panel (`/`) with a username and password. Accounts are created on the\nserver with `voicekit users add <name> --admin`. Accounts with the `admin` scope manage API keys in the\npanel, or through `GET/POST/DELETE /api/v1/keys` (also usable with an API key that has the `admin` scope).\n"}, "servers": [{"url": "/", "description": "This server"}], "security": [{"bearer": []}, {"apiKeyHeader": []}], "tags": [{"name": "Speech (TTS)", "description": "Turn text into speech with a saved voice. Scope `tts`."}, {"name": "Voice cloning", "description": "Create and manage voices. Scope `clone`."}, {"name": "Text generation", "description": "Draft text with the local LLM. Scope `text`."}, {"name": "Jobs", "description": "Follow asynchronous cloning and synthesis jobs."}, {"name": "API keys", "description": "Create, list and revoke API keys. Scope `admin`."}, {"name": "Activity log", "description": "Sign-ins, API calls and changes, as shown in the admin panel. Scope `admin`."}, {"name": "System", "description": "Health, identity and browser login. No key required."}], "paths": {"/api/v1/handshake": {"get": {"tags": ["Speech (TTS)"], "operationId": "handshake", "x-scope": "any key (sections per scope)", "summary": "Handshake: available voices and languages", "description": "Call once when your client starts. It returns the API version and the key's scopes, plus one section per scope the key holds (`null` otherwise). The `tts` section lists the voices and languages to offer to your users, the defaults to preselect, and the limits for `POST /speak`. Voices are sorted with recommended ones first.", "responses": {"200": {"description": "Capabilities for this key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Handshake"}, "example": {"api_version": "v1", "server_version": "0.1.0", "key": {"name": "my-website", "scopes": ["tts"]}, "scopes": ["tts"], "text": null, "clone": null, "tts": {"voices": [{"id": "anna", "name": "anna", "language": "German", "language_code": "de", "mode": "icl", "created_at": "2026-10-30T11:17:43+00:00", "recommended": true, "warnings": []}, {"id": "old-voice", "name": "old-voice", "language": "German", "language_code": "de", "mode": "icl", "created_at": "2026-10-29T23:06:23+00:00", "recommended": false, "warnings": ["Built from a 1124 s reference; output may ramble or repeat. Re-create the voice from 6-15 s of speech."]}], "languages": [{"id": "German", "code": "de", "name": "German", "native_name": "Deutsch"}, {"id": "English", "code": "en", "name": "English", "native_name": "English"}, {"id": "French", "code": "fr", "name": "French", "native_name": "Français"}], "default_voice": "anna", "default_language": "German", "cross_lingual": true, "max_text_chars": 5000, "formats": ["wav", "mp3"], "sample_rate": 24000, "busy": false, "model_loaded": true}}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s -H \"Authorization: Bearer $VOICEKIT_KEY\" https://voice.example.com/api/v1/handshake   | jq '.tts | {voices: [.voices[] | {id, language, recommended}], languages: [.languages[].code], default_voice}'"}, {"lang": "python", "label": "Python", "source": "import requests\n\nBASE, H = \"https://voice.example.com\", {\"Authorization\": \"Bearer vk_...\"}\ntts = requests.get(f\"{BASE}/api/v1/handshake\", headers=H).json()[\"tts\"]\n\nfor v in tts[\"voices\"]:\n    flag = \"\" if v[\"recommended\"] else \"  (!) \" + v[\"warnings\"][0]\n    print(f\"{v['id']:<16} {v['language']}{flag}\")\nlanguages = {l[\"code\"]: l[\"native_name\"] for l in tts[\"languages\"]}\n\nvoice = tts[\"default_voice\"]\nlanguage = next(v[\"language\"] for v in tts[\"voices\"] if v[\"id\"] == voice)  # its own language sounds best"}, {"lang": "javascript", "label": "JavaScript", "source": "// Fill two <select> elements from the handshake and keep the language in sync with the voice.\nconst BASE = \"https://voice.example.com\";\nconst H = { Authorization: \"Bearer vk_...\" };\nconst { tts } = await (await fetch(`${BASE}/api/v1/handshake`, { headers: H })).json();\n\nconst voiceSel = document.querySelector(\"#voice\"), langSel = document.querySelector(\"#language\");\nvoiceSel.innerHTML = tts.voices.map((v) =>\n  `<option value=\"${v.id}\">${v.name} · ${v.language}${v.recommended ? \"\" : \" ⚠\"}</option>`).join(\"\");\nlangSel.innerHTML = tts.languages.map((l) => `<option value=\"${l.code}\">${l.native_name}</option>`).join(\"\");\n\nconst syncLanguage = () => {\n  const v = tts.voices.find((x) => x.id === voiceSel.value);\n  if (v) langSel.value = v.language_code;           // preselect the voice's own language\n};\nvoiceSel.value = tts.default_voice;\nsyncLanguage();\nvoiceSel.addEventListener(\"change\", syncLanguage);\n\n// later: POST /api/v1/speak with { voice: voiceSel.value, language: langSel.value, text, wait: true }"}]}}, "/api/v1/speak": {"post": {"tags": ["Speech (TTS)"], "operationId": "speak", "x-scope": "tts", "summary": "Synthesize speech", "description": "Starts a synthesis job for `text` in the given voice. Long texts are split at paragraph and sentence boundaries and joined with short pauses. The result is a 24 kHz mono WAV (an MP3 is available from the same URL with `?format=mp3`).", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/SpeakRequest"}, "example": {"voice": "anna", "text": "Guten Tag! Schön, dass Sie da sind.", "wait": true}}}}, "responses": {"200": {"description": "With `wait: true`: the finished job.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Job"}, "example": {"id": "3f9c0a1b2d4e", "kind": "speak", "name": "anna", "status": "done", "stage": {"stage": "save"}, "created_at": "2026-10-30T15:48:24+00:00", "log": ["Text: 63 characters -> 1 chunk(s) (target 300, max 500)."], "result": {"elapsed_s": 6.1, "item": {"file": "20261030-164824-anna.wav", "url": "/api/v1/audio/20261030-164824-anna.wav", "mp3_url": "/api/v1/audio/20261030-164824-anna.wav?format=mp3", "voice": "anna", "language": "German", "text": "Guten Tag! Schön, dass Sie da sind.", "duration_s": 3.8, "created_at": "2026-10-30T15:48:30+00:00", "warnings": [], "generation_s": 5.4}}}}}}, "202": {"description": "Job started (or, with `wait`, still running after 30 minutes).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobStarted"}}}}, "400": {"description": "Invalid input (empty or too long text, unsupported language).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "404": {"description": "Unknown voice.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "The GPU is busy with another job.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s https://voice.example.com/api/v1/speak \\\n  -H \"Authorization: Bearer $VOICEKIT_KEY\" -H \"Content-Type: application/json\" \\\n  -d '{\"voice\": \"anna\", \"text\": \"Guten Tag! Schön, dass Sie da sind.\", \"wait\": true}' \\\n  | jq -r .result.item.mp3_url \\\n  | xargs -I{} curl -s -H \"Authorization: Bearer $VOICEKIT_KEY\" -o hello.mp3 \"https://voice.example.com{}\""}, {"lang": "python", "label": "Python", "source": "import requests\n\nBASE = \"https://voice.example.com\"\nH = {\"Authorization\": \"Bearer vk_...\"}\n\njob = requests.post(f\"{BASE}/api/v1/speak\", headers=H, timeout=1900, json={\n    \"voice\": \"anna\", \"text\": \"Guten Tag! Schön, dass Sie da sind.\", \"wait\": True,\n}).json()\nif job[\"status\"] != \"done\":\n    raise RuntimeError(job.get(\"error\"))\naudio = requests.get(BASE + job[\"result\"][\"item\"][\"mp3_url\"], headers=H)\nopen(\"hello.mp3\", \"wb\").write(audio.content)"}, {"lang": "javascript", "label": "JavaScript", "source": "const BASE = \"https://voice.example.com\";\nconst H = { Authorization: \"Bearer vk_...\", \"Content-Type\": \"application/json\" };\n\nconst job = await (await fetch(`${BASE}/api/v1/speak`, {\n  method: \"POST\", headers: H,\n  body: JSON.stringify({ voice: \"anna\", text: \"Hello there!\", language: \"English\", wait: true }),\n})).json();\nconst mp3 = await (await fetch(BASE + job.result.item.mp3_url, { headers: H })).arrayBuffer();"}]}}, "/api/v1/audio/{file}": {"get": {"tags": ["Speech (TTS)"], "operationId": "getAudio", "x-scope": "tts", "summary": "Download generated audio", "description": "Returns a generated file as WAV (default) or MP3. Supports `Range` requests, so it can be used directly as an `<audio>` source (with a session cookie) or streamed.", "parameters": [{"name": "file", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The `file` value of a result, e.g. `20261030-164824-anna.wav`."}, {"name": "format", "in": "query", "schema": {"type": "string", "enum": ["wav", "mp3"], "default": "wav"}, "description": "`mp3` encodes on first request (LAME VBR) and caches the result."}, {"name": "download", "in": "query", "schema": {"type": "string"}, "allowEmptyValue": true, "description": "If present, adds `Content-Disposition: attachment`."}], "responses": {"200": {"description": "The audio file.", "content": {"audio/wav": {}, "audio/mpeg": {}}}, "206": {"description": "Partial content for a `Range` request."}, "404": {"description": "No such file.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s -H \"Authorization: Bearer $VOICEKIT_KEY\" -o out.wav \\\n  https://voice.example.com/api/v1/audio/20261030-164824-anna.wav"}]}}, "/api/v1/history": {"get": {"tags": ["Speech (TTS)"], "operationId": "history", "x-scope": "tts", "summary": "List recent generations", "description": "Newest first. Shared by all keys with the `tts` scope.", "parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 200, "default": 30}}], "responses": {"200": {"description": "Recent items.", "content": {"application/json": {"schema": {"type": "object", "properties": {"items": {"type": "array", "items": {"$ref": "#/components/schemas/AudioItem"}}}}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/voices": {"get": {"tags": ["Voice cloning"], "operationId": "listVoices", "x-scope": "clone or tts", "summary": "List voices", "responses": {"200": {"description": "All voices.", "content": {"application/json": {"schema": {"type": "object", "properties": {"voices": {"type": "array", "items": {"$ref": "#/components/schemas/Voice"}}}}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s -H \"Authorization: Bearer $VOICEKIT_KEY\" https://voice.example.com/api/v1/voices"}]}, "post": {"tags": ["Voice cloning"], "operationId": "createVoice", "x-scope": "clone", "summary": "Clone a voice", "description": "Builds a voice from a short recording and its **exact** transcript. Upload as `multipart/form-data`. Only clone voices you own or have explicit permission to clone. Use 6–15 s of clean speech from one speaker. If the file is longer, select a segment with `start`/`duration` and transcribe only that segment.", "requestBody": {"required": true, "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/CloneForm"}, "encoding": {"audio": {"contentType": "audio/wav, audio/flac, audio/mpeg, audio/mp4"}}}}}, "responses": {"200": {"description": "With `wait=true`: the finished job (`result.voice`, `result.duration_s`, `result.warnings`).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Job"}}}}, "202": {"description": "Job started.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/JobStarted"}}}}, "400": {"description": "Invalid name, language, transcript or audio format.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "A voice with this name exists (send overwrite=true), or the GPU is busy.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "413": {"description": "Audio larger than 32 MiB.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s https://voice.example.com/api/v1/voices \\\n  -H \"Authorization: Bearer $VOICEKIT_KEY\" \\\n  -F name=anna -F language=German -F wait=true \\\n  -F \"transcript=Heute Morgen habe ich frischen Kaffee gekocht und aus dem Fenster geschaut.\" \\\n  -F audio=@anna.wav"}, {"lang": "python", "label": "Python", "source": "import requests\n\nwith open(\"anna.wav\", \"rb\") as f:\n    job = requests.post(\"https://voice.example.com/api/v1/voices\",\n        headers={\"Authorization\": \"Bearer vk_...\"}, timeout=600,\n        data={\"name\": \"anna\", \"language\": \"German\", \"wait\": \"true\",\n              \"transcript\": \"Heute Morgen habe ich frischen Kaffee gekocht und aus dem Fenster geschaut.\"},\n        files={\"audio\": (\"anna.wav\", f, \"audio/wav\")}).json()\nprint(job[\"status\"], job.get(\"result\") or job.get(\"error\"))"}]}}, "/api/v1/voices/{name}": {"parameters": [{"name": "name", "in": "path", "required": true, "schema": {"type": "string"}}], "get": {"tags": ["Voice cloning"], "operationId": "getVoice", "x-scope": "clone or tts", "summary": "Get a voice", "responses": {"200": {"description": "Voice details.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/VoiceDetail"}}}}, "404": {"description": "Unknown voice.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}, "delete": {"tags": ["Voice cloning"], "operationId": "deleteVoice", "x-scope": "clone", "summary": "Delete a voice", "description": "Removes the voice and its reference recording permanently. Generated audio is kept.", "responses": {"200": {"description": "Deleted.", "content": {"application/json": {"schema": {"type": "object", "properties": {"deleted": {"type": "string"}}}}}}, "404": {"description": "Unknown voice.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "The GPU is busy; retry shortly.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s -X DELETE -H \"Authorization: Bearer $VOICEKIT_KEY\" \\\n  https://voice.example.com/api/v1/voices/anna"}]}}, "/api/v1/write": {"post": {"tags": ["Text generation"], "operationId": "write", "x-scope": "text", "summary": "Generate text to be spoken", "description": "Sends a chat to the local LLM, which replies with **only** the text to speak: plain prose with no Markdown and no preamble, in `language`. For revisions, send the whole conversation plus the text as it is now in `current_text`, including your own edits; the model then revises that version. The response streams as newline-delimited JSON (`application/x-ndjson`): any number of `delta` lines, then one `done` line with the cleaned final text (or an `error` line).", "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/WriteRequest"}, "example": {"messages": [{"role": "user", "content": "A friendly voicemail greeting, Mon–Fri 9 to 5."}], "language": "English"}}}}, "responses": {"200": {"description": "NDJSON stream of `WriteEvent` objects.", "content": {"application/x-ndjson": {"schema": {"$ref": "#/components/schemas/WriteEvent"}, "example": "{\"type\":\"delta\",\"text\":\"Hello, \"}\n{\"type\":\"delta\",\"text\":\"you have reached …\"}\n{\"type\":\"done\",\"text\":\"Hello, you have reached …\",\"changes\":[],\"truncated\":false,\"stats\":{\"model\":\"qwen3.5:9b-q4_K_M\",\"eval_count\":58,\"total_s\":1.9}}"}}}, "400": {"description": "Invalid request (no user message, unsupported language).", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -sN https://voice.example.com/api/v1/write \\\n  -H \"Authorization: Bearer $VOICEKIT_KEY\" -H \"Content-Type: application/json\" \\\n  -d '{\"language\": \"English\", \"messages\": [{\"role\": \"user\", \"content\": \"A 20-second intro for my cooking podcast\"}]}' \\\n  | jq -rj 'select(.type==\"delta\") | .text'"}, {"lang": "python", "label": "Python", "source": "import json, requests\n\ndef write(messages, language=\"English\", current_text=\"\"):\n    with requests.post(\"https://voice.example.com/api/v1/write\", stream=True, timeout=600,\n                       headers={\"Authorization\": \"Bearer vk_...\"},\n                       json={\"messages\": messages, \"language\": language, \"current_text\": current_text}) as r:\n        r.raise_for_status()\n        for line in r.iter_lines():\n            ev = json.loads(line)\n            if ev[\"type\"] == \"delta\":\n                print(ev[\"text\"], end=\"\", flush=True)\n            elif ev[\"type\"] == \"done\":\n                return ev[\"text\"]\n            elif ev[\"type\"] == \"error\":\n                raise RuntimeError(ev[\"message\"])\n\nchat = [{\"role\": \"user\", \"content\": \"A 20-second intro for my cooking podcast\"}]\ndraft = write(chat)\nchat += [{\"role\": \"assistant\", \"content\": draft}, {\"role\": \"user\", \"content\": \"Make it more playful\"}]\nfinal = write(chat, current_text=draft)"}]}}, "/api/v1/chat": {"post": {"tags": ["Text generation"], "operationId": "chat", "x-scope": "text", "summary": "Chat with the selected Ollama model", "description": "General chat without the speech-writer prompt. The administrator selects one model in the Models panel; clients cannot override it. Send up to 16 system, user or assistant messages, ending with a user message. The response includes Ollama's measured token counts and timing; generation speed excludes model loading and prompt processing. Set stream=true for newline-delimited delta, done, and error events.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["messages"], "properties": {"messages": {"type": "array", "minItems": 1, "maxItems": 16, "items": {"type": "object", "required": ["role", "content"], "properties": {"role": {"type": "string", "enum": ["system", "user", "assistant"]}, "content": {"type": "string", "maxLength": 32000, "description": "Up to 8,000 characters for system/user and 32,000 for assistant messages."}}}}, "max_tokens": {"type": "integer", "minimum": 1, "maximum": 8192, "default": 2048}, "stream": {"type": "boolean", "default": false}}}, "example": {"messages": [{"role": "user", "content": "Hello!"}], "max_tokens": 256}}}}, "responses": {"200": {"description": "JSON response, or NDJSON events when stream=true.", "content": {"application/json": {"schema": {"type": "object", "properties": {"model": {"type": "string"}, "message": {"type": "object"}, "done_reason": {"type": ["string", "null"]}, "stats": {"type": "object"}}}}, "application/x-ndjson": {"schema": {"type": "string"}, "example": "{\"type\":\"delta\",\"text\":\"Hello\"}\n{\"type\":\"done\",\"model\":\"example\",\"message\":{\"role\":\"assistant\",\"content\":\"Hello\"},\"stats\":{}}\n"}}}, "400": {"description": "Invalid request or Ollama error.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "409": {"description": "Text generator is busy.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s https://voice.example.com/api/v1/chat   -H 'Authorization: Bearer $VOICEKIT_KEY' -H 'Content-Type: application/json'   -d '{\"messages\":[{\"role\":\"user\",\"content\":\"Hello!\"}]}'"}]}}, "/api/v1/llm": {"get": {"tags": ["Text generation"], "operationId": "llmStatus", "x-scope": "text", "summary": "LLM status", "description": "Whether the configured Ollama model is installed and currently loaded.", "responses": {"200": {"description": "Status.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/LlmStatus"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/jobs/{id}": {"get": {"tags": ["Jobs"], "operationId": "getJob", "x-scope": "scope of the job (clone or tts)", "summary": "Get job status", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "Current state.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Job"}, "example": {"id": "3f9c0a1b2d4e", "kind": "speak", "name": "anna", "status": "done", "stage": {"stage": "save"}, "created_at": "2026-10-30T15:48:24+00:00", "log": ["Text: 63 characters -> 1 chunk(s) (target 300, max 500)."], "result": {"elapsed_s": 6.1, "item": {"file": "20261030-164824-anna.wav", "url": "/api/v1/audio/20261030-164824-anna.wav", "mp3_url": "/api/v1/audio/20261030-164824-anna.wav?format=mp3", "voice": "anna", "language": "German", "text": "Guten Tag! Schön, dass Sie da sind.", "duration_s": 3.8, "created_at": "2026-10-30T15:48:30+00:00", "warnings": [], "generation_s": 5.4}}}}}}, "404": {"description": "Unknown or expired job.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s -H \"Authorization: Bearer $VOICEKIT_KEY\" https://voice.example.com/api/v1/jobs/3f9c0a1b2d4e"}]}}, "/api/v1/jobs/{id}/events": {"get": {"tags": ["Jobs"], "operationId": "jobEvents", "x-scope": "scope of the job (clone or tts)", "summary": "Stream job progress (SSE)", "description": "Server-Sent Events. Each `data:` line is a `JobEvent`. The full history is replayed first, `Last-Event-ID` resumes. The stream ends after the `done` or `error` event.", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}}], "responses": {"200": {"description": "`text/event-stream` of `JobEvent`.", "content": {"text/event-stream": {"schema": {"$ref": "#/components/schemas/JobEvent"}}}}, "404": {"description": "Unknown or expired job.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -sN -H \"Authorization: Bearer $VOICEKIT_KEY\" \\\n  https://voice.example.com/api/v1/jobs/3f9c0a1b2d4e/events"}]}}, "/api/v1/keys": {"get": {"tags": ["API keys"], "operationId": "listKeys", "x-scope": "admin", "summary": "List API keys", "description": "Never returns the secrets, only a short visible prefix.", "responses": {"200": {"description": "All keys.", "content": {"application/json": {"schema": {"type": "object", "properties": {"keys": {"type": "array", "items": {"$ref": "#/components/schemas/ApiKey"}}}}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}, "post": {"tags": ["API keys"], "operationId": "createKey", "x-scope": "admin", "summary": "Create an API key", "description": "The response contains the secret **once**; only its hash is stored.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["name", "scopes"], "properties": {"name": {"type": "string", "maxLength": 64, "description": "Unique label, e.g. the app's name."}, "scopes": {"type": "array", "items": {"type": "string", "enum": ["text", "clone", "tts", "admin"]}}}}, "example": {"name": "my-webapp", "scopes": ["tts", "text"]}}}}, "responses": {"201": {"description": "Created.", "content": {"application/json": {"schema": {"type": "object", "properties": {"key": {"type": "string", "description": "The secret. Store it now."}, "info": {"$ref": "#/components/schemas/ApiKey"}}}, "example": {"key": "vk_…", "info": {"id": "k_3f9c0a1b", "kind": "key", "name": "my-webapp", "scopes": ["text", "tts"], "created_at": "2026-10-30T21:00:00+00:00", "prefix": "vk_AbCdEfG", "created_by": "user:alex"}}}}}, "400": {"description": "Invalid name or scopes, or the name is taken.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s https://voice.example.com/api/v1/keys -H \"Authorization: Bearer $ADMIN_KEY\" \\\n  -H \"Content-Type: application/json\" -d '{\"name\": \"my-webapp\", \"scopes\": [\"tts\", \"text\"]}'"}]}}, "/api/v1/keys/{id}": {"delete": {"tags": ["API keys"], "operationId": "revokeKey", "x-scope": "admin", "summary": "Revoke an API key", "description": "Takes effect immediately for API calls and browser sessions using the key.", "parameters": [{"name": "id", "in": "path", "required": true, "schema": {"type": "string"}, "description": "The key's `id` (`k_…`)."}], "responses": {"200": {"description": "Revoked.", "content": {"application/json": {"schema": {"type": "object", "properties": {"revoked": {"$ref": "#/components/schemas/ApiKey"}}}}}}, "404": {"description": "No such key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/events": {"get": {"tags": ["Activity log"], "operationId": "listEvents", "x-scope": "admin", "summary": "Read the activity log", "description": "Newest first. Records sign-ins (successful and failed), API calls (who, IP, method, path, status, duration), key and voice changes, job results and refusals. Texts, passwords and keys are never recorded. Routine traffic (health checks, job polling, reading this log) is left out unless it fails. Use `after` with the newest id you have for live updates, `before` with the oldest for older pages.", "parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}}, {"name": "after", "in": "query", "schema": {"type": "integer"}, "description": "Only events with a larger id."}, {"name": "before", "in": "query", "schema": {"type": "integer"}, "description": "Only events with a smaller id."}, {"name": "group", "in": "query", "schema": {"type": "string", "enum": ["auth", "api", "keys", "voices", "jobs"]}}, {"name": "level", "in": "query", "schema": {"type": "string", "enum": ["problems"]}, "description": "`problems`: only warnings and errors."}, {"name": "q", "in": "query", "schema": {"type": "string"}, "description": "Text search across all fields."}], "responses": {"200": {"description": "Events and 24-hour totals.", "content": {"application/json": {"schema": {"type": "object", "properties": {"events": {"type": "array", "items": {"$ref": "#/components/schemas/Event"}}, "stats": {"type": "object", "description": "`{hours, api, login, login_failed, problems, jobs, clients}`"}}}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/health": {"get": {"tags": ["System"], "operationId": "health", "security": [], "summary": "Health check", "responses": {"200": {"description": "Server is up.", "content": {"application/json": {"schema": {"type": "object", "properties": {"status": {"const": "ok"}, "version": {"type": "string"}, "busy": {"type": "boolean"}}}}}}}}}, "/api/v1/me": {"get": {"tags": ["System"], "operationId": "me", "security": [], "summary": "Who am I", "description": "Reports whether authentication is required and which scopes the presented key or session has. Useful to verify a key.", "responses": {"200": {"description": "Identity.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Me"}}}}}, "x-code-samples": [{"lang": "curl", "label": "curl", "source": "curl -s -H \"Authorization: Bearer $VOICEKIT_KEY\" https://voice.example.com/api/v1/me"}]}}, "/api/v1/login": {"post": {"tags": ["System"], "operationId": "login", "security": [], "summary": "Browser login", "description": "Signs in to the admin panel with an account (`username` + `password`), or with an API key (`key`). Account login returns an MFA challenge when required; only successful MFA verification creates the full session. Otherwise sets an HttpOnly, SameSite=Strict session cookie valid for 12 h. API clients should send their key on every request instead.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"oneOf": [{"type": "object", "title": "account", "required": ["username", "password"], "properties": {"username": {"type": "string"}, "password": {"type": "string"}}}, {"type": "object", "title": "API key", "required": ["key"], "properties": {"key": {"type": "string"}}}]}}}}, "responses": {"200": {"description": "Signed in, or MFA challenge required; `Set-Cookie` header sent.", "content": {"application/json": {"schema": {"type": "object"}}}}, "401": {"description": "Wrong credentials.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Too many failed attempts.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/mfa/setup": {"get": {"tags": ["System"], "operationId": "mfaSetup", "security": [], "summary": "Get authenticator enrollment details", "description": "Requires the short-lived MFA challenge cookie from a password login in setup mode. The QR and secret are sensitive.", "responses": {"200": {"description": "Authenticator secret and locally generated QR image.", "content": {"application/json": {"schema": {"type": "object", "properties": {"secret": {"type": "string"}, "qr": {"type": "string"}}}}}}, "401": {"description": "Challenge expired.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/mfa/verify": {"post": {"tags": ["System"], "operationId": "mfaVerify", "security": [], "summary": "Complete an MFA challenge", "description": "Requires the challenge cookie and canonical HTTPS Origin. Accepts a six-digit TOTP or an unused recovery code. Enrollment returns recovery codes once.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["code"], "properties": {"code": {"type": "string", "maxLength": 32}}}}}}, "responses": {"200": {"description": "Verified; authenticated session cookie sent.", "content": {"application/json": {"schema": {"type": "object"}}}}, "401": {"description": "Invalid code or expired challenge.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "429": {"description": "Too many MFA attempts.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/admin/mfa": {"get": {"tags": ["System"], "operationId": "mfaPolicy", "x-scope": "admin account session", "summary": "Get MFA policy", "responses": {"200": {"description": "MFA policy.", "content": {"application/json": {"schema": {"type": "object", "properties": {"enabled": {"type": "boolean"}}}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}, "post": {"tags": ["System"], "operationId": "setMfaPolicy", "x-scope": "admin account session", "summary": "Set MFA policy", "description": "Changing the policy invalidates all existing account sessions. API keys are unaffected.", "requestBody": {"required": true, "content": {"application/json": {"schema": {"type": "object", "required": ["enabled"], "properties": {"enabled": {"type": "boolean"}}}}}}, "responses": {"200": {"description": "MFA policy updated.", "content": {"application/json": {"schema": {"type": "object"}}}}, "401": {"description": "Missing or invalid API key.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}, "403": {"description": "The key lacks the required scope.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}}}}}, "/api/v1/logout": {"post": {"tags": ["System"], "operationId": "logout", "security": [], "summary": "Browser logout", "responses": {"200": {"description": "Session ended.", "content": {"application/json": {"schema": {"type": "object"}}}}}}}}, "components": {"securitySchemes": {"bearer": {"type": "http", "scheme": "bearer", "description": "`Authorization: Bearer vk_…`"}, "apiKeyHeader": {"type": "apiKey", "in": "header", "name": "X-API-Key"}}, "schemas": {"Error": {"type": "object", "required": ["error"], "properties": {"error": {"type": "string", "description": "What went wrong."}, "hint": {"type": ["string", "null"], "description": "How to fix it, when known."}}}, "SpeakRequest": {"type": "object", "required": ["voice", "text"], "properties": {"voice": {"type": "string", "description": "Voice name (see `GET /voices`)."}, "text": {"type": "string", "maxLength": 5000, "description": "Text to speak, verbatim. Blank lines separate paragraphs (slightly longer pause)."}, "language": {"type": "string", "description": "Language of the text: a language `id` or `code` from the handshake (e.g. `German` or `de`; case-insensitive). Defaults to the voice's own language."}, "wait": {"type": "boolean", "default": false, "description": "Return only when the job has finished (up to 30 minutes)."}}}, "CloneForm": {"type": "object", "required": ["name", "audio"], "properties": {"name": {"type": "string", "pattern": "^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$", "description": "Voice name: lowercase letters, digits, `-`, `_`."}, "audio": {"type": "string", "format": "binary", "description": "WAV, FLAC, MP3 or M4A; the file name's extension decides the decoder."}, "transcript": {"type": "string", "description": "Exact words spoken in the (selected) audio. Required unless `x_vector_only` is true."}, "language": {"type": "string", "default": "German", "description": "Language spoken in the recording: a language `id` or `code`."}, "start": {"type": "number", "description": "Segment start in seconds."}, "duration": {"type": "number", "description": "Segment length in seconds (6–15 recommended)."}, "x_vector_only": {"type": "boolean", "default": false, "description": "Clone from a speaker embedding only, without transcript. Less similar; use only if no transcript exists."}, "overwrite": {"type": "boolean", "default": false, "description": "Replace an existing voice of this name (only after the new one is complete)."}, "wait": {"type": "boolean", "default": false}}}, "WriteRequest": {"type": "object", "required": ["messages"], "properties": {"messages": {"type": "array", "minItems": 1, "maxItems": 16, "items": {"$ref": "#/components/schemas/ChatMessage"}, "description": "The conversation so far; the last message must be from the user. Older messages beyond 16 are dropped."}, "language": {"type": "string", "default": "German", "description": "Language to write in: a language `id` or `code`."}, "current_text": {"type": "string", "description": "The text as it is now, including your own edits. If it differs from the last assistant message, the model revises it."}}}, "ChatMessage": {"type": "object", "required": ["role", "content"], "properties": {"role": {"type": "string", "enum": ["user", "assistant"]}, "content": {"type": "string", "maxLength": 32000, "description": "Up to 8,000 characters for system/user and 32,000 for assistant messages."}}}, "WriteEvent": {"oneOf": [{"type": "object", "title": "delta", "properties": {"type": {"const": "delta"}, "text": {"type": "string"}}}, {"type": "object", "title": "done", "properties": {"type": {"const": "done"}, "text": {"type": "string", "description": "Final, cleaned text."}, "changes": {"type": "array", "items": {"type": "string"}, "description": "Cleanups applied (e.g. removed Markdown)."}, "truncated": {"type": "boolean", "description": "True if the length limit cut the text off."}, "stats": {"type": "object"}}}, {"type": "object", "title": "error", "properties": {"type": {"const": "error"}, "message": {"type": "string"}, "hint": {"type": ["string", "null"]}}}]}, "JobStarted": {"type": "object", "properties": {"job": {"type": "string"}, "status": {"const": "running"}, "status_url": {"type": "string"}, "events_url": {"type": "string"}}, "example": {"job": "3f9c0a1b2d4e", "status": "running", "status_url": "/api/v1/jobs/3f9c0a1b2d4e", "events_url": "/api/v1/jobs/3f9c0a1b2d4e/events"}}, "Job": {"type": "object", "properties": {"id": {"type": "string"}, "kind": {"type": "string", "enum": ["speak", "train"]}, "name": {"type": "string", "description": "Voice name."}, "status": {"type": "string", "enum": ["running", "done", "error"]}, "stage": {"type": ["object", "null"], "description": "Latest stage, e.g. `{\"stage\": \"generate\", \"chunk\": 2, \"chunks\": 3}`. Speak: prepare, load, generate, save. Clone: upload, convert, analyze, load, build, save."}, "created_at": {"type": "string", "format": "date-time"}, "log": {"type": "array", "items": {"type": "string"}, "description": "Last 50 log lines."}, "result": {"type": "object", "description": "When done. Speak: `{elapsed_s, item: AudioItem}`. Clone: `{elapsed_s, voice, duration_s, warnings}`."}, "error": {"type": "object", "description": "When failed: `{message, hint}`."}}}, "JobEvent": {"type": "object", "properties": {"id": {"type": "integer"}, "type": {"type": "string", "enum": ["stage", "log", "done", "error"]}, "t": {"type": "number", "description": "Unix time."}, "stage": {"type": "string"}, "chunk": {"type": "integer"}, "chunks": {"type": "integer"}, "message": {"type": "string"}}}, "AudioItem": {"type": "object", "properties": {"file": {"type": "string"}, "url": {"type": "string", "description": "WAV download path."}, "mp3_url": {"type": "string", "description": "MP3 download path."}, "voice": {"type": "string"}, "language": {"type": "string"}, "text": {"type": "string"}, "duration_s": {"type": "number"}, "created_at": {"type": "string", "format": "date-time"}, "generation_s": {"type": "number"}, "warnings": {"type": "array", "items": {"type": "string"}, "description": "Quality heuristics, e.g. suspiciously long audio for the text length. Listen before relying on it."}}}, "Voice": {"type": "object", "properties": {"name": {"type": "string"}, "ok": {"type": "boolean", "description": "False if the profile is damaged."}, "error": {"type": ["string", "null"]}, "language": {"type": "string"}, "mode": {"type": "string", "enum": ["icl", "x_vector_only"]}, "created_at": {"type": "string"}, "reference_duration_s": {"type": "number"}, "long_reference": {"type": "boolean", "description": "Reference > 30 s; such voices tend to ramble."}}}, "VoiceDetail": {"type": "object", "properties": {"name": {"type": "string"}, "language": {"type": "string"}, "mode": {"type": "string"}, "created_at": {"type": "string"}, "reference_duration_s": {"type": "number"}, "transcript": {"type": ["string", "null"]}, "warnings": {"type": "array", "items": {"type": "string"}}, "model": {"type": "string"}}}, "LlmStatus": {"type": "object", "properties": {"enabled": {"type": "boolean"}, "model": {"type": "string"}, "available": {"type": "boolean"}, "installed": {"type": "boolean"}, "loaded": {"type": "boolean"}, "error": {"type": "string"}, "hint": {"type": "string"}}}, "Event": {"type": "object", "properties": {"id": {"type": "integer"}, "ts": {"type": "string", "format": "date-time"}, "type": {"type": "string", "enum": ["login", "login_failed", "logout", "refused", "api", "key_created", "key_revoked", "voice_created", "voice_deleted", "job_done", "job_failed"]}, "level": {"type": "string", "enum": ["info", "warn", "error"]}, "actor": {"type": "string", "description": "`user:<name>`, `key:<name>`, `local`, or absent."}, "ip": {"type": "string"}, "method": {"type": "string"}, "path": {"type": "string"}, "status": {"type": "integer"}, "ms": {"type": "integer"}, "voice": {"type": "string"}, "detail": {"type": "string"}, "ua": {"type": "string", "description": "User agent (shortened)."}}}, "ApiKey": {"type": "object", "properties": {"id": {"type": "string", "description": "`k_…`, used to revoke."}, "kind": {"const": "key"}, "name": {"type": "string"}, "scopes": {"type": "array", "items": {"type": "string", "enum": ["text", "clone", "tts", "admin"]}}, "prefix": {"type": "string", "description": "First characters of the secret, for recognition."}, "created_at": {"type": "string", "format": "date-time"}, "created_by": {"type": ["string", "null"], "description": "`user:<name>`, a key name, or `local`."}}}, "Language": {"type": "object", "properties": {"id": {"type": "string", "description": "Value to send as `language` (the `code` works too)."}, "code": {"type": "string", "description": "ISO 639-1 code, e.g. `de`."}, "name": {"type": "string", "description": "English name."}, "native_name": {"type": "string", "description": "Name in the language itself, for display."}}}, "TtsVoice": {"type": "object", "properties": {"id": {"type": "string", "description": "Value to send as `voice`."}, "name": {"type": "string", "description": "Display name (currently the same as `id`)."}, "language": {"type": "string", "description": "The voice's own language: preselect it."}, "language_code": {"type": ["string", "null"]}, "mode": {"type": "string", "enum": ["icl", "x_vector_only"], "description": "`icl` = cloned with transcript (most similar)."}, "created_at": {"type": "string", "format": "date-time"}, "recommended": {"type": "boolean", "description": "False if the voice has known quality issues."}, "warnings": {"type": "array", "items": {"type": "string"}, "description": "Show these to users."}}}, "TtsOptions": {"type": "object", "properties": {"voices": {"type": "array", "items": {"$ref": "#/components/schemas/TtsVoice"}, "description": "Recommended voices first."}, "languages": {"type": "array", "items": {"$ref": "#/components/schemas/Language"}}, "default_voice": {"type": ["string", "null"], "description": "Newest recommended voice; null if none."}, "default_language": {"type": "string", "description": "The default voice's language."}, "cross_lingual": {"type": "boolean", "description": "Any voice can speak any listed language."}, "max_text_chars": {"type": "integer"}, "formats": {"type": "array", "items": {"type": "string", "enum": ["wav", "mp3"]}}, "sample_rate": {"type": "integer"}, "busy": {"type": "boolean", "description": "A job is running right now (a new one would get 409)."}, "model_loaded": {"type": "boolean", "description": "False: the next job first loads the model (~10 s)."}}}, "Handshake": {"type": "object", "properties": {"api_version": {"type": "string", "const": "v1"}, "server_version": {"type": "string"}, "key": {"type": ["object", "null"], "description": "`{name, scopes}`; null in local mode without keys."}, "scopes": {"type": "array", "items": {"type": "string", "enum": ["text", "clone", "tts"]}}, "tts": {"oneOf": [{"$ref": "#/components/schemas/TtsOptions"}, {"type": "null"}], "description": "Present with the `tts` scope."}, "text": {"type": ["object", "null"], "description": "With the `text` scope: `{model, languages, max_history_messages, target_max_chars}`."}, "clone": {"type": ["object", "null"], "description": "With the `clone` scope: `{languages, audio_formats, recommended_seconds, max_upload_bytes, name_pattern}`."}}}, "Me": {"type": "object", "properties": {"auth_required": {"type": "boolean"}, "authenticated": {"type": "boolean"}, "mfa_pending": {"type": ["string", "null"], "enum": ["setup", "verify", null]}, "via": {"type": ["string", "null"], "enum": ["key", "session", "local", null]}, "key": {"type": ["object", "null"], "description": "`{id, name, scopes, prefix, created_at}`"}, "scopes": {"type": "array", "items": {"type": "string", "enum": ["text", "clone", "tts", "admin"]}}, "login": {"type": "object", "description": "Which sign-in methods exist: `{password, api_key}`."}}}}}}