From 7701dbe841810725d2de3b00b54a930235e8a93d Mon Sep 17 00:00:00 2001 From: Warren B Date: Wed, 2 Sep 2026 22:42:43 +0100 Subject: [PATCH] server: forward `language` only where the model's contract accepts it A model whose spec does not declare `language` returned HTTP 500 for every transcription the WebUI sent, because build_openai_transcription_request wrote request.options["language"] whenever the body carried the key. Clients send that field on every request, empty or not, and spec-backed models validate their request options strictly, so the model rejected its own client: POST /v1/audio/transcriptions {"model":"parakeet-tdt","audio":"...wav","language":"","text":"","options":{}} -> {"error":{"message":"unknown Parakeet TDT request option: language"}} Parakeet-TDT could not transcribe at all from the WebUI. AudioSR, ControlFoley, MiDashengLM-Gen, PersonaPlex and HeartMuLa fail the same way through /v1/tasks/run, which folds a top-level `language` into the option map. The option is now forwarded only when the caller actually chose a language and the model's contract accepts it. Acceptance is resolved once at registration next to the existing accepts_reference_text flag, and for the same reason: resolving it per request re-reads the embedded spec on the request thread. A `language` the caller placed inside `options` is left alone and still produces the strict rejection, which is the correct answer to an explicit choice. The language continues to reach every model through text_input, so nothing loses the feature; qwen3_forced_aligner requires it from there, which is why the fix belongs on the server rather than in the client's form. Validation, on a server built from this branch with Parakeet-TDT loaded (Metal, Apple M4 Max): language:"" at the top level -> 200, transcript returned language:"en" at the top level -> 200, transcript returned options:{"language":"en"} -> 500, "unknown Parakeet TDT request option: language" (unchanged, deliberate) ctest: 40/40. --- app/server/runtime.cpp | 59 +++++++++++++++++++++++++++++++++++------- app/server/runtime.h | 5 ++++ 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/app/server/runtime.cpp b/app/server/runtime.cpp index 2f2cfbd2d..972fc5e48 100644 --- a/app/server/runtime.cpp +++ b/app/server/runtime.cpp @@ -918,6 +918,7 @@ const engine::runtime::AudioBuffer & select_audio_output(const engine::runtime:: engine::runtime::TaskRequest build_openai_transcription_request( const Value & body, const std::filesystem::path & base_dir, + bool accepts_language_option, const std::string * uploaded_audio_bytes = nullptr) { const auto * audio = body.find("audio"); if (audio == nullptr) { @@ -940,7 +941,14 @@ engine::runtime::TaskRequest build_openai_transcription_request( std::string language; if (const auto * value = body.find("language")) { language = value->as_string(); - request.options["language"] = language; + // Only forward a language the caller actually chose, and only to a model + // whose contract accepts it. Clients send the field unconditionally, so + // without both guards a model that validates request options strictly + // rejects the whole request over an option the user never set. The + // language still reaches the model through text_input either way. + if (!language.empty() && accepts_language_option) { + request.options["language"] = language; + } } std::string context; if (const auto * value = body.find("text")) { @@ -1248,6 +1256,11 @@ void ServerState::refresh_model_option_flags(LoadedModel & model) { "reference_text", effective_override, model.config.path); + model.accepts_language = model_accepts_request_option( + model.config.family, + "language", + effective_override, + model.config.path); } HttpResponse ServerState::handle_model_load(const std::string & body_text) { @@ -2496,7 +2509,7 @@ HttpResponse ServerState::handle_transcription_json(const std::string & body_tex auto & model = require_model(body); const auto request = apply_default_request_options( model, - build_openai_transcription_request(body, request_base_)); + build_openai_transcription_request(body, request_base_, model.accepts_language)); const auto busy_timeout_ms = parse_busy_timeout_override(body); if (bool_field(body, "stream", false)) { return run_transcription_stream(model, request, busy_timeout_ms); @@ -2575,7 +2588,8 @@ HttpResponse ServerState::handle_transcription_multipart(const std::string & bod auto & model = require_model(body); const auto request = apply_default_request_options( model, - build_openai_transcription_request(body, request_base_, &file_part->data)); + build_openai_transcription_request( + body, request_base_, model.accepts_language, &file_part->data)); if (stream) { return run_transcription_stream(model, request, busy_timeout_ms); } @@ -2911,15 +2925,40 @@ HttpResponse ServerState::handle_transcription_live(const HttpRequest & request) }); } +// /v1/tasks/run folds a top-level `language` into the option map, so the same +// unset-field problem reaches models through this route as well. Remove only the +// option this route synthesised, never one the caller wrote inside `options`: +// an explicit option is a deliberate choice, and the strict rejection is the +// right answer to it. +engine::runtime::TaskRequest drop_unsupported_language_option( + engine::runtime::TaskRequest request, + const Value & request_json, + bool accepts_language_option) { + if (accepts_language_option) { + return request; + } + const auto * top_level = request_json.find("language"); + if (top_level == nullptr || !top_level->is_string()) { + return request; + } + const auto it = request.options.find("language"); + if (it != request.options.end() && it->second == top_level->as_string()) { + request.options.erase(it); + } + return request; +} + HttpResponse ServerState::handle_generic_run(const std::string & body_text) { const auto body = engine::io::json::parse(body_text); auto & model = require_model(body); const auto * request_json = body.find("request"); + const auto & effective_json = request_json != nullptr ? *request_json : body; const auto request = apply_default_request_options( model, - minitts::cli::build_request_from_json( - request_json != nullptr ? *request_json : body, - request_base_)); + drop_unsupported_language_option( + minitts::cli::build_request_from_json(effective_json, request_base_), + effective_json, + model.accepts_language)); const auto busy_timeout_ms = parse_busy_timeout_override(body); const auto timed_result = model_run_mode(model) == engine::runtime::RunMode::Streaming ? run_streaming_model(model, request, {}, busy_timeout_ms) @@ -2931,11 +2970,13 @@ HttpResponse ServerState::handle_generic_stream(const std::string & body_text) { const auto body = engine::io::json::parse(body_text); auto & model = require_model(body); const auto * request_json = body.find("request"); + const auto & effective_json = request_json != nullptr ? *request_json : body; const auto request = apply_default_request_options( model, - minitts::cli::build_request_from_json( - request_json != nullptr ? *request_json : body, - request_base_)); + drop_unsupported_language_option( + minitts::cli::build_request_from_json(effective_json, request_base_), + effective_json, + model.accepts_language)); std::vector events; const auto timed_result = run_streaming_model( model, diff --git a/app/server/runtime.h b/app/server/runtime.h index 0b7241a4d..d2e2889e9 100644 --- a/app/server/runtime.h +++ b/app/server/runtime.h @@ -71,6 +71,11 @@ class ServerState final : public IHttpHandler { // the request thread, which costs ~0.9 s per request for large GGUFs. // `true` mirrors model_accepts_request_option's no-contract behavior. bool accepts_reference_text = true; + // Same treatment for `language`. Clients send the field on every + // transcription whether or not the user chose one, so a model whose + // contract omits it would reject the whole request over an option + // nobody set. Resolved once at registration for the same cost reason. + bool accepts_language = true; // Serializes runs on this model and bounds how long a caller waits for its // turn; see BusyGuard. BusyGuard busy;