OpenAiCompatibleTtsProvider sends response_format in the wrong case #209

Open
opened 2026-08-02 13:37:01 +02:00 by scarlet · 0 comments
Member

Split out of the #208 review (Jibril) rather than folded in, to keep that PR to its own provider.

The defect

OpenAiCompatibleTtsProvider.SpeechRequest serializes through JsonSerializerDefaults.Web, so its ResponseFormat property goes over the wire as responseFormat. The OpenAI audio-speech shape documents the field as response_format (snake_case), and every conformant server declares it that way.

// src/Kagura.Infrastructure/Tts/OpenAiCompatibleTtsProvider.cs
private sealed record SpeechRequest(string Model, string Input, string Voice, string ResponseFormat);

So the field is silently ignored by every endpoint this adapter talks to — OpenRouter, Kokoro-FastAPI, VoxCPM.cpp — and the server falls back to its own default.

Why nothing has broken

The adapter asks for "mp3" at OpenAiCompatibleTtsProvider.cs:95, and mp3 is also the shape's default. Intended value and fallback value coincide, so the ignored field is invisible. GeneratedAudio is tagged TtsAudioFormat.Mp3 and happens to be correct.

This is luck, not correctness. It breaks the moment either side moves:

  • anything wants a format other than MP3 (WAV, Opus, FLAC), or
  • an endpoint's default is not MP3 — at which point the adapter mislabels the bytes, since it hardcodes TtsAudioFormat.Mp3 regardless of what came back.

The identical bug in Qwen3TtsProvider was not invisible, because there the intended format (WAV) differed from the default: the ignored key produced MP3, which the sidecar cannot encode without an ffmpeg binary, and every preset call returned 500. Confirmed live during #208:

response_format=wav  → HTTP 200, content-type audio/wav
responseFormat=wav   → HTTP 500

Fix

Same annotation as #208:

private sealed record SpeechRequest(
    string Model,
    string Input,
    string Voice,
    [property: JsonPropertyName("response_format")] string ResponseFormat);

Test note

OpenAi_compatible_synthesis_posts_the_speech_shape_and_a_keyless_local_endpoint_works asserts model, input and voice but never response_format, so it neither catches nor certifies this. When fixing, pin the key rather than only the value — asserting the value alone is what let the same bug through review in #208. The stub records what the client sends, so an assertion written against the buggy spelling is a false positive.

Worth a sweep of the other adapters for the same class of mistake at the same time: any multi-word JSON property going out through JsonSerializerDefaults.Web is a candidate. FishAudioTtsProvider's reference_id is already pinned by an explicit test assertion, so it is known good.

Split out of the #208 review (Jibril) rather than folded in, to keep that PR to its own provider. ## The defect `OpenAiCompatibleTtsProvider.SpeechRequest` serializes through `JsonSerializerDefaults.Web`, so its `ResponseFormat` property goes over the wire as **`responseFormat`**. The OpenAI audio-speech shape documents the field as **`response_format`** (snake_case), and every conformant server declares it that way. ```csharp // src/Kagura.Infrastructure/Tts/OpenAiCompatibleTtsProvider.cs private sealed record SpeechRequest(string Model, string Input, string Voice, string ResponseFormat); ``` So the field is silently ignored by every endpoint this adapter talks to — OpenRouter, Kokoro-FastAPI, VoxCPM.cpp — and the server falls back to its own default. ## Why nothing has broken The adapter asks for `"mp3"` at `OpenAiCompatibleTtsProvider.cs:95`, and `mp3` is also the shape's default. Intended value and fallback value coincide, so the ignored field is invisible. `GeneratedAudio` is tagged `TtsAudioFormat.Mp3` and happens to be correct. This is luck, not correctness. It breaks the moment either side moves: - anything wants a format other than MP3 (WAV, Opus, FLAC), or - an endpoint's default is not MP3 — at which point the adapter mislabels the bytes, since it hardcodes `TtsAudioFormat.Mp3` regardless of what came back. The identical bug in `Qwen3TtsProvider` was **not** invisible, because there the intended format (WAV) differed from the default: the ignored key produced MP3, which the sidecar cannot encode without an ffmpeg binary, and every preset call returned 500. Confirmed live during #208: ``` response_format=wav → HTTP 200, content-type audio/wav responseFormat=wav → HTTP 500 ``` ## Fix Same annotation as #208: ```csharp private sealed record SpeechRequest( string Model, string Input, string Voice, [property: JsonPropertyName("response_format")] string ResponseFormat); ``` ## Test note `OpenAi_compatible_synthesis_posts_the_speech_shape_and_a_keyless_local_endpoint_works` asserts `model`, `input` and `voice` but never `response_format`, so it neither catches nor certifies this. When fixing, pin the key rather than only the value — asserting the value alone is what let the same bug through review in #208. The stub records what the *client* sends, so an assertion written against the buggy spelling is a false positive. Worth a sweep of the other adapters for the same class of mistake at the same time: any multi-word JSON property going out through `JsonSerializerDefaults.Web` is a candidate. `FishAudioTtsProvider`'s `reference_id` is already pinned by an explicit test assertion, so it is known good.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagura#209
No description provided.