Skip to content

Commit a7fa678

Browse files
authored
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>
1 parent 3698361 commit a7fa678

4 files changed

Lines changed: 142 additions & 0 deletions

File tree

core/http/endpoints/localai/tts.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ func TTSEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig
4040
return echo.ErrBadRequest
4141
}
4242

43+
if err := applyTTSSpeed(input); err != nil {
44+
return err
45+
}
46+
4347
xlog.Debug("LocalAI TTS Request received", "model", input.Model)
4448

4549
if cfg.Backend == "" && input.Backend != "" {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package localai
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"strconv"
7+
8+
"github.com/labstack/echo/v4"
9+
"github.com/mudler/LocalAI/core/schema"
10+
)
11+
12+
// ttsSpeedMin and ttsSpeedMax mirror the range the OpenAI Speech API documents
13+
// for the `speed` field.
14+
const (
15+
ttsSpeedMin = 0.25
16+
ttsSpeedMax = 4.0
17+
)
18+
19+
// applyTTSSpeed normalises the OpenAI `speed` field onto the backend-specific
20+
// params map, which is what actually reaches the gRPC TTSRequest. Without this
21+
// the field is parsed off the request and then dropped, so a call with
22+
// speed=0.8 returns HTTP 200 with an unchanged playback rate instead of either
23+
// honouring or rejecting it (#11097).
24+
//
25+
// An explicit `"speed": 0` is invalid (below the documented minimum) and is
26+
// rejected with 400 rather than treated as unset, which is why Speed is a
27+
// pointer. An explicit params["speed"] wins so the LocalAI-native extension keeps
28+
// precedence over the OpenAI-compatible field. Backends whose model exposes no
29+
// rate control ignore the param, exactly like they ignore `instructions`.
30+
func applyTTSSpeed(input *schema.TTSRequest) error {
31+
if input.Speed == nil {
32+
return nil
33+
}
34+
speed := *input.Speed
35+
if speed < ttsSpeedMin || speed > ttsSpeedMax {
36+
return echo.NewHTTPError(http.StatusBadRequest,
37+
fmt.Sprintf("speed must be between %g and %g", ttsSpeedMin, ttsSpeedMax))
38+
}
39+
if input.Params == nil {
40+
input.Params = make(map[string]string)
41+
}
42+
if _, ok := input.Params["speed"]; !ok {
43+
input.Params["speed"] = strconv.FormatFloat(float64(speed), 'g', -1, 32)
44+
}
45+
return nil
46+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package localai
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/labstack/echo/v4"
7+
"github.com/mudler/LocalAI/core/schema"
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
)
11+
12+
// f32 returns a pointer to v, matching how the OpenAI `speed` field is decoded
13+
// (a pointer so an explicit zero is distinguishable from an omitted field).
14+
func f32(v float32) *float32 { return &v }
15+
16+
// Regression for #11097: /v1/audio/speech accepted the documented OpenAI
17+
// `speed` field, dropped it before the request reached the backend, and
18+
// returned 200 with an unchanged playback rate. The field must now be
19+
// normalised onto the params map that is forwarded to the gRPC TTSRequest,
20+
// and an out-of-range value must be rejected instead of silently ignored.
21+
var _ = Describe("applyTTSSpeed", func() {
22+
It("forwards speed onto the backend params map", func() {
23+
input := &schema.TTSRequest{Speed: f32(0.8)}
24+
Expect(applyTTSSpeed(input)).To(Succeed())
25+
Expect(input.Params).To(HaveKeyWithValue("speed", "0.8"))
26+
})
27+
28+
It("leaves params untouched when speed is unset", func() {
29+
input := &schema.TTSRequest{}
30+
Expect(applyTTSSpeed(input)).To(Succeed())
31+
Expect(input.Params).To(BeNil())
32+
})
33+
34+
It("treats an omitted speed as unset but rejects an explicit zero", func() {
35+
omitted := &schema.TTSRequest{}
36+
Expect(applyTTSSpeed(omitted)).To(Succeed())
37+
Expect(omitted.Params).To(BeNil())
38+
39+
explicitZero := &schema.TTSRequest{Speed: f32(0)}
40+
err := applyTTSSpeed(explicitZero)
41+
Expect(err).To(HaveOccurred())
42+
httpErr, ok := err.(*echo.HTTPError)
43+
Expect(ok).To(BeTrue())
44+
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
45+
Expect(explicitZero.Params).To(BeNil())
46+
})
47+
48+
It("keeps an explicit params entry over the OpenAI field", func() {
49+
input := &schema.TTSRequest{Speed: f32(0.8), Params: map[string]string{"speed": "1.5"}}
50+
Expect(applyTTSSpeed(input)).To(Succeed())
51+
Expect(input.Params).To(HaveKeyWithValue("speed", "1.5"))
52+
})
53+
54+
It("preserves unrelated params", func() {
55+
input := &schema.TTSRequest{Speed: f32(2), Params: map[string]string{"ref_text": "hello"}}
56+
Expect(applyTTSSpeed(input)).To(Succeed())
57+
Expect(input.Params).To(HaveKeyWithValue("ref_text", "hello"))
58+
Expect(input.Params).To(HaveKeyWithValue("speed", "2"))
59+
})
60+
61+
DescribeTable("rejects values outside the OpenAI range",
62+
func(speed float32) {
63+
input := &schema.TTSRequest{Speed: f32(speed)}
64+
err := applyTTSSpeed(input)
65+
Expect(err).To(HaveOccurred())
66+
httpErr, ok := err.(*echo.HTTPError)
67+
Expect(ok).To(BeTrue())
68+
Expect(httpErr.Code).To(Equal(http.StatusBadRequest))
69+
Expect(input.Params).To(BeNil())
70+
},
71+
Entry("below the minimum", float32(0.1)),
72+
Entry("above the maximum", float32(4.5)),
73+
Entry("negative", float32(-1)),
74+
Entry("explicit zero (distinct from an omitted field)", float32(0)),
75+
)
76+
77+
DescribeTable("accepts the documented bounds",
78+
func(speed float32, want string) {
79+
input := &schema.TTSRequest{Speed: f32(speed)}
80+
Expect(applyTTSSpeed(input)).To(Succeed())
81+
Expect(input.Params).To(HaveKeyWithValue("speed", want))
82+
},
83+
Entry("minimum", float32(0.25), "0.25"),
84+
Entry("maximum", float32(4), "4"),
85+
)
86+
})

core/schema/localai.go

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

0 commit comments

Comments
 (0)