Files
LocalAI/core/http/endpoints/openai/transcription.go
T
localai-org-maint-botandlocalai-org-maint-bot c525ad16e8 fix(audio): record transcription usage and traces (#12546)
* fix(audio): record transcription usage and traces

Count successful transcription requests by model, including streams.
Keep token counts at zero because transcription exposes no token usage.

Capture multipart API trace metadata without reading uploaded audio.
Do not record failed transcription or client writes as successful usage.

Assisted-by: Codex:gpt-6-astra

* fix(audio): preserve aliases in streaming usage

Streaming transcription records the resolved target as its usage model.
Pass the requested name so JSON and SSE requests share the alias bucket.

Assisted-by: Codex:gpt-6-astra

* test(http): check multipart reader close errors

Assert successful reader cleanup to satisfy the errcheck CI gate.

Assisted-by: Codex:gpt-6

---------

Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
2026-10-07 16:57:23 +02:00

380 lines
12 KiB
Go

package openai
import (
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
model "github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// TranscriptEndpoint is the OpenAI Whisper API endpoint https://platform.openai.com/docs/api-reference/audio/create
// @Summary Transcribes audio into the input language.
// @Tags audio
// @accept multipart/form-data
// @Param model formData string true "model"
// @Param file formData file true "file"
// @Param temperature formData number false "sampling temperature"
// @Param timestamp_granularities formData []string false "timestamp granularities (word, segment)"
// @Param stream formData boolean false "stream partial results as SSE"
// @Success 200 {object} map[string]string "Response"
// @Router /v1/audio/transcriptions [post]
// resolveTranscriptionLanguage picks the transcription language. The request
// form field (OpenAI's `language` param) wins, then any language carried on the
// parsed request, and finally the model config default (parameters.language).
func resolveTranscriptionLanguage(formLanguage, requestLanguage, configLanguage string) string {
if formLanguage != "" {
return formLanguage
}
if requestLanguage != "" {
return requestLanguage
}
return configLanguage
}
// resolveTranscriptionTranslate picks the translate flag: the request form
// field (`translate`, when a valid bool) wins over the model config default
// (parameters.translate).
func resolveTranscriptionTranslate(formTranslate string, configTranslate bool) bool {
if b, err := strconv.ParseBool(formTranslate); err == nil {
return b
}
return configTranslate
}
// uploadedFile reads a required multipart file field. Any failure here (no
// multipart boundary, malformed body, missing field) is caused by the request,
// so it maps to 400 instead of leaking the parser error as a 500.
func uploadedFile(c echo.Context, field string) (*multipart.FileHeader, error) {
file, err := c.FormFile(field)
if err != nil {
return nil, echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("missing or invalid %q file upload: %v", field, err))
}
return file, nil
}
func TranscriptEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig) echo.HandlerFunc {
return func(c echo.Context) error {
input, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenAIRequest)
if !ok || input.Model == "" {
return echo.ErrBadRequest
}
config, ok := c.Get(middleware.CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig)
if !ok || config == nil {
return echo.ErrBadRequest
}
diarize := c.FormValue("diarize") != "false"
prompt := c.FormValue("prompt")
responseFormat := schema.TranscriptionResponseFormatType(c.FormValue("response_format"))
// OpenAI accepts `temperature` as a string in multipart form. Tolerate
// missing/invalid values rather than failing the whole request.
var temperature float32
if v := c.FormValue("temperature"); v != "" {
if t, err := strconv.ParseFloat(v, 32); err == nil {
temperature = float32(t)
}
}
// timestamp_granularities[] is a multi-value form field per the OpenAI spec.
// Echo exposes all values for a key via FormParams.
var timestampGranularities []string
if form, err := c.FormParams(); err == nil {
for _, key := range []string{"timestamp_granularities[]", "timestamp_granularities"} {
if vals, ok := form[key]; ok {
for _, v := range vals {
v = strings.TrimSpace(v)
if v != "" {
timestampGranularities = append(timestampGranularities, v)
}
}
}
}
}
stream := false
if v := c.FormValue("stream"); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
stream = b
}
}
// 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 {
return err
}
f, err := file.Open()
if err != nil {
return err
}
defer f.Close()
dir, err := os.MkdirTemp("", "whisper")
if err != nil {
return err
}
defer os.RemoveAll(dir)
dst := filepath.Join(dir, path.Base(file.Filename))
dstFile, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(dstFile, f); err != nil {
xlog.Debug("Audio file copying error", "filename", file.Filename, "dst", dst, "error", err)
return err
}
xlog.Debug("Audio file copied", "dst", dst)
// Language/translate resolve with the request form field taking
// precedence over the model config default (parameters.language /
// parameters.translate). Previously only the parsed request was read,
// which for a multipart upload never carries these, so config-level
// settings were silently ignored and multilingual models (e.g. canary)
// defaulted to English translation. (#10655)
language := resolveTranscriptionLanguage(c.FormValue("language"), input.Language, config.Language)
translate := resolveTranscriptionTranslate(c.FormValue("translate"), config.Translate)
req := backend.TranscriptionRequest{
Audio: dst,
Language: language,
Translate: translate,
Diarize: diarize,
Prompt: prompt,
Temperature: temperature,
TimestampGranularities: timestampGranularities,
}
if stream {
return streamTranscription(c, req, input.Model, ml, *config, appConfig)
}
tr, err := backend.ModelTranscriptionWithOptions(c.Request().Context(), req, ml, *config, appConfig)
if err != nil {
// Log before returning so the underlying error survives. Echo's
// error handler turns this into a 500 with a generic body, which
// otherwise leaves operators chasing a silent failure — see e.g.
// distributed transcription, where the gRPC error from a remote
// node is the only signal of what actually went wrong.
xlog.Error("Transcription failed",
"model", config.Name,
"audio", dst,
"error", err)
return err
}
xlog.Debug("Transcribed", "transcription", tr)
switch responseFormat {
case schema.TranscriptionResponseFormatLrc, schema.TranscriptionResponseFormatText, schema.TranscriptionResponseFormatSrt, schema.TranscriptionResponseFormatVtt:
err = c.String(http.StatusOK, schema.TranscriptionResponse(tr, responseFormat))
case schema.TranscriptionResponseFormatJson:
tr.Segments = nil
tr.Words = nil
fallthrough
case schema.TranscriptionResponseFormatJsonVerbose, "": // maintain backwards compatibility
trs := schema.TranscriptionResultSeconds{
Text: tr.Text,
Language: tr.Language,
Duration: tr.Duration,
Words: []schema.TranscriptionWordSeconds{},
Segments: []schema.TranscriptionSegmentSeconds{},
}
for _, word := range tr.Words {
trs.Words = append(trs.Words, schema.TranscriptionWordSeconds{
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Speaker: word.Speaker,
})
}
for _, seg := range tr.Segments {
segWords := []schema.TranscriptionWordSeconds{}
for _, word := range seg.Words {
segWords = append(segWords, schema.TranscriptionWordSeconds{
Start: word.Start.Seconds(),
End: word.End.Seconds(),
Text: word.Text,
Speaker: word.Speaker,
})
}
trs.Segments = append(trs.Segments, schema.TranscriptionSegmentSeconds{
Id: seg.Id,
Start: seg.Start.Seconds(),
End: seg.End.Seconds(),
Text: seg.Text,
Tokens: seg.Tokens,
Speaker: seg.Speaker,
Words: segWords,
})
}
err = c.JSON(http.StatusOK, trs)
default:
return echo.NewHTTPError(http.StatusBadRequest, "invalid response_format")
}
if err == nil {
// Transcription exposes no canonical token counts, but successful
// requests still contribute model usage and elapsed time.
middleware.StampUsage(c, input.Model, 0, 0)
}
return err
}
}
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
// can't truly stream still produce a single Final event, which we surface as
// one delta + done.
func streamTranscription(c echo.Context, req backend.TranscriptionRequest, requestedModel string, ml *model.ModelLoader, config config.ModelConfig, appConfig *config.ApplicationConfig) error {
c.Response().Header().Set("Content-Type", "text/event-stream")
c.Response().Header().Set("Cache-Control", "no-cache")
c.Response().Header().Set("Connection", "keep-alive")
c.Response().WriteHeader(http.StatusOK)
writeEvent := func(payload any) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
if _, err := fmt.Fprintf(c.Response().Writer, "data: %s\n\n", data); err != nil {
return err
}
c.Response().Flush()
return nil
}
var assembled strings.Builder
var finalResult *schema.TranscriptionResult
var writeErr error
err := backend.ModelTranscriptionStream(c.Request().Context(), req, ml, config, appConfig, func(chunk backend.TranscriptionStreamChunk) {
if chunk.Delta != "" {
assembled.WriteString(chunk.Delta)
if writeErr == nil {
writeErr = writeEvent(map[string]any{
"type": "transcript.text.delta",
"delta": chunk.Delta,
})
}
}
if chunk.Final != nil {
finalResult = chunk.Final
}
})
if err != nil {
errPayload := map[string]any{
"type": "error",
"error": map[string]any{
"message": err.Error(),
"type": "server_error",
},
}
_ = writeEvent(errPayload)
_, _ = fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n")
c.Response().Flush()
return nil
}
if writeErr != nil {
return writeErr
}
// Build the final event. Prefer the backend-provided final result; if the
// backend only emitted deltas, synthesize the result from what we collected.
if finalResult == nil {
finalResult = &schema.TranscriptionResult{Text: assembled.String()}
} else if finalResult.Text == "" && assembled.Len() > 0 {
finalResult.Text = assembled.String()
}
// If the backend never produced a delta but did return a final text, emit
// it as a single delta so clients always see at least one delta event.
if assembled.Len() == 0 && finalResult.Text != "" {
if err := writeEvent(map[string]any{
"type": "transcript.text.delta",
"delta": finalResult.Text,
}); err != nil {
return err
}
}
// done carries the assembled text plus, when the backend produced them,
// per-segment timings, audio duration, and detected language. The OpenAI
// streaming spec only specifies `text`; the extra fields are an additive
// extension so streaming clients (e.g. notetaker) can build the same
// TranscriptionResultSeconds shape they get from the JSON response path
// without us forcing them off SSE just to recover segments. Spec-compliant
// clients ignore unknown fields.
doneEvent := map[string]any{
"type": "transcript.text.done",
"text": finalResult.Text,
}
if finalResult.Language != "" {
doneEvent["language"] = finalResult.Language
}
if finalResult.Duration > 0 {
doneEvent["duration"] = finalResult.Duration
}
if len(finalResult.Segments) > 0 {
segs := make([]map[string]any, 0, len(finalResult.Segments))
for _, seg := range finalResult.Segments {
entry := map[string]any{
"id": seg.Id,
"start": seg.Start.Seconds(),
"end": seg.End.Seconds(),
"text": seg.Text,
}
if seg.Speaker != "" {
entry["speaker"] = seg.Speaker
}
segs = append(segs, entry)
}
doneEvent["segments"] = segs
}
if err := writeEvent(doneEvent); err != nil {
return err
}
if _, err := fmt.Fprintf(c.Response().Writer, "data: [DONE]\n\n"); err != nil {
return err
}
c.Response().Flush()
middleware.StampUsage(c, requestedModel, 0, 0)
return nil
}