From f20cc16033ba75b04c18d3da08a7f5d713acbe90 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sun, 27 Sep 2026 20:17:06 +0000 Subject: [PATCH] fix(openai): reject an unknown audio response_format as a 400 before the backend runs Transcription and diarization checked response_format only after the backend had run, and returned a plain error for an unknown value. Failover counts a plain error as a target failure, so one request with a bad response_format ran the backend on every target of a chain and tripped all of them. Check the format before the backend runs and answer 400. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude:claude-opus-5-5 [Claude Code] --- .../endpoints/openai/audio_upload_test.go | 16 +++++++++++++++ core/http/endpoints/openai/diarization.go | 10 ++++++++-- core/http/endpoints/openai/transcription.go | 20 +++++++++++++++++-- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/core/http/endpoints/openai/audio_upload_test.go b/core/http/endpoints/openai/audio_upload_test.go index 2df8e5f02..ee788a4fd 100644 --- a/core/http/endpoints/openai/audio_upload_test.go +++ b/core/http/endpoints/openai/audio_upload_test.go @@ -50,6 +50,22 @@ var _ = Describe("audio upload endpoints reject bad uploads as client errors", f Expect(he.Code).To(Equal(http.StatusBadRequest)) } + // An unknown response_format is the caller's fault too, and must be + // rejected before the backend runs: a failover chain would otherwise + // transcribe on every target and count the error against each of them. + for name, ec := range map[string]endpointCase{ + "transcription": cases["transcription"], + "diarization": cases["diarization"], + } { + It(name+": unknown response_format", func() { + body := "--xyz\r\nContent-Disposition: form-data; name=\"response_format\"\r\n\r\nbogus\r\n" + + "--xyz\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.wav\"\r\n\r\nRIFF\r\n--xyz--\r\n" + var err error + Expect(func() { err = run(ec, "multipart/form-data; boundary=xyz", body) }).NotTo(Panic(), "the backend must not be reached") + expectBadRequest(err) + }) + } + for name, ec := range cases { It(name+": multipart content type without a boundary", func() { expectBadRequest(run(ec, "multipart/form-data", "")) diff --git a/core/http/endpoints/openai/diarization.go b/core/http/endpoints/openai/diarization.go index 59cc73599..b39935e8b 100644 --- a/core/http/endpoints/openai/diarization.go +++ b/core/http/endpoints/openai/diarization.go @@ -1,7 +1,6 @@ package openai import ( - "errors" "fmt" "io" "net/http" @@ -75,6 +74,13 @@ func DiarizationEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, ap if responseFormat == "" { responseFormat = schema.DiarizationResponseFormatJson } + switch responseFormat { + case schema.DiarizationResponseFormatJson, schema.DiarizationResponseFormatJsonVerbose, schema.DiarizationResponseFormatRTTM: + default: + // Checked before the backend runs, for the same reason as in + // TranscriptEndpoint. + return echo.NewHTTPError(http.StatusBadRequest, "invalid response_format (expected: json, verbose_json, rttm)") + } file, err := uploadedFile(c, "file") if err != nil { @@ -126,7 +132,7 @@ func DiarizationEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, ap case schema.DiarizationResponseFormatJsonVerbose: return c.JSON(http.StatusOK, result) default: - return errors.New("invalid response_format (expected: json, verbose_json, rttm)") + return echo.NewHTTPError(http.StatusBadRequest, "invalid response_format (expected: json, verbose_json, rttm)") } } } diff --git a/core/http/endpoints/openai/transcription.go b/core/http/endpoints/openai/transcription.go index 77b9237c1..6c99d7afc 100644 --- a/core/http/endpoints/openai/transcription.go +++ b/core/http/endpoints/openai/transcription.go @@ -2,7 +2,6 @@ package openai import ( "encoding/json" - "errors" "fmt" "io" "mime/multipart" @@ -117,6 +116,13 @@ func TranscriptEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, app } } + // Reject an unknown format before the backend runs: the backend work + // would be wasted, and a failover chain would count the error + // against every target. + if !stream && !validTranscriptionResponseFormat(responseFormat) { + return echo.NewHTTPError(http.StatusBadRequest, "invalid response_format") + } + // retrieve the file data from the request file, err := uploadedFile(c, "file") if err != nil { @@ -230,11 +236,21 @@ func TranscriptEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, app } return c.JSON(http.StatusOK, trs) default: - return errors.New("invalid response_format") + return echo.NewHTTPError(http.StatusBadRequest, "invalid response_format") } } } +func validTranscriptionResponseFormat(f schema.TranscriptionResponseFormatType) bool { + switch f { + case "", schema.TranscriptionResponseFormatLrc, schema.TranscriptionResponseFormatText, + schema.TranscriptionResponseFormatSrt, schema.TranscriptionResponseFormatVtt, + schema.TranscriptionResponseFormatJson, schema.TranscriptionResponseFormatJsonVerbose: + return true + } + return false +} + // streamTranscription emits OpenAI-format SSE events for a transcription // request: one `transcript.text.delta` per backend chunk, a final // `transcript.text.done` with the assembled text, and `[DONE]`. Backends that