{"openapi":"3.1.0","info":{"title":"SotaSpeech API","summary":"Vietnamese-first speech-to-text: async file jobs, live mic, OpenAI-compatible.","description":"\nSpeech-to-text API for Vietnamese, English, Japanese and Korean.\n\n**File upload** is asynchronous: enqueue a job, then poll it. Large files are\nuploaded straight to object storage via a presigned PUT so the bytes never transit\nthis API.\n\n**Live mic** is a WebSocket (`/ws`) served by the in-process engine.\n\n**OpenAI compatibility**: `POST /v1/audio/transcriptions` accepts the same multipart\nrequest as `client.audio.transcriptions.create(...)`.\n\nRecognition for file uploads runs in a separate tier (`demo/stt_service` -> vLLM),\nselected through the model registry — see `GET /models`.\n","version":"1.0.0"},"paths":{"/health":{"get":{"tags":["ops"],"summary":"Liveness and dependency state","description":"Always 200 while the process is serving. Read the fields to tell WHICH dependency is unhealthy: `job_queue` for the RabbitMQ connection, `asr_loaded` for the in-process engine, `busy` for the live-mic session gate.","operationId":"health_health_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResponse"}}}}}}},"/models":{"get":{"tags":["ops"],"summary":"Models available to this deployment","description":"Drives the frontend's model switcher. `ready` says whether picking a model will actually work right now: local kinds need the in-process engine loaded, http kinds need an endpoint_url configured.","operationId":"list_models_models_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ModelsResponse"}}}}}}},"/transcribe":{"post":{"tags":["transcription"],"summary":"Enqueue transcription of an audio file at a URL","description":"Returns immediately with a `task_id`; the download and the decode both happen in the worker. The URL's response HEADERS are checked on this request (~one round trip), so a bad scheme, a 404, an expired share link or an oversized file is still a 400/413 here rather than a job that fails later. Poll `GET /transcribe/{task_id}`.","operationId":"transcribe_from_url_transcribe_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TranscribeUrlRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskAccepted"}}}},"400":{"description":"Unknown model, unsupported language, or an unusable file_url."},"401":{"description":"Missing or invalid API key."},"413":{"description":"File exceeds 2 GB."},"503":{"description":"Model not configured, or the job broker is reconnecting."},"429":{"description":"Rate limit exceeded (see the Retry-After header)."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/uploads/presign":{"post":{"tags":["transcription"],"summary":"Mint a presigned PUT URL for a direct browser upload","description":"The browser PUTs the audio straight to object storage, so the bytes never transit this API or its reverse proxy — which is what stops a large upload tripping a gateway's request-body limit. Call `POST /transcribe/object` with the returned `object_key` once the PUT has completed.","operationId":"presign_upload_uploads_presign_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PresignResponse"}}}},"401":{"description":"Missing or invalid API key."},"429":{"description":"Rate limit exceeded (see the Retry-After header)."},"503":{"description":"Storage unavailable."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/transcribe/upload":{"post":{"tags":["transcription"],"summary":"Upload an audio file THROUGH this API and enqueue it","description":"Multipart alternative to the presigned-PUT flow: the browser POSTs the file to this API, which streams it into object storage server-side and enqueues the job — so no public/browser-reachable MinIO endpoint is required. The bytes DO transit this API (unlike POST /uploads/presign), so prefer the presigned flow when MinIO can be exposed to the browser. Poll `GET /transcribe/{task_id}`.","operationId":"transcribe_from_upload_transcribe_upload_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_transcribe_from_upload_transcribe_upload_post"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskAccepted"}}}},"400":{"description":"Unknown model, unsupported language, or an unusable file_url."},"401":{"description":"Missing or invalid API key."},"413":{"description":"File exceeds 2 GB."},"503":{"description":"Model not configured, or the job broker is reconnecting."},"429":{"description":"Rate limit exceeded (see the Retry-After header)."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/transcribe/object":{"post":{"tags":["transcription"],"summary":"Enqueue transcription of an already-uploaded object","description":"For audio uploaded via the presigned PUT from `POST /uploads/presign`. Mirrors `POST /transcribe` without an audio body on the request.","operationId":"transcribe_from_object_transcribe_object_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TranscribeObjectRequest"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskAccepted"}}}},"400":{"description":"Unknown model, unsupported language, or an unusable file_url."},"401":{"description":"Missing or invalid API key."},"413":{"description":"File exceeds 2 GB."},"503":{"description":"Model not configured, or the job broker is reconnecting."},"429":{"description":"Rate limit exceeded (see the Retry-After header)."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/transcribe/{task_id}":{"get":{"tags":["transcription"],"summary":"Poll a transcription job","description":"Reads Redis first — the authoritative copy while a job is in flight — then falls back to MongoDB, so a task_id whose Redis entry has expired (`JOB_TTL_SEC`) still resolves. That fallback is what makes a `/upload/{task_id}` link permanently shareable instead of rotting after 24h.\n\nThe job must belong to the calling API key. A task_id created by a different key answers 404 — the same response as a task_id that does not exist, so the endpoint cannot be used to probe which ids are real.","operationId":"get_transcription_transcribe__task_id__get","parameters":[{"name":"task_id","in":"path","required":true,"schema":{"type":"string","title":"Task Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskStatusResponse"}}}},"401":{"description":"Missing or invalid API key."},"404":{"description":"Unknown or expired task_id."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/media/{object_key}":{"get":{"tags":["transcription"],"summary":"Stream an uploaded audio object through this API","description":"Proxies the bytes from object storage, honouring HTTP Range so the player can seek. Unauthenticated by design (the object_key is an unguessable UUID path), matching the presigned-GET it replaces.","operationId":"stream_media_media__object_key__get","parameters":[{"name":"object_key","in":"path","required":true,"schema":{"type":"string","title":"Object Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/history":{"get":{"tags":["history"],"summary":"List past transcription jobs, newest first","description":"Backed by MongoDB rather than Redis, so unlike `GET /transcribe/{task_id}` this never expires — it is the source for a 'past transcriptions' page. Each row's `audio_url` is re-minted on read, so playback works no matter how long ago the job ran.\n\nScoped to the calling API key: the page lists only the jobs that key created. Jobs recorded before per-key ownership existed have no owner and are not returned to anyone.","operationId":"list_history_history_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":100,"minimum":1,"description":"Page size. Clamped to 1..100.","default":20,"title":"Limit"},"description":"Page size. Clamped to 1..100."},{"name":"skip","in":"query","required":false,"schema":{"type":"integer","minimum":0,"description":"Offset for pagination.","default":0,"title":"Skip"},"description":"Offset for pagination."},{"name":"status","in":"query","required":false,"schema":{"anyOf":[{"$ref":"#/components/schemas/JobStatus"},{"type":"null"}],"description":"Filter to one job status.","title":"Status"},"description":"Filter to one job status."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}},{"name":"x-api-key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HistoryPage"}}}},"401":{"description":"Missing or invalid API key."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/v1/audio/transcriptions":{"post":{"tags":["openai-compat"],"summary":"OpenAI-compatible transcription (synchronous)","description":"Drop-in for `client.audio.transcriptions.create(...)`. Supported `response_format`: json, verbose_json, text, srt, vtt. `model` and `temperature` are accepted for wire compatibility and ignored.","operationId":"create_transcription_v1_audio_transcriptions_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/Body_create_transcription_v1_audio_transcriptions_post"}}}},"responses":{"200":{"description":"Transcript in the requested response_format.","content":{"application/json":{"schema":{}}}},"401":{"description":"Incorrect API key (OpenAI error envelope)."},"413":{"description":"File exceeds 2 GB."},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"Body_create_transcription_v1_audio_transcriptions_post":{"properties":{"file":{"anyOf":[{"type":"string","contentMediaType":"application/octet-stream"},{"type":"null"}],"title":"File"},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"prompt":{"type":"string","title":"Prompt","default":""},"response_format":{"type":"string","title":"Response Format","default":"json"},"temperature":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Temperature"}},"type":"object","title":"Body_create_transcription_v1_audio_transcriptions_post"},"Body_transcribe_from_upload_transcribe_upload_post":{"properties":{"file":{"type":"string","contentMediaType":"application/octet-stream","title":"File","description":"The audio/video file to transcribe."},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"},"context":{"type":"string","title":"Context","default":""}},"type":"object","required":["file"],"title":"Body_transcribe_from_upload_transcribe_upload_post"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HealthResponse":{"properties":{"status":{"type":"string","title":"Status","description":"'ok' — the process is serving."},"asr_model":{"type":"string","title":"Asr Model","description":"Base model id the in-process engine uses."},"asr_loaded":{"type":"boolean","title":"Asr Loaded","description":"Whether the in-process engine is loaded. False when STT_LOCAL_ENGINE=0, which also disables /ws."},"gpu_free_gb":{"type":"number","title":"Gpu Free Gb","description":"Free VRAM, for capacity checks."},"busy":{"type":"boolean","title":"Busy","description":"Whether a live-mic session currently holds the GPU session gate."},"mt_loaded":{"type":"boolean","title":"Mt Loaded","description":"Whether translation is available.","default":false},"mt_model":{"type":"string","title":"Mt Model","default":""},"job_queue":{"type":"string","enum":["ready","reconnecting","unknown"],"title":"Job Queue","description":"Whether a publish would reach the broker.","default":"unknown"},"job_queue_error":{"type":"string","title":"Job Queue Error","description":"Last connect failure, so 'broker down' ([Errno 111] Connection refused) can be told from 'misconfigured URL' without reading container logs.","default":""}},"type":"object","required":["status","asr_model","asr_loaded","gpu_free_gb","busy"],"title":"HealthResponse","description":"Liveness plus enough state to diagnose the common outages."},"HistoryEntry":{"properties":{"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"created_at":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Created At","description":"Unix timestamp (seconds) when the job was created."},"updated_at":{"anyOf":[{"type":"number"},{"type":"null"}],"title":"Updated At","description":"Unix timestamp (seconds) of the last status change."},"params":{"anyOf":[{"$ref":"#/components/schemas/HistoryEntryParams"},{"type":"null"}]},"result":{"anyOf":[{"$ref":"#/components/schemas/TranscriptionResult"},{"type":"null"}]},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error"}},"type":"object","required":["task_id","status"],"title":"HistoryEntry","description":"One past job."},"HistoryEntryParams":{"properties":{"model_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model Id"},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language"},"context":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Context"},"object_key":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Object Key","description":"Set for uploads that went through MinIO."},"file_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"File Url","description":"Set for jobs enqueued from a URL."}},"additionalProperties":true,"type":"object","title":"HistoryEntryParams","description":"The request that created a job, as recorded.\n\n`extra=\"allow\"` on purpose: this mirrors whatever the enqueue route stored, and a\nnew optional request field should show up in history without needing a change\nhere as well."},"HistoryPage":{"properties":{"jobs":{"items":{"$ref":"#/components/schemas/HistoryEntry"},"type":"array","title":"Jobs"},"total":{"type":"integer","title":"Total","description":"Total matching jobs, ignoring pagination."},"limit":{"type":"integer","title":"Limit","description":"Page size actually applied (clamped to 1..100)."},"skip":{"type":"integer","title":"Skip","description":"Offset actually applied."}},"type":"object","required":["jobs","total","limit","skip"],"title":"HistoryPage","description":"A page of history, newest first."},"JobStatus":{"type":"string","enum":["queued","processing","done","failed"],"title":"JobStatus","description":"The job state machine, as stored in Redis and Mongo.\n\nAn enum rather than a bare string so `/docs` lists the four possible values and\na typo in a comparison fails at import rather than silently never matching."},"ModelInfo":{"properties":{"id":{"type":"string","title":"Id"},"label":{"type":"string","title":"Label"},"kind":{"type":"string","enum":["local","local_base","http"],"title":"Kind","description":"local/local_base run in this process; http is a remote tier."},"ready":{"type":"boolean","title":"Ready","description":"Whether this model can actually serve right now."},"supports_streaming":{"type":"boolean","title":"Supports Streaming","description":"Whether /ws (live mic) can use it.","default":false},"supports_timestamps":{"type":"boolean","title":"Supports Timestamps","default":false},"supports_context":{"type":"boolean","title":"Supports Context","default":false}},"type":"object","required":["id","label","kind","ready"],"title":"ModelInfo","description":"One entry from the models.yaml registry, as surfaced to the model switcher."},"ModelsResponse":{"properties":{"models":{"items":{"$ref":"#/components/schemas/ModelInfo"},"type":"array","title":"Models"},"default_model":{"type":"string","title":"Default Model","description":"Used when a request omits `model`."}},"type":"object","required":["models","default_model"],"title":"ModelsResponse"},"PresignRequest":{"properties":{"filename":{"type":"string","title":"Filename","description":"Original filename; used only to name the object."},"content_type":{"type":"string","title":"Content Type","description":"Must match the Content-Type the browser will send on the PUT, or the signature will not verify.","default":"application/octet-stream"}},"additionalProperties":false,"type":"object","required":["filename"],"title":"PresignRequest","description":"Body of `POST /uploads/presign`."},"PresignResponse":{"properties":{"object_key":{"type":"string","title":"Object Key","description":"Pass this to POST /transcribe/object."},"upload_url":{"type":"string","title":"Upload Url","description":"PUT the raw bytes here. Expires."},"expires_in":{"type":"integer","title":"Expires In","description":"Seconds until `upload_url` stops working."}},"type":"object","required":["object_key","upload_url","expires_in"],"title":"PresignResponse","description":"A presigned PUT the browser uploads to directly."},"QueueStats":{"properties":{"queued":{"type":"integer","title":"Queued","description":"Jobs waiting for a free worker slot, across the whole deployment."},"processing":{"type":"integer","title":"Processing","description":"Jobs decoding right now. Caps out at the worker's MAX_CONCURRENT_TRANSCRIBE_JOBS, so it doubles as a 'the pipeline is full' signal."},"position":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Position","description":"1-based place of THIS job in the queue; null once it is no longer waiting. `1` means it is next to start."},"concurrency":{"type":"integer","title":"Concurrency","description":"MAX_CONCURRENT_TRANSCRIBE_JOBS — how many jobs this deployment decodes at once. Included so the UI can say '4 of 4 busy' without hardcoding a number that only .env knows."}},"additionalProperties":false,"type":"object","required":["queued","processing","concurrency"],"title":"QueueStats","description":"How busy the pipeline is, so a waiting client can show progress.\n\nAttached to the poll response instead of living on its own endpoint: the client\nis already polling this route every ~1.2s, and a second endpoint would double\nthat request rate to display one line of text.\n\nEstimates, not promises. Delivery order is RabbitMQ's, and a job ahead in the\nline may finish early or fail — so these drive a \"3rd in line\" hint, never an ETA."},"Segment":{"properties":{"start":{"type":"number","title":"Start","description":"Seconds from the start of the clip."},"end":{"type":"number","title":"End","description":"Seconds from the start of the clip."},"start_fmt":{"type":"string","title":"Start Fmt","description":"MM:SS.mmm, or HH:MM:SS.mmm past the hour."},"end_fmt":{"type":"string","title":"End Fmt","description":"MM:SS.mmm, or HH:MM:SS.mmm past the hour."},"text":{"type":"string","title":"Text"},"speaker":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Speaker","description":"Diarization label (e.g. 'SPEAKER_00'), or null when diarization is disabled or found no overlap."}},"type":"object","required":["start","end","start_fmt","end_fmt","text"],"title":"Segment","description":"One timestamped span of transcript."},"SpeakerTurn":{"properties":{"start":{"type":"number","title":"Start"},"end":{"type":"number","title":"End"},"speaker":{"type":"string","title":"Speaker"}},"type":"object","required":["start","end","speaker"],"title":"SpeakerTurn","description":"Raw diarization output, before it is matched onto segments."},"TaskAccepted":{"properties":{"task_id":{"type":"string","title":"Task Id","description":"Poll GET /transcribe/{task_id} with this."},"status":{"type":"string","const":"queued","title":"Status","description":"Always 'queued' — the decode happens in the worker.","default":"queued"}},"type":"object","required":["task_id"],"title":"TaskAccepted","description":"Returned by both enqueue endpoints. The job has NOT run yet."},"TaskStatusResponse":{"properties":{"task_id":{"type":"string","title":"Task Id"},"status":{"$ref":"#/components/schemas/JobStatus"},"result":{"anyOf":[{"$ref":"#/components/schemas/TranscriptionResult"},{"type":"null"}],"description":"Populated once `status` is 'done'."},"error":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error","description":"Populated once `status` is 'failed'."},"queue":{"anyOf":[{"$ref":"#/components/schemas/QueueStats"},{"type":"null"}],"description":"Queue depth while the job is still queued/processing. Null once it is done or failed — a finished job must not pay for two extra counts on every read of a shared permalink."}},"type":"object","required":["task_id","status"],"title":"TaskStatusResponse","description":"Returned by `GET /transcribe/{task_id}`.\n\nExactly one of `result` / `error` is populated, according to `status`. Both are\nnullable rather than a union so a polling client can read one shape throughout a\njob's life instead of switching on status first."},"TranscribeObjectRequest":{"properties":{"object_key":{"type":"string","title":"Object Key","description":"Key returned by POST /uploads/presign, after the PUT completed."},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language","description":"A short code or name from the Qwen3-ASR language set (e.g. 'vi', 'en', 'zh-CN', 'de', ... — see domain.languages.LANGUAGE_CODES). Unsupported value -> 400; omit/'auto' -> auto-detect (base model)."},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model"},"context":{"type":"string","title":"Context","default":""}},"additionalProperties":false,"type":"object","required":["object_key"],"title":"TranscribeObjectRequest","description":"Body of `POST /transcribe/object` — audio already uploaded to MinIO."},"TranscribeUrlRequest":{"properties":{"file_url":{"type":"string","title":"File Url","description":"http(s) URL the audio is fetched from. Prechecked by its response headers on this request; the body transfer happens in the worker, so the task_id returns immediately.","examples":["https://example.com/meeting.mp3"]},"language":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Language","description":"A short code or Title-case name from the Qwen3-ASR language set (e.g. 'vi', 'en', 'ja', 'ko', 'zh-CN', 'de', 'fr', ... — see domain.languages.LANGUAGE_CODES for the full list). An unsupported value is a 400. Omit or 'auto' for auto-detect (base model only — the fine-tune needs a language). Note: the adapter is Vietnamese-focused, so non-Vietnamese quality is best-effort base-model behaviour.","examples":["vi"]},"model":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Model","description":"ACCEPTED AND IGNORED. This deployment always serves `default_model` from models.yaml, because every registry entry maps onto the same SotaSpeech weights. Kept in the schema so existing callers that still send it are not rejected (the body forbids unknown fields). Same behaviour as POST /v1/audio/transcriptions.","examples":["qwen3-asr-vllm"]},"context":{"type":"string","title":"Context","description":"Optional context or hotword hint, injected as the model's system turn.","default":""}},"additionalProperties":false,"type":"object","required":["file_url"],"title":"TranscribeUrlRequest","description":"Body of `POST /transcribe`."},"TranscriptionResult":{"properties":{"text":{"type":"string","title":"Text","description":"Cleaned transcript: no markers, hallucination runs collapsed, Vietnamese truecased."},"raw_text":{"type":"string","title":"Raw Text","description":"The model's verbatim output, markers intact and no post-processing. For debugging a quality regression."},"language":{"type":"string","title":"Language","description":"Title-case name of the language decoded."},"model":{"type":"string","title":"Model","description":"Registry id of the model that produced this."},"duration_sec":{"type":"number","title":"Duration Sec","description":"Length of the audio."},"asr_ms":{"type":"number","title":"Asr Ms","description":"Wall-clock time spent in recognition."},"diarize_ms":{"type":"number","title":"Diarize Ms","description":"Wall-clock time spent in speaker diarization. Runs CONCURRENTLY with recognition, so it is not additive with asr_ms. 0 when diarization is disabled.","default":0.0},"total_ms":{"type":"number","title":"Total Ms","description":"Total server-side processing time: object download + decode + max(asr, diarize) + post-processing. Excludes upload and queue wait. This is the number that matches the wall-clock wait, which asr_ms alone under-reports.","default":0.0},"rtf":{"type":"number","title":"Rtf","description":"Real-time factor: asr_ms/1000 over duration_sec."},"segments":{"items":{"$ref":"#/components/schemas/Segment"},"type":"array","title":"Segments"},"segment_count":{"type":"integer","title":"Segment Count","default":0},"has_timestamps":{"type":"boolean","title":"Has Timestamps","description":"False for models that return plain text only (e.g. the base checkpoint), in which case `segments` is empty.","default":false},"speaker_turns":{"anyOf":[{"items":{"$ref":"#/components/schemas/SpeakerTurn"},"type":"array"},{"type":"null"}],"title":"Speaker Turns","description":"Raw diarization turns. null when diarization is disabled."},"speakers_pending":{"type":"boolean","title":"Speakers Pending","description":"Whether speaker labels are still being computed. Completed job responses always set this false because the worker keeps the task in 'processing' until diarization finishes. Also false when diarization is disabled and labels will never be produced.","default":false},"translation":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Translation","description":"VI<->EN translation of `text`. null unless MT is configured."},"audio_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Audio Url","description":"Presigned GET for the original upload, re-minted on every read so a permalink stays playable after the first URL expires."}},"type":"object","required":["text","raw_text","language","model","duration_sec","asr_ms","rtf"],"title":"TranscriptionResult","description":"The finished transcript. Present on a job once `status` is 'done'."},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}},"tags":[{"name":"ops","description":"Health and capability discovery. Unauthenticated."},{"name":"transcription","description":"Enqueue a job, poll it, presign an upload."},{"name":"history","description":"Permanent record of past jobs."},{"name":"openai-compat","description":"Drop-in for the OpenAI audio SDK."},{"name":"live","description":"Live-mic WebSocket."}]}