Repository navigation
fix(server): align the b10621 audio transcription route - #1484
Merged
Merged
Conversation
b10621 has no speech-to-text model. `POST /v1/audio/transcriptions` is a translation layer over `/v1/chat/completions`: it refuses unless the loaded chat model takes audio, converts the multipart form into a chat request whose single user message carries an ASR prompt plus the uploaded clip, and renders the completion as a transcript event. mlxcel's route was served by a dedicated Whisper worker, and that worker is populated only when `-m` names a Whisper checkpoint, which leaves the chat worker unloaded. Measured on this tree, the two were mutually exclusive server shapes: `mlxcel-server -m models/mlx/whisper-tiny` transcribed and answered `/v1/chat/completions` with a 503, while `mlxcel-server -m models/mlx/gemma3n-e2b-4bit` answered chat and answered `/v1/audio/transcriptions` with `501 audio model kind not loaded: stt`. The second command line is the only shape b10621 can express, and it was the one that did not work, so route-name overlap was not compatibility however closely the field set matched. That settles the design question #1446 asked, from behavior rather than preference. The shared route now dispatches through the loaded chat model exactly as upstream does, via `routes::chat::non_stream_chat_completion` (widened to `pub(crate)`, so the typed `ChatCompletionResponse` is reused with no JSON round-trip). The Whisper worker stays the implementation when no chat model can take audio, which is a shape b10621 cannot express at all and therefore adds no divergence in any configuration upstream can reach. New `src/server/routes/transcription_compat.rs` carries the contract, each item read off the pinned upstream source rather than its schema prose: the capability refusal and its precedence over every field check, the missing-file refusal, `response_format` restricted to `json`, the ASR preset prompt `Transcribe audio to text` with the `" (language: xx)"` suffix, `temperature` / `max_tokens` retyped from strings with a 400 on a value that does not parse, `stream` compared against the literal `"true"`, the multipart duplicate-key rule (a repeated text field becomes an array and falls back to its default, a repeated `file` part keeps the last), unknown fields carried and ignored, and the `transcript.text.done` / `transcript.text.delta` / `data: [DONE]` response shapes. mlxcel's `text` and `verbose_json` formats move to `/v1/audio/translations`, which b10621 does not mount, so the shared route's default is upstream's. Limits land before a decoder sees the clip: at most 32 multipart parts, 25 MiB per part, and a WAV geometry read from the header (at most 192 kHz, 8 channels, 600 seconds), with a `data` chunk that declares more audio than the file carries clamped to what is present. Validated with real checkpoints on M1 Ultra. `gemma3n-e2b-4bit` transcribes the clip through the chat model on both `/v1/audio/transcriptions` and `/audio/transcriptions`, and a Korean clip with `language=ko` comes back in Korean. `whisper-tiny` serves the same route in the same response shape. `qwen3-0.6b-4bit` answers 501 `not_supported_error` with upstream's sentence, and does so ahead of a bad `response_format`, matching upstream's ordering. Missing file, `response_format=text`, `response_format=verbose_json`, a non-numeric `temperature`, a non-WAV container and a truncated file are all 400 with the expected wording; an unknown field and a duplicated `response_format` are both served. `stream=true` returns `text/event-stream` with the delta, done and `[DONE]` frames. Both route entries stay `deferred` against #1446, which stays open: only RIFF/WAVE is decoded where b10621's mtmd front-end takes mp3 and flac, and a streamed response arrives as one delta rather than incrementally. Their manifest notes and divergence lists now record exactly that. Refs #1446
5 of 7 tasks
11 tasks done
be-free-snowboy
pushed a commit
to freestylejs/snowbrain-mlxcel
that referenced
this pull request
Oct 27, 2026
## Summary Makes `POST /v1/audio/transcriptions` behave the way llama-server b10621 behaves: it transcribes through the loaded chat model, with upstream's field set, errors, ordering and response shape. Sub-issue of lablup#1431. **Issue lablup#1446 stays open after this merges.** Two divergences remain and are recorded on the manifest entries, so both route entries stay `deferred` and the issue keeps owning them. The body deliberately avoids putting a closing verb next to the issue number, because GitHub parses one even inside a sentence that denies it. See "What is still deferred" below. ## The design decision lablup#1446 asked for, settled by measurement b10621 has no speech-to-text model. Its transcription route is a translation layer over `/v1/chat/completions`: refuse unless the loaded chat model takes audio, convert the multipart form into a chat request whose single user message carries an ASR prompt plus the clip, render the completion as a transcript event. mlxcel's route was served by a dedicated Whisper worker, populated only when `-m` names a Whisper checkpoint, and that same `-m` leaves the chat worker unloaded. So the two were mutually exclusive server shapes. Measured on this tree, before the change: | Command line | `/v1/chat/completions` | `/v1/audio/transcriptions` | |---|---|---| | `mlxcel-server -m models/mlx/whisper-tiny` | 503, no chat model | transcribes | | `mlxcel-server -m models/mlx/gemma3n-e2b-4bit` | answers | **501 `audio model kind not loaded: stt`** | The second row is the only shape b10621 can express, and it was the one that did not work. No amount of multipart-field alignment fixes that, because the two are not the same process. **The dedicated STT worker is therefore not a correctly aliased implementation of b10621's route**, and the compatibility route has to use the loaded multimodal chat model, which is what this PR does. The same tree shows the translation is real rather than theoretical: posting the clip to `gemma3n-e2b-4bit` as an `input_audio` content part returns "The quick brown fox jumps over the lazy dog.", which is the clip. The Whisper worker is kept as the fallback for the Whisper-server shape, which b10621 cannot express at all, so it adds no divergence in any configuration upstream can reach. ## What changed - **`src/server/routes/transcription_compat.rs` (new)** holds the contract, each item read off the pinned upstream source rather than its schema prose: `convert_transcriptions_to_chatcmpl` for the field semantics, `server-http.cpp`'s multipart-to-JSON translation for the duplicate-key rule, `json_value` for what a wrong type does, `ex_wrapper` for which exception becomes which status, and `to_json_oaicompat_asr` for the response. - **`routes::chat::non_stream_chat_completion` widened to `pub(crate)`** so the synthesized request reuses the typed `ChatCompletionResponse` with no JSON round-trip. - **`audio_transcriptions` dispatches** to the chat model when it can take audio, and to the STT worker otherwise; `audio_translations` is untouched. - **mlxcel's `text` and `verbose_json` formats move to `/v1/audio/translations`**, which b10621 does not mount, so the shared route's default is upstream's and the extensions survive on a route that is documented as mlxcel's own. ### Aligned with upstream - Capability refusal `The current model does not support audio input.` as a 501 `not_supported_error`, ahead of every field check, so a request that is wrong in two ways gets the 501. - `No input file found for transcription` (400) for a form with no `file` part. - `response_format` defaults to `json`; anything else is `Only 'json' response_format is supported for transcription` (400). - The ASR preset prompt `Transcribe audio to text`, overridden by `prompt`, with a non-empty `language` appended as `" (language: xx)"` rather than passed as a decoder parameter. - `temperature` and `max_tokens` retyped from strings with a 400 on a value that does not parse, because upstream's `std::stof` / `std::stoul` throw `std::invalid_argument` and its wrapper maps that to 400. - `stream` compared against the literal `"true"`, because the form carries strings. - A repeated text field collapses into an array and falls back to that field's default, so a duplicated `prompt` is ignored and a duplicated `response_format` resolves to `json`; a repeated `file` part keeps the last. - Unknown fields carried and ignored. - Responses are `{"type":"transcript.text.done","text":...,"usage":{...}}` and, streamed, `{"type":"transcript.text.delta","delta":...}` frames terminated by `data: [DONE]`. ### Migration **A client parsing `{"text": ...}` off `/v1/audio/transcriptions` must read `text` out of the transcript event instead, or move to `/v1/audio/translations`, which is unchanged.** b10621 does not emit OpenAI's classic transcription object here, and neither does mlxcel now. ### Limits All applied before a decoder sees the clip: at most 32 multipart parts, 25 MiB per part (matching the route's body limit), and a WAV geometry read from the header rather than a decode: at most 192 kHz, 8 channels, 600 seconds. A `data` chunk declaring more audio than the file carries is clamped to what is present, so an amplifying header costs a header parse. ## Test plan - [x] `cargo test --profile test-fast --features metal,accelerate --lib routes::transcription_compat` (18 passed), `--lib server::routes` (258), `--lib server::app` (29), `--lib server::llama_compat` (3), `--lib server::media` (66), `--lib cli::multimodal` (20), `--lib vision::image_token_overrides` (10) - [x] `--test llama_compat_manifest` (4 passed), `--test cli_help_consistency` (25 passed), `python3 scripts/ci/check_llama_compat_manifest.py` - [x] `cargo clippy --lib --tests --features metal,accelerate -- -D warnings`, `cargo fmt --all -- --check`, and `RUSTFLAGS="-D warnings" cargo check --no-default-features --all-targets` - [x] `gemma3n-e2b-4bit`: the clip transcribes through the chat model on `/v1/audio/transcriptions` and on the `/audio/transcriptions` alias, and a Korean clip with `language=ko` comes back in Korean, so language routing reaches the model. - [x] `whisper-tiny`: the same route serves from the STT worker in the same response shape, and `/v1/audio/translations` still answers `verbose_json` and `text`. - [x] `qwen3-0.6b-4bit`: 501 `not_supported_error` with upstream's sentence, and it wins over a bad `response_format`, matching upstream's ordering. - [x] Differential cases against a live server: missing file, `response_format=text`, `response_format=verbose_json`, a non-numeric `temperature`, a non-WAV container and a truncated file are 400 with the expected wording; an unknown field and a duplicated `response_format` are served. - [x] `stream=true` returns `text/event-stream` carrying the delta frame, the done frame and `data: [DONE]`. ## What is still deferred Both entries stay `deferred` against lablup#1446 for two divergences a b10621 client can observe, and the issue stays open to carry them: - **Container support.** Only RIFF/WAVE is decoded. b10621's mtmd front-end takes mp3, flac and the rest, so a non-WAV clip is a 400 here and a transcript there. Fixing it means an audio decoder in `src/audio/`, not a route change. - **Stream granularity.** A streamed response arrives as one delta carrying the whole transcript. The frame shapes and the terminator are upstream's; incremental delivery would mean an ASR variant of `stream_chat_completion`. A third residue is recorded but cannot be reached from a b10621 command line: on the Whisper-server shape the `usage` counts are zeros and `prompt` steers nothing, because the STT worker reports neither. Refs lablup#1446
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Makes
POST /v1/audio/transcriptionsbehave the way llama-server b10621 behaves: it transcribes through the loaded chat model, with upstream's field set, errors, ordering and response shape. Sub-issue of #1431.Issue #1446 stays open after this merges. Two divergences remain and are recorded on the manifest entries, so both route entries stay
deferredand the issue keeps owning them. The body deliberately avoids putting a closing verb next to the issue number, because GitHub parses one even inside a sentence that denies it. See "What is still deferred" below.The design decision #1446 asked for, settled by measurement
b10621 has no speech-to-text model. Its transcription route is a translation layer over
/v1/chat/completions: refuse unless the loaded chat model takes audio, convert the multipart form into a chat request whose single user message carries an ASR prompt plus the clip, render the completion as a transcript event.mlxcel's route was served by a dedicated Whisper worker, populated only when
-mnames a Whisper checkpoint, and that same-mleaves the chat worker unloaded. So the two were mutually exclusive server shapes. Measured on this tree, before the change:/v1/chat/completions/v1/audio/transcriptionsmlxcel-server -m models/mlx/whisper-tinymlxcel-server -m models/mlx/gemma3n-e2b-4bitaudio model kind not loaded: sttThe second row is the only shape b10621 can express, and it was the one that did not work. No amount of multipart-field alignment fixes that, because the two are not the same process. The dedicated STT worker is therefore not a correctly aliased implementation of b10621's route, and the compatibility route has to use the loaded multimodal chat model, which is what this PR does.
The same tree shows the translation is real rather than theoretical: posting the clip to
gemma3n-e2b-4bitas aninput_audiocontent part returns "The quick brown fox jumps over the lazy dog.", which is the clip. The Whisper worker is kept as the fallback for the Whisper-server shape, which b10621 cannot express at all, so it adds no divergence in any configuration upstream can reach.What changed
src/server/routes/transcription_compat.rs(new) holds the contract, each item read off the pinned upstream source rather than its schema prose:convert_transcriptions_to_chatcmplfor the field semantics,server-http.cpp's multipart-to-JSON translation for the duplicate-key rule,json_valuefor what a wrong type does,ex_wrapperfor which exception becomes which status, andto_json_oaicompat_asrfor the response.routes::chat::non_stream_chat_completionwidened topub(crate)so the synthesized request reuses the typedChatCompletionResponsewith no JSON round-trip.audio_transcriptionsdispatches to the chat model when it can take audio, and to the STT worker otherwise;audio_translationsis untouched.textandverbose_jsonformats move to/v1/audio/translations, which b10621 does not mount, so the shared route's default is upstream's and the extensions survive on a route that is documented as mlxcel's own.Aligned with upstream
The current model does not support audio input.as a 501not_supported_error, ahead of every field check, so a request that is wrong in two ways gets the 501.No input file found for transcription(400) for a form with nofilepart.response_formatdefaults tojson; anything else isOnly 'json' response_format is supported for transcription(400).Transcribe audio to text, overridden byprompt, with a non-emptylanguageappended as" (language: xx)"rather than passed as a decoder parameter.temperatureandmax_tokensretyped from strings with a 400 on a value that does not parse, because upstream'sstd::stof/std::stoulthrowstd::invalid_argumentand its wrapper maps that to 400.streamcompared against the literal"true", because the form carries strings.promptis ignored and a duplicatedresponse_formatresolves tojson; a repeatedfilepart keeps the last.{"type":"transcript.text.done","text":...,"usage":{...}}and, streamed,{"type":"transcript.text.delta","delta":...}frames terminated bydata: [DONE].Migration
A client parsing
{"text": ...}off/v1/audio/transcriptionsmust readtextout of the transcript event instead, or move to/v1/audio/translations, which is unchanged. b10621 does not emit OpenAI's classic transcription object here, and neither does mlxcel now.Limits
All applied before a decoder sees the clip: at most 32 multipart parts, 25 MiB per part (matching the route's body limit), and a WAV geometry read from the header rather than a decode: at most 192 kHz, 8 channels, 600 seconds. A
datachunk declaring more audio than the file carries is clamped to what is present, so an amplifying header costs a header parse.Test plan
cargo test --profile test-fast --features metal,accelerate --lib routes::transcription_compat(18 passed),--lib server::routes(258),--lib server::app(29),--lib server::llama_compat(3),--lib server::media(66),--lib cli::multimodal(20),--lib vision::image_token_overrides(10)--test llama_compat_manifest(4 passed),--test cli_help_consistency(25 passed),python3 scripts/ci/check_llama_compat_manifest.pycargo clippy --lib --tests --features metal,accelerate -- -D warnings,cargo fmt --all -- --check, andRUSTFLAGS="-D warnings" cargo check --no-default-features --all-targetsgemma3n-e2b-4bit: the clip transcribes through the chat model on/v1/audio/transcriptionsand on the/audio/transcriptionsalias, and a Korean clip withlanguage=kocomes back in Korean, so language routing reaches the model.whisper-tiny: the same route serves from the STT worker in the same response shape, and/v1/audio/translationsstill answersverbose_jsonandtext.qwen3-0.6b-4bit: 501not_supported_errorwith upstream's sentence, and it wins over a badresponse_format, matching upstream's ordering.response_format=text,response_format=verbose_json, a non-numerictemperature, a non-WAV container and a truncated file are 400 with the expected wording; an unknown field and a duplicatedresponse_formatare served.stream=truereturnstext/event-streamcarrying the delta frame, the done frame anddata: [DONE].What is still deferred
Both entries stay
deferredagainst #1446 for two divergences a b10621 client can observe, and the issue stays open to carry them:src/audio/, not a route change.stream_chat_completion.A third residue is recorded but cannot be reached from a b10621 command line: on the Whisper-server shape the
usagecounts are zeros andpromptsteers nothing, because the STT worker reports neither.Refs #1446