Fix #1833: Improve transcription response format compatibility - #2088
Conversation
Make transcription responses closer to the OpenAI spec: errors stay JSON, json and verbose_json stay JSON, and text, srt, and vtt now return raw text bodies. Update docs and add response_format test coverage.
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks, good work! The transcription behavior looks right.
But there is a blocker regarding the scope of the forward_multipart_request() change. This helper is also used by SD image edits/variations, where successful responses are expected to be JSON. With this PR, any successful non-JSON response from those backends would become {"text": ...} and be returned to clients as HTTP 200 instead of surfacing as an invalid backend response. Could we make the non-JSON-success fallback opt-in and enable it only for transcription requests with response_format set to text, srt, or vtt?
I’d also add a negative test for an unsupported response_format value so the new documented supported-values list is enforced deterministically.
|
@claude review |
|
Claude finished @ramkrishna2910's task in 2m 9s —— View job Code Review: Transcription response formats (#2088)
Nice fix — the user-facing behavior ( 🔴 Blocker: the
|
| Caller | Endpoint | Expected 200 body |
|---|---|---|
whisper_server.cpp |
— | does not use this helper (see below) |
fastflowlm_server.cpp:398 |
/v1/audio/transcriptions |
text or JSON ✅ intended |
sd_server.cpp:540 |
/v1/images/edits |
JSON ❌ side effect |
sd_server.cpp:573 |
/v1/images/edits (variations) |
JSON ❌ side effect |
For SD image edits/variations, a malformed non-JSON 200 previously surfaced as a backend error (the json::parse throw was caught at wrapped_server.cpp:221 → NetworkException). With this change it silently becomes {"text": "<garbage>"} returned as HTTP 200 — exactly the regression @fl0rianr flagged.
Crucially, the whisper.cpp backend doesn't go through this helper at all — WhisperServer does its own post_multipart + parse and already wraps non-JSON itself at whisper_server.cpp:557-563:
try {
return json::parse(res.body);
} catch (const json::parse_error&) {
return json{{"text", res.body}}; // already handled here
}So the only transcription backend that benefits from the forward_multipart_request() change is FastFlowLM, while the cost is changed semantics for SD.
Recommended fix (cleanest, matches existing convention): revert the wrapped_server.cpp change and instead do the try/parse/wrap locally in fastflowlm_server.cpp:398, mirroring exactly what whisper_server.cpp already does. This scopes the fallback to the one backend that needs it and leaves SD's strict-JSON contract intact — no opt-in flag needed.
// fastflowlm_server.cpp, replacing line 398
auto resp = forward_multipart_request("/v1/audio/transcriptions", fields);
// FLM returns raw text for text/srt/vtt; the helper already wrapped it as {"text": ...}
return resp;If you'd rather keep it in the shared helper, the alternative is an opt-in bool allow_raw_text_fallback = false param (default preserves SD behavior, transcription callers pass true) — that also satisfies the blocker, but duplicating the whisper pattern in FLM is more consistent with the codebase.
🟡 Negative test needs server-side validation to be deterministic
@fl0rianr also asked for a negative test for an unsupported response_format. Note that there is currently no validation of response_format in handle_audio_transcriptions — an unsupported value (e.g. "foo") is just forwarded to the backend, whose behavior (error vs. default-to-json) isn't guaranteed. So a negative test today wouldn't be deterministic.
To enforce the newly-documented supported-values list, add a check in server.cpp (around server.cpp:2182, before forwarding) that returns HTTP 400 with an invalid_request_error for any response_format not in {json, verbose_json, text, srt, vtt}. Then the negative test can assert 400.
🟢 Minor / nits
server.cpp:2188— guarding onresponse["text"].is_string()is good defensive code; iftextis somehow missing it correctly falls through to the JSON dump. 👍- The new early-
returninside theif (response.contains("error"))block (server.cpp:2174-2178) is a correct cleanup of the prior fall-through. 👍 - Content-Type
text/plainforsrt/vttmatches OpenAI's behavior. 👍 - Out of scope, but worth a glance:
sd_server.cpp:540and:573both forward to/v1/images/edits— if:573is the variations path, that may be a separate latent bug. Not part of this PR.
Test coverage
test_002b_transcription_response_formats is a solid positive test across all five formats. Once validation lands, please add the negative case @fl0rianr requested. Note this test requires a live whisper/FLM backend, so I couldn't execute it in this environment — the analysis above is from reading the code paths.
Overall: behavior is right, the docs update is accurate, but the shared-helper change should be narrowed to transcription before merge.
• fix/1833-audio-transcription-response-formats
Keep image responses strict so backend mistakes do not look like successful requests. Let audio transcription return plain text only when the user asks for text, srt, or vtt, and return a clear 400 error for unsupported response formats.
|
@fl0rianr @ramkrishna2910 thanks for the reviews. I updated the PR based on the feedback. The main issue was that the first version allowed plain text responses too broadly. That was okay for audio I changed it so:
In terms of files, the shared multipart helper is now strict by default, FastFlowLM and Whisper only allow plain The follow-up triggered by @ramkrishna2910 was useful too: it confirmed that the same helper function is used by Verification:
|
|
CI error is related: |
# Conflicts: # src/cpp/include/lemon/wrapped_server.h # src/cpp/server/wrapped_server.cpp
|
@anditherobot please get CI healthy or close the PR (nothing personal just putting messages like this any PR of this age) |
|
@jeremyfowers Thanks for following up I completely understand. The branch is now updated with the latest main Thank you both |
fl0rianr
left a comment
There was a problem hiding this comment.
Thanks for updating and rebasing this. I re-reviewed the current head. My original two requests are addressed: the shared multipart fallback is now opt-in, and unsupported response_format values are rejected with a 400 and covered by a test.
I still see two issues before I can approve:
1. Raw text is still detected by JSON parse failure
Both WrappedServer::forward_multipart_request() and the Whisper paths first try json::parse(body) and only treat the response as raw text if parsing fails.
That is not safe for response_format=text. A perfectly valid transcript such as true, null, or 123 is also valid JSON. In that case parsing succeeds, the expected {"text": ...} wrapper is never created, and handle_audio_transcriptions() falls back to returning JSON instead of the requested raw text.
The requested format should determine how a successful response is interpreted, e.g.:
if (allow_plain_text_success) {
return json{{"text", response.body}};
}
return json::parse(response.body);The same applies to the Whisper paths. A small regression test with a JSON-looking text body such as true\n would cover this nicely.
2. FLM does not actually provide SRT/VTT responses
I traced the current FastFlowLM implementation as well. Its /v1/audio/transcriptions endpoint only copies model and file from the multipart request; response_format is not passed to the ASR handler. The handler always returns the compact {model, text} JSON response.
Lemonade can turn that into plain text, but it cannot produce real SRT or VTT from it. The current test masks this by skipping the --> / WEBVTT assertions when wrapped_server == "flm", so ordinary transcript text can pass as successful srt or vtt.
I think we should either reject/document those formats as unsupported for FLM, or implement actual format support. The test should not report SRT/VTT support without validating the requested format. The same limitation also explains why FLM's verbose_json response is only the compact shape.
One small bonus of fixing Nr. 1 by branching on the requested format before parsing is that the strict multipart path can keep its previous JSON parse-error behavior instead of changing error semantics for unrelated callers.
Once these two points are addressed this looks good from my side.
|
@fl0rianr commit First: the format now decides how the body is read, before anything is Second: dropped
|
## Lemonade LLM Server Update
This automated PR updates Lemonade from `11.5.1` to `11.7.0`.
### Changelog
## Headline
- A new `POST /v1/models/register` endpoint registers or updates `user.*` model definitions without downloading any files.
- New `GET/POST/DELETE /v1/models/{id}/options` endpoints save, inspect, and reset per-model recipe options without loading the model, with per-load transient overrides.
- New `GET /v1/stats` and Prometheus `/metrics` endpoints report prefix-cache effectiveness and router route-switch counters.
- The built-in catalog adds the Qwen3.8-27B and NVIDIA Nemotron 3.5 Lightning 30B-A3B GGUF models and the Z-Image-Turbo image model.
- Lemonade can now be installed on Windows with `winget` and on macOS with a Homebrew cask.
## Breaking Changes
- `POST /pull` now returns 400 and registers nothing when a model definition names an unservable or contradictory deployment mode; such labels are no longer silently normalized.
- Stored `user_models.json` entries that violate the new deployment-mode label rule are now skipped at startup with an error instead of being repaired.
- Removed the deprecated `Lite Collection` and `Ultra Collection` model registry entries.
- `/v1/audio/transcriptions` now returns raw text bodies for `text`/`srt`/`vtt` instead of a JSON `{text: ...}` wrapper, and returns 400 for unsupported `response_format` values.
- The TheNoise image backend no longer accepts per-request `upscale` and `lora_dir` recipe options; `lora_dir` is now set server-wide in `config.json` and `upscale` is only honored as a passed-through request parameter.
- `lemonade launch opencode` now emits a `limit` object (`limit.context`/`limit.output`) instead of a top-level `contextWindow` field.
- The `no_broadcast` config key is replaced by an inverted `broadcast` boolean, auto-migrated on load.
- An existing but unreadable `extra_models_dir` now returns 400 on `/internal/set` instead of being silently applied.
## Lemonade Server
| Operating System | Downloads |
|------------------|-----------|
| **Windows** | [lemonade.msi](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade.msi) |
| **Ubuntu 24.04+** | [Launchpad PPA](https://launchpad.net/~lemonade-team/+archive/ubuntu/stable) |
| **Debian 13 (x86_64)** | [lemonade-server_11.7.0-debian13_amd64.deb](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-server_11.7.0-debian13_amd64.deb) |
| **Debian 13 (ARM64)** | [lemonade-server_11.7.0-debian13_arm64.deb](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-server_11.7.0-debian13_arm64.deb) |
| **Fedora 43 (x86_64)** | [lemonade-server-11.7.0-fc43.x86_64.rpm](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-server-11.7.0-fc43.x86_64.rpm) |
| **Fedora 43 (ARM64)** | [lemonade-server-11.7.0-fc43.aarch64.rpm](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-server-11.7.0-fc43.aarch64.rpm) |
| **Fedora 44 (x86_64)** | [lemonade-server-11.7.0-fc44.x86_64.rpm](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-server-11.7.0-fc44.x86_64.rpm) |
| **Fedora 44 (ARM64)** | [lemonade-server-11.7.0-fc44.aarch64.rpm](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-server-11.7.0-fc44.aarch64.rpm) |
| **macOS** | [Lemonade-11.7.0-Darwin.pkg](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/Lemonade-11.7.0-Darwin.pkg) |
> **Other platforms?** See our [Installation Options](https://lemonade-server.ai/docs/guide/install/) for [Docker](https://lemonade-server.ai/docs/guide/install/docker/), [Snap](https://lemonade-server.ai/docs/guide/install/ubuntu/#__tabbed_2_3), [Arch](https://lemonade-server.ai/docs/guide/install/arch/), [Debian](https://lemonade-server.ai/docs/guide/install/), and more.
## Embeddable Lemonade
Portable binaries for bundling into your own installer. Run `lemond ./` as a subprocess.
| Platform | Download |
|----------|----------|
| **Ubuntu x64** | [lemonade-embeddable-11.7.0-ubuntu-x64.tar.gz](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-embeddable-11.7.0-ubuntu-x64.tar.gz) |
| **Ubuntu arm64** | [lemonade-embeddable-11.7.0-ubuntu-arm64.tar.gz](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-embeddable-11.7.0-ubuntu-arm64.tar.gz) |
| **Windows x64** | [lemonade-embeddable-11.7.0-windows-x64.zip](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-embeddable-11.7.0-windows-x64.zip) |
| **macOS arm64** | [lemonade-embeddable-11.7.0-macos-arm64.tar.gz](https://github.com/lemonade-sdk/lemonade/releases/download/v11.7.0/lemonade-embeddable-11.7.0-macos-arm64.tar.gz) |
---
## What's Changed
Thanks `SlawomirNowaczyk`, `abn`, `anditherobot`, `bitgamma`, `blackdeathdrow`, `bong-water-water-bong`, `fl0rianr`, `ianbmacdonald`, `iswaryaalex`, `jeremyfowers`, `kenvandine`, `mzyy94`, `osimarr`, `popey`, `ramkrishna2910`, `superm1`, `yelliver`, `zaneni6` for your awesome contributions to this release!
<details>
<summary>Click to expand changelog</summary>
* Bump TheNoise to version v0.1.2 by `bitgamma` in lemonade-sdk/lemonade#3079
* docs(backends): drop the model catalog tables from the backend reference by `jeremyfowers` in lemonade-sdk/lemonade#3103
* fix(alias): report the real reason for alias validation failures by `ramkrishna2910` in lemonade-sdk/lemonade#3111
* feat(server): add model registration endpoint by `fl0rianr` in lemonade-sdk/lemonade#3118
* Debian packaging improvements by `superm1` in lemonade-sdk/lemonade#3039
* docs: add winget and Homebrew install to homepage quickstart by `yelliver` in lemonade-sdk/lemonade#2970
* Trigger snap release candidate build from release branches by `kenvandine` in lemonade-sdk/lemonade#2908
* fix(server): compare artifacts, not commit SHAs, when checking for model updates by `blackdeathdrow` in lemonade-sdk/lemonade#3073
* fix(server): normalize thinking controls for llama.cpp by `fl0rianr` in lemonade-sdk/lemonade#3132
* fix: stop request during prefill now possible by `fl0rianr` in lemonade-sdk/lemonade#3133
* ci: stop building unused targets in the validate workflows by `jeremyfowers` in lemonade-sdk/lemonade#3052
* fix(server): harden extra models directory handling by `fl0rianr` in lemonade-sdk/lemonade#3105
* Fix #1833: Improve transcription response format compatibility by `anditherobot` in lemonade-sdk/lemonade#2088
* ci: stop pinning lite validation legs to the 128gb runner by `jeremyfowers` in lemonade-sdk/lemonade#3139
* feat(models): add an explicit `chat` label by `jeremyfowers` in lemonade-sdk/lemonade#3099
* feat(telemetry): capture prefix-cache effectiveness (cache_n, cached_tokens) and route-switch counts by `ramkrishna2910` in lemonade-sdk/lemonade#2968
* Update FLM version by `zaneni6` in lemonade-sdk/lemonade#3144
* Backend Battle Nightly Regression by `iswaryaalex` in lemonade-sdk/lemonade#3069
* Remove emoji from FAQ title by `jeremyfowers` in lemonade-sdk/lemonade#3154
* Manage per-model recipe options without loading by `jeremyfowers` in lemonade-sdk/lemonade#3110
* Enable % used context on opencode side panel by `osimarr` in lemonade-sdk/lemonade#1725
* feat(server,cli): add discovery and broadcast controls with config decoupling by `abn` in lemonade-sdk/lemonade#3135
* fix(packaging): add AppStream MetaInfo files for desktop entries by `superm1` in lemonade-sdk/lemonade#3123
* fix(flm): correctly report downloaded model size by `mzyy94` in lemonade-sdk/lemonade#3166
* Minor refactor of new gate code by `SlawomirNowaczyk` in lemonade-sdk/lemonade#2971
* update TheNoise v0.2.1 by `bitgamma` in lemonade-sdk/lemonade#3171
* fix(flm): stop re-pulling already-downloaded models on every load by `mzyy94` in lemonade-sdk/lemonade#3167
* feat: flag comment slop in a pre-commit rule, and run pre-commit in CI by `ianbmacdonald` in lemonade-sdk/lemonade#2689
* add lemonade version to benchmark results by `bitgamma` in lemonade-sdk/lemonade#3173
* feat(server): support transient null overrides on load by `fl0rianr` in lemonade-sdk/lemonade#3172
* fix(macos): reduce log directory permissions to prevent privilege esc… by `superm1` in lemonade-sdk/lemonade#2625
* docs: add pull request template by `fl0rianr` in lemonade-sdk/lemonade#2551
* fix(npu): pre-reject NPU loads when auto-tune can only reserve fallback ctx (#1151) by `bong-water-water-bong` in lemonade-sdk/lemonade#3164
* models: add Qwen3.8 and Nemotron 3.5 GGUF by `yelliver` in lemonade-sdk/lemonade#3177
* Fix pre-commit JSON validation issues by `superm1` in lemonade-sdk/lemonade#3179
* docs: fix multi-file checkpoint rendering by `yelliver` in lemonade-sdk/lemonade#3174
* Remove some MTP default flags by `bitgamma` in lemonade-sdk/lemonade#3187
* ci: wire server eviction tests into CI by `fl0rianr` in lemonade-sdk/lemonade#3015
* ci: run watchdog lifecycle tests in backend CI by `fl0rianr` in lemonade-sdk/lemonade#3021
* test: replace Python system info replicas with C++ coverage by `fl0rianr` in lemonade-sdk/lemonade#3020
* fix(server): let load args override saved args by `fl0rianr` in lemonade-sdk/lemonade#3204
* refactor(server): drop load_command from the options endpoint by `jeremyfowers` in lemonade-sdk/lemonade#3199
* fix(test): new watchdog test concurrency issue by `fl0rianr` in lemonade-sdk/lemonade#3201
* fix(pre-commit): make comment-slop runnable on Windows by `blackdeathdrow` in lemonade-sdk/lemonade#3208
* Update to ROCm 7.14 by `superm1` in lemonade-sdk/lemonade#2768
* Fix stale gfx90X-dcgpu TheRock URL mapping for gfx908/gfx90a by `kenvandine` in lemonade-sdk/lemonade#3196
* test: add concurrent chat completion coverage by `fl0rianr` in lemonade-sdk/lemonade#2060
* Fail startup when one resolved address cannot bind by `popey` in lemonade-sdk/lemonade#3197
* fix(ci): resolve test flakes across C++ store, python test harness, and workflows by `abn` in lemonade-sdk/lemonade#2805
</details>
## New Contributors
* `yelliver` made their first contribution in lemonade-sdk/lemonade#2970
* `zaneni6` made their first contribution in lemonade-sdk/lemonade#3144
* `mzyy94` made their first contribution in lemonade-sdk/lemonade#3166
**Full Changelog**: lemonade-sdk/lemonade@v11.6.0...v11.7.0
---
> Windows installers are signed. Free code signing provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org). See our [Code Signing Policy](https://github.com/lemonade-sdk/lemonade#code-signing-policy).
### Changes
- Updated `config.yaml` version
- Updated `build.yaml` LEMONADE_VERSION
- Updated `Dockerfile` LEMONADE_VERSION
- Updated documentation files
- Updated CHANGELOG.md
### Packaging check
The update script verified this release ships both
`lemonade-embeddable-<version>-ubuntu-arm64.tar.gz` and
`...-x64.tar.gz`, which are the archives this app installs.
The image bundles a glibc closure computed with `ldd` at build time.
If this release adds a new shared-library dependency, the smoke test
will surface it as `error while loading shared libraries`.
### Release Notes
Full release notes: https://github.com/lemonade-sdk/lemonade/releases/tag/v11.7.0
---
This PR was automatically generated by the Update Lemonade workflow
Auto-merged by GitHub Actions
Summary
This PR fixes #1833 for the OpenAI-compatible transcription API.
Apps can request transcription results in different formats, including JSON, plain text, SRT, and VTT. Lemonade already accepted those requests, but plain text and subtitle formats were still wrapped in JSON instead of being returned directly. That could confuse apps expecting normal text or subtitle output.
This change makes
text,srt, andvttreturn raw text bodies.json,verbose_json, and errors still return JSON.Before, a
vttresponse looked like this:{ "text": "WEBVTT\n\n00:00:00.000 --> 00:00:04.000\nListen, read, repeat.\n" }After this change:
The same applies to
srt.Testing
I reviewed the full diff and kept the change scoped to transcription response formats.
I manually checked the endpoint with a WAV file using all supported response formats:
json,verbose_json,text,srt, andvtt. This confirmed the old behavior:text,srt, andvttwere returned as JSON wrappers.I ran
git diff --check. The commit also passed the repo pre-commit hooks, including Black.Documentation
Updated the OpenAI API docs to list the supported transcription response formats:
json,verbose_json,text,srt, andvtt.Fixes #1833