fix(tts): forward the OpenAI speed field to the backend (#11097) (#11120)

* fix(tts): forward the OpenAI speed field to the backend (#11097)

/v1/audio/speech accepted the documented OpenAI `speed` field and then
dropped it: schema.TTSRequest had no Speed member, so the value never
reached proto.TTSRequest and the request returned 200 with an unchanged
playback rate.

Accept speed and normalise it into the existing per-request params map,
which core/backend forwards verbatim to the backend. An explicit
params["speed"] still wins, and a value outside the documented 0.25-4.0
range is now rejected with 400 instead of being silently ignored.

Signed-off-by: Anai-Guo <antai12232931@outlook.com>

* fix(tts): distinguish explicit speed=0 from an omitted field

Make TTSRequest.Speed a *float32 so an explicit `"speed": 0` (invalid,
below the documented 0.25 minimum) is rejected with 400 instead of being
treated as unset and silently defaulted. An omitted field stays nil and
leaves the backend default untouched.

Add a request-boundary regression that distinguishes an omitted speed from
an explicit zero, addressing review feedback.

Signed-off-by: Anai-Guo <antai12232931@outlook.com>

* docs: drop the speed field from the TTS docs

Per review: no backend consumes params.speed today, so documenting it
would be misleading. The API-level plumbing and validation stay.

Signed-off-by: Anai-Guo <antai12232931@outlook.com>

---------

Signed-off-by: Anai-Guo <antai12232931@outlook.com>
This commit is contained in:
Tai An
2026-07-27 10:03:32 -07:00
committed by GitHub
parent 3698361510
commit a7fa678d83
4 changed files with 142 additions and 0 deletions

View File

@@ -40,6 +40,10 @@ func TTSEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig
return echo.ErrBadRequest
}
if err := applyTTSSpeed(input); err != nil {
return err
}
xlog.Debug("LocalAI TTS Request received", "model", input.Model)
if cfg.Backend == "" && input.Backend != "" {

View File

@@ -0,0 +1,46 @@
package localai
import (
"fmt"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
)
// ttsSpeedMin and ttsSpeedMax mirror the range the OpenAI Speech API documents
// for the `speed` field.
const (
ttsSpeedMin = 0.25
ttsSpeedMax = 4.0
)
// applyTTSSpeed normalises the OpenAI `speed` field onto the backend-specific
// params map, which is what actually reaches the gRPC TTSRequest. Without this
// the field is parsed off the request and then dropped, so a call with
// speed=0.8 returns HTTP 200 with an unchanged playback rate instead of either
// honouring or rejecting it (#11097).
//
// An explicit `"speed": 0` is invalid (below the documented minimum) and is
// rejected with 400 rather than treated as unset, which is why Speed is a
// pointer. An explicit params["speed"] wins so the LocalAI-native extension keeps
// precedence over the OpenAI-compatible field. Backends whose model exposes no
// rate control ignore the param, exactly like they ignore `instructions`.
func applyTTSSpeed(input *schema.TTSRequest) error {
if input.Speed == nil {
return nil
}
speed := *input.Speed
if speed < ttsSpeedMin || speed > ttsSpeedMax {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("speed must be between %g and %g", ttsSpeedMin, ttsSpeedMax))
}
if input.Params == nil {
input.Params = make(map[string]string)
}
if _, ok := input.Params["speed"]; !ok {
input.Params["speed"] = strconv.FormatFloat(float64(speed), 'g', -1, 32)
}
return nil
}

View File

@@ -0,0 +1,86 @@
package localai
import (
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// f32 returns a pointer to v, matching how the OpenAI `speed` field is decoded
// (a pointer so an explicit zero is distinguishable from an omitted field).
func f32(v float32) *float32 { return &v }
// Regression for #11097: /v1/audio/speech accepted the documented OpenAI
// `speed` field, dropped it before the request reached the backend, and
// returned 200 with an unchanged playback rate. The field must now be
// normalised onto the params map that is forwarded to the gRPC TTSRequest,
// and an out-of-range value must be rejected instead of silently ignored.
var _ = Describe("applyTTSSpeed", func() {
It("forwards speed onto the backend params map", func() {
input := &schema.TTSRequest{Speed: f32(0.8)}
Expect(applyTTSSpeed(input)).To(Succeed())
Expect(input.Params).To(HaveKeyWithValue("speed", "0.8"))
})
It("leaves params untouched when speed is unset", func() {
input := &schema.TTSRequest{}
Expect(applyTTSSpeed(input)).To(Succeed())
Expect(input.Params).To(BeNil())
})
It("treats an omitted speed as unset but rejects an explicit zero", func() {
omitted := &schema.TTSRequest{}
Expect(applyTTSSpeed(omitted)).To(Succeed())
Expect(omitted.Params).To(BeNil())
explicitZero := &schema.TTSRequest{Speed: f32(0)}
err := applyTTSSpeed(explicitZero)
Expect(err).To(HaveOccurred())
httpErr, ok := err.(*echo.HTTPError)
Expect(ok).To(BeTrue())
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
Expect(explicitZero.Params).To(BeNil())
})
It("keeps an explicit params entry over the OpenAI field", func() {
input := &schema.TTSRequest{Speed: f32(0.8), Params: map[string]string{"speed": "1.5"}}
Expect(applyTTSSpeed(input)).To(Succeed())
Expect(input.Params).To(HaveKeyWithValue("speed", "1.5"))
})
It("preserves unrelated params", func() {
input := &schema.TTSRequest{Speed: f32(2), Params: map[string]string{"ref_text": "hello"}}
Expect(applyTTSSpeed(input)).To(Succeed())
Expect(input.Params).To(HaveKeyWithValue("ref_text", "hello"))
Expect(input.Params).To(HaveKeyWithValue("speed", "2"))
})
DescribeTable("rejects values outside the OpenAI range",
func(speed float32) {
input := &schema.TTSRequest{Speed: f32(speed)}
err := applyTTSSpeed(input)
Expect(err).To(HaveOccurred())
httpErr, ok := err.(*echo.HTTPError)
Expect(ok).To(BeTrue())
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
Expect(input.Params).To(BeNil())
},
Entry("below the minimum", float32(0.1)),
Entry("above the maximum", float32(4.5)),
Entry("negative", float32(-1)),
Entry("explicit zero (distinct from an omitted field)", float32(0)),
)
DescribeTable("accepts the documented bounds",
func(speed float32, want string) {
input := &schema.TTSRequest{Speed: f32(speed)}
Expect(applyTTSSpeed(input)).To(Succeed())
Expect(input.Params).To(HaveKeyWithValue("speed", want))
},
Entry("minimum", float32(0.25), "0.25"),
Entry("maximum", float32(4), "4"),
)
})

View File

@@ -80,6 +80,12 @@ type TTSRequest struct {
Format string `json:"response_format,omitempty" yaml:"response_format,omitempty"` // (optional) output format
Stream bool `json:"stream,omitempty" yaml:"stream,omitempty"` // (optional) enable streaming TTS
SampleRate int `json:"sample_rate,omitempty" yaml:"sample_rate,omitempty"` // (optional) desired output sample rate
// Speed is the OpenAI `speed` field (0.25-4.0). It is a pointer so an
// explicit `"speed": 0` (invalid, rejected with 400) is distinguishable
// from an omitted field (left at the backend default). It is normalised
// into Params["speed"] so it reaches the backend over the same channel as
// the other per-request generation parameters.
Speed *float32 `json:"speed,omitempty" yaml:"speed,omitempty"`
// Instructions is a free-form, per-request style/voice description. It maps to
// the OpenAI `instructions` field and is forwarded to the backend so expressive
// TTS models (e.g. Qwen3-TTS CustomVoice/VoiceDesign) can vary tone or designed