Skip to content

fix(server): align the b10621 audio transcription route - #1484

Merged
inureyes merged 1 commit into
mainfrom
fix/issue-1446-audio-transcription-compat
Aug 28, 2026
Merged

inureyes merged 1 commit into
mainfrom
fix/issue-1446-audio-transcription-compat

Conversation

@inureyes

@inureyes inureyes commented Aug 28, 2026 •

Copy link
Copy Markdown
Member

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 #1431.

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

  • 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.py
  • cargo clippy --lib --tests --features metal,accelerate -- -D warnings, cargo fmt --all -- --check, and RUSTFLAGS="-D warnings" cargo check --no-default-features --all-targets
  • 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.
  • 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.
  • qwen3-0.6b-4bit: 501 not_supported_error with upstream's sentence, and it wins over a bad response_format, matching upstream's ordering.
  • 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.
  • 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 #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 #1446

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
@inureyes inureyes added status:review Under review type:bug Bug fixes, error corrections, or issue resolutions priority:high High priority area:inference Generation, sampling, decoding (incl. speculative, DRY) modeltype:audio Audio or speech model labels Aug 28, 2026
@inureyes
inureyes merged commit 0309f5c into main Aug 28, 2026
12 checks passed
@inureyes
inureyes deleted the fix/issue-1446-audio-transcription-compat branch August 28, 2026 02:26
@inureyes inureyes added status:done Completed and removed status:review Under review labels Aug 28, 2026
@inureyes inureyes self-assigned this Aug 31, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:inference Generation, sampling, decoding (incl. speculative, DRY) modeltype:audio Audio or speech model priority:high High priority status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant