mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-20 02:17:24 +00:00
207 lines
6.3 KiB
Go
207 lines
6.3 KiB
Go
package openaicompat
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
|
)
|
|
|
|
// sttMaxBytes matches the OpenAI upload limit, which compatible engines
|
|
// generally adopt. Enforced before the request so an oversized file fails
|
|
// locally instead of after a full upload.
|
|
const sttMaxBytes = 25 << 20 // 25 MB
|
|
|
|
// STTProvider transcribes audio via an OpenAI-compatible
|
|
// POST /audio/transcriptions.
|
|
type STTProvider struct {
|
|
cfg Config
|
|
}
|
|
|
|
// NewSTTProvider returns an STT provider for the configured endpoint.
|
|
// Returns ErrAPIBaseRequired when cfg.APIBase is empty.
|
|
func NewSTTProvider(cfg Config) (*STTProvider, error) {
|
|
if strings.TrimSpace(cfg.APIBase) == "" {
|
|
return nil, ErrAPIBaseRequired
|
|
}
|
|
return &STTProvider{cfg: cfg.withDefaults()}, nil
|
|
}
|
|
|
|
// Name returns the stable provider identifier.
|
|
func (p *STTProvider) Name() string { return providerName }
|
|
|
|
// Transcribe converts audio to text.
|
|
//
|
|
// The multipart filename is significant, not cosmetic: several compatible
|
|
// engines type the audio by its extension rather than by Content-Type, and
|
|
// reject an upload whose name carries no format they recognise. The filename is
|
|
// therefore derived from STTInput.Filename, then the base of FilePath, then the
|
|
// MIME type — never left blank.
|
|
func (p *STTProvider) Transcribe(ctx context.Context, in audio.STTInput, opts audio.STTOptions) (*audio.TranscriptResult, error) {
|
|
body, contentType, err := p.buildForm(in, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
url := strings.TrimRight(p.cfg.APIBase, "/") + "/audio/transcriptions"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openai_compat stt: create request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", contentType)
|
|
if p.cfg.APIKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+p.cfg.APIKey)
|
|
}
|
|
|
|
timeout := time.Duration(p.cfg.TimeoutMs) * time.Millisecond
|
|
if opts.TimeoutMs > 0 {
|
|
timeout = time.Duration(opts.TimeoutMs) * time.Millisecond
|
|
}
|
|
hc := &http.Client{Timeout: timeout}
|
|
resp, err := hc.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("openai_compat stt: http request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyLimit))
|
|
return nil, fmt.Errorf("openai_compat stt: endpoint error %d: %s", resp.StatusCode, strings.TrimSpace(string(errBody)))
|
|
}
|
|
|
|
// verbose_json is a superset of json; language and duration are absent from
|
|
// a plain json response and stay zero-valued, which the Manager tolerates.
|
|
var result struct {
|
|
Text string `json:"text"`
|
|
Language string `json:"language"`
|
|
Duration float64 `json:"duration"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, fmt.Errorf("openai_compat stt: parse response: %w", err)
|
|
}
|
|
|
|
language := result.Language
|
|
if language == "" {
|
|
language = opts.Language
|
|
}
|
|
return &audio.TranscriptResult{
|
|
Text: result.Text,
|
|
Language: language,
|
|
Duration: result.Duration,
|
|
Provider: providerName,
|
|
}, nil
|
|
}
|
|
|
|
// buildForm assembles the multipart body, reading the audio from FilePath or
|
|
// Bytes. Returns the body and its Content-Type header value.
|
|
func (p *STTProvider) buildForm(in audio.STTInput, opts audio.STTOptions) (io.Reader, string, error) {
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
|
|
if model := firstNonEmpty(opts.ModelID, p.cfg.STTModel); model != "" {
|
|
if err := mw.WriteField("model", model); err != nil {
|
|
return nil, "", fmt.Errorf("openai_compat stt: write model field: %w", err)
|
|
}
|
|
}
|
|
if opts.Language != "" {
|
|
if err := mw.WriteField("language", opts.Language); err != nil {
|
|
return nil, "", fmt.Errorf("openai_compat stt: write language field: %w", err)
|
|
}
|
|
}
|
|
if err := mw.WriteField("response_format", "json"); err != nil {
|
|
return nil, "", fmt.Errorf("openai_compat stt: write response_format field: %w", err)
|
|
}
|
|
|
|
fw, err := mw.CreateFormFile("file", sttFilename(in))
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("openai_compat stt: create form file: %w", err)
|
|
}
|
|
if err := writeAudio(fw, in); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return nil, "", fmt.Errorf("openai_compat stt: close multipart writer: %w", err)
|
|
}
|
|
return &buf, mw.FormDataContentType(), nil
|
|
}
|
|
|
|
// writeAudio copies the input audio into dst, enforcing the size cap.
|
|
func writeAudio(dst io.Writer, in audio.STTInput) error {
|
|
if in.FilePath != "" {
|
|
info, err := os.Stat(in.FilePath)
|
|
if err != nil {
|
|
return fmt.Errorf("openai_compat stt: stat file: %w", err)
|
|
}
|
|
if info.Size() > sttMaxBytes {
|
|
return fmt.Errorf("openai_compat stt: file too large (%d bytes, max %d)", info.Size(), sttMaxBytes)
|
|
}
|
|
f, err := os.Open(in.FilePath)
|
|
if err != nil {
|
|
return fmt.Errorf("openai_compat stt: open file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := io.Copy(dst, f); err != nil {
|
|
return fmt.Errorf("openai_compat stt: read file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if len(in.Bytes) == 0 {
|
|
return fmt.Errorf("openai_compat stt: neither FilePath nor Bytes provided")
|
|
}
|
|
if len(in.Bytes) > sttMaxBytes {
|
|
return fmt.Errorf("openai_compat stt: audio too large (%d bytes, max %d)", len(in.Bytes), sttMaxBytes)
|
|
}
|
|
if _, err := dst.Write(in.Bytes); err != nil {
|
|
return fmt.Errorf("openai_compat stt: write audio bytes: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sttFilename derives the multipart filename. See Transcribe for why a blank
|
|
// name is not an option.
|
|
func sttFilename(in audio.STTInput) string {
|
|
if in.Filename != "" {
|
|
return in.Filename
|
|
}
|
|
if in.FilePath != "" {
|
|
return filepath.Base(in.FilePath)
|
|
}
|
|
return "audio" + extFromMime(in.MimeType)
|
|
}
|
|
|
|
// extFromMime maps a MIME type to a file extension, including the leading dot.
|
|
func extFromMime(mime string) string {
|
|
// Strip codec parameters, e.g. "audio/ogg; codecs=opus".
|
|
if idx := strings.IndexByte(mime, ';'); idx >= 0 {
|
|
mime = mime[:idx]
|
|
}
|
|
switch strings.TrimSpace(mime) {
|
|
case "audio/ogg", "audio/opus":
|
|
return ".ogg"
|
|
case "audio/mpeg", "audio/mp3":
|
|
return ".mp3"
|
|
case "audio/wav", "audio/wave", "audio/x-wav":
|
|
return ".wav"
|
|
case "audio/pcm", "audio/l16":
|
|
return ".pcm"
|
|
case "audio/mp4", "audio/m4a", "audio/x-m4a":
|
|
return ".m4a"
|
|
case "audio/webm":
|
|
return ".webm"
|
|
case "audio/flac", "audio/x-flac":
|
|
return ".flac"
|
|
default:
|
|
return ".bin"
|
|
}
|
|
}
|