mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-28 16:20:39 +00:00
- HTTP synthesize + test-connection now read tenant tts.timeout_ms (default 120s, was hardcoded 15s/10s). Gemini client default also bumped 30s→120s so both layers align when tenant config unset. - Inline prefix "Speak naturally: " prepended to single-voice text; multi-speaker transcripts pass through unchanged. - ErrTextOnlyResponse sentinel for 400 "text generation" bodies; single-voice retries once with stronger prefix. Narrowed needle list avoids false positives on unrelated 400s. - SynthesizeWithFallbackAdapted now returns errors.Join so sentinel survives fallback chain; HTTP 422 mapping + locale-translated ForLLM in agent tool (EN/VI/ZH catalogs). - Default Gemini model bumped to gemini-3.1-flash-tts-preview.
362 lines
13 KiB
Go
362 lines
13 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
|
|
"github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// TTSHandler handles POST /v1/tts/synthesize — converts text to audio via a
|
|
// configured TTS provider and returns raw audio bytes with the appropriate MIME type.
|
|
type TTSHandler struct {
|
|
mu sync.RWMutex
|
|
manager *audio.Manager
|
|
rateLimiter func(string) bool // per-IP/token rate limit check (nil = no limit)
|
|
systemConfigs store.SystemConfigStore // per-tenant TTS settings
|
|
configSecrets store.ConfigSecretsStore // per-tenant TTS secrets
|
|
}
|
|
|
|
// NewTTSHandler creates a TTSHandler backed by the given audio.Manager.
|
|
func NewTTSHandler(mgr *audio.Manager) *TTSHandler {
|
|
return &TTSHandler{manager: mgr}
|
|
}
|
|
|
|
// SetRateLimiter injects the rate limiter function (reused from the server's global limiter).
|
|
func (h *TTSHandler) SetRateLimiter(fn func(string) bool) { h.rateLimiter = fn }
|
|
|
|
// SetStores injects stores for per-tenant TTS config lookup.
|
|
func (h *TTSHandler) SetStores(sc store.SystemConfigStore, cs store.ConfigSecretsStore) {
|
|
h.systemConfigs = sc
|
|
h.configSecrets = cs
|
|
}
|
|
|
|
// UpdateManager swaps the underlying manager (hot-reload safe).
|
|
func (h *TTSHandler) UpdateManager(mgr *audio.Manager) {
|
|
if mgr == nil {
|
|
return
|
|
}
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.manager = mgr
|
|
}
|
|
|
|
// RegisterRoutes wires TTS endpoints onto mux with RoleOperator auth.
|
|
func (h *TTSHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /v1/tts/synthesize",
|
|
requireAuth(permissions.RoleOperator, h.handleSynthesize))
|
|
mux.HandleFunc("POST /v1/tts/test-connection",
|
|
requireAuth(permissions.RoleOperator, h.handleTestConnection))
|
|
h.registerCapabilitiesRoute(mux)
|
|
}
|
|
|
|
// synthesizeRequest is the JSON body for POST /v1/tts/synthesize.
|
|
type synthesizeRequest struct {
|
|
Text string `json:"text"`
|
|
Provider string `json:"provider,omitempty"`
|
|
VoiceID string `json:"voice_id,omitempty"`
|
|
ModelID string `json:"model_id,omitempty"`
|
|
}
|
|
|
|
const (
|
|
maxSynthesizeBodyBytes = 4 << 10 // 4KB — enough for 500 chars + metadata
|
|
maxSynthesizeTextChars = 500
|
|
defaultSynthesizeTimeoutMs = 120000 // 120s default; tenant tts.timeout_ms overrides
|
|
)
|
|
|
|
// handleSynthesize serves POST /v1/tts/synthesize.
|
|
func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
locale := store.LocaleFromContext(ctx)
|
|
|
|
// Rate limit (best-effort — reuses per-IP/token limiter; no per-user bucket).
|
|
if h.rateLimiter != nil {
|
|
key := r.RemoteAddr
|
|
if tok := extractBearerToken(r); tok != "" {
|
|
key = "token:" + tok
|
|
}
|
|
if !h.rateLimiter(key) {
|
|
w.Header().Set("Retry-After", "60")
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, i18n.T(locale, i18n.MsgRateLimitExceeded)), http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Cap request body to prevent DoS via oversized JSON.
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxSynthesizeBodyBytes)
|
|
|
|
var req synthesizeRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, fmt.Sprintf(`{"error":"invalid json: %s"}`, err.Error()), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
req.Text = strings.TrimSpace(req.Text)
|
|
if req.Text == "" {
|
|
http.Error(w, `{"error":"text is required"}`, http.StatusBadRequest)
|
|
return
|
|
}
|
|
if len([]rune(req.Text)) > maxSynthesizeTextChars {
|
|
http.Error(w, fmt.Sprintf(`{"error":"text exceeds %d chars"}`, maxSynthesizeTextChars), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Try per-tenant TTS config first, fall back to global manager.
|
|
p, name, tenantParams, err := h.resolveTenantProvider(ctx, req.Provider)
|
|
if err != nil {
|
|
// Tenant config lookup failed, try global manager
|
|
h.mu.RLock()
|
|
mgr := h.manager
|
|
h.mu.RUnlock()
|
|
|
|
name = req.Provider
|
|
if name == "" {
|
|
name = mgr.PrimaryProvider()
|
|
}
|
|
if name == "" {
|
|
http.Error(w, `{"error":"no tts provider configured"}`, http.StatusNotFound)
|
|
return
|
|
}
|
|
var ok bool
|
|
p, ok = mgr.GetProvider(name)
|
|
if !ok {
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, "provider not found: "+name), http.StatusNotFound)
|
|
return
|
|
}
|
|
}
|
|
|
|
// ElevenLabs model validation — rejects unknown model IDs with allowlist error.
|
|
if name == "elevenlabs" && req.ModelID != "" {
|
|
if err := elevenlabs.ValidateModel(req.ModelID); err != nil {
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Validate tenant params against the provider's capability schema (Finding #3).
|
|
// Rejects unknown keys and out-of-range values before forwarding to the provider.
|
|
if len(tenantParams) > 0 {
|
|
if dp, ok := p.(audio.DescribableProvider); ok {
|
|
caps := dp.Capabilities()
|
|
if err := audio.ValidateParams(caps.Params, tenantParams); err != nil {
|
|
slog.Warn("tts.synthesize.invalid-params", "provider", name, "error", err)
|
|
var ukErr audio.ErrTTSParamUnknownKey
|
|
var orErr audio.ErrTTSParamOutOfRange
|
|
switch {
|
|
case errors.As(err, &ukErr):
|
|
msg := i18n.T(locale, i18n.MsgTtsParamUnknownKey, ukErr.Key)
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity)
|
|
case errors.As(err, &orErr):
|
|
msg := i18n.T(locale, i18n.MsgTtsParamOutOfRange, orErr.Key, orErr.Val, orErr.Min, orErr.Max)
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity)
|
|
default:
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusUnprocessableEntity)
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Synthesize with tenant-configured deadline; fall back to 120s default.
|
|
timeoutMs := loadTenantTTSTimeoutMs(ctx, h.systemConfigs)
|
|
if timeoutMs <= 0 {
|
|
timeoutMs = defaultSynthesizeTimeoutMs
|
|
}
|
|
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
|
|
defer cancel()
|
|
|
|
opts := audio.TTSOptions{Voice: req.VoiceID, Model: req.ModelID, Params: tenantParams}
|
|
// Restore saved multi-speaker defaults (Gemini) so persisted config reactivates
|
|
// at runtime instead of silently falling through to single-voice mode.
|
|
if speakers := loadSavedSpeakers(ctx, h.systemConfigs, name); len(speakers) > 0 {
|
|
opts.Speakers = speakers
|
|
}
|
|
start := time.Now()
|
|
result, err := p.Synthesize(synthCtx, req.Text, opts)
|
|
dur := time.Since(start)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
|
slog.Warn("tts.synthesize.timeout", "provider", name, "ms", dur.Milliseconds())
|
|
http.Error(w, `{"error":"synthesis timeout"}`, http.StatusGatewayTimeout)
|
|
return
|
|
}
|
|
// Translate Gemini sentinel errors to 422 Unprocessable Entity with i18n message.
|
|
if errors.Is(err, gemini.ErrInvalidVoice) {
|
|
slog.Warn("tts.synthesize.invalid-params", "provider", name, "error", err)
|
|
msg := i18n.T(locale, i18n.MsgTtsGeminiInvalidVoice, req.VoiceID)
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
if errors.Is(err, gemini.ErrSpeakerLimit) {
|
|
slog.Warn("tts.synthesize.invalid-params", "provider", name, "error", err)
|
|
msg := i18n.T(locale, i18n.MsgTtsGeminiSpeakerLimit)
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
if errors.Is(err, gemini.ErrInvalidModel) {
|
|
slog.Warn("tts.synthesize.invalid-params", "provider", name, "error", err)
|
|
msg := i18n.T(locale, i18n.MsgTtsGeminiInvalidModel, req.ModelID)
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
if errors.Is(err, gemini.ErrTextOnlyResponse) {
|
|
slog.Warn("tts.synthesize.text-only", "provider", name, "error", err)
|
|
msg := i18n.T(locale, i18n.MsgTtsGeminiTextOnly)
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity)
|
|
return
|
|
}
|
|
// Surface upstream error to caller — opaque "upstream synthesis failed"
|
|
// makes the test playground useless for debugging provider config.
|
|
slog.Warn("tts.synthesize.failed", "provider", name, "error", err)
|
|
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadGateway)
|
|
return
|
|
}
|
|
|
|
slog.Info("tts.synthesize.ok", "provider", name, "bytes", len(result.Audio), "ms", dur.Milliseconds())
|
|
w.Header().Set("Content-Type", result.MimeType)
|
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(result.Audio)))
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(result.Audio)
|
|
}
|
|
|
|
// resolveTenantProvider attempts to create a TTS provider from tenant-specific config.
|
|
// Performs dual-read: reads legacy flat keys AND tts.{p}.params JSON blob.
|
|
// Blob wins on key conflict; legacy flat keys fill gaps.
|
|
// Returns (nil, "", nil, error) if tenant has no TTS config — caller should fall back to global manager.
|
|
func (h *TTSHandler) resolveTenantProvider(ctx context.Context, explicitProvider string) (audio.TTSProvider, string, map[string]any, error) {
|
|
if h.systemConfigs == nil || h.configSecrets == nil {
|
|
return nil, "", nil, fmt.Errorf("stores not configured")
|
|
}
|
|
|
|
// Get tenant's configured provider
|
|
providerName := explicitProvider
|
|
if providerName == "" {
|
|
var err error
|
|
providerName, err = h.systemConfigs.Get(ctx, "tts.provider")
|
|
if err != nil || providerName == "" {
|
|
return nil, "", nil, fmt.Errorf("no tenant tts provider")
|
|
}
|
|
}
|
|
|
|
// Build ephemeral provider from tenant config
|
|
req := testConnectionRequest{Provider: providerName, TimeoutMs: loadTenantTTSTimeoutMs(ctx, h.systemConfigs)}
|
|
|
|
switch providerName {
|
|
case "openai":
|
|
if key, _ := h.configSecrets.Get(ctx, "tts.openai.api_key"); key != "" {
|
|
req.APIKey = key
|
|
} else {
|
|
return nil, "", nil, fmt.Errorf("no api key")
|
|
}
|
|
req.APIBase, _ = h.systemConfigs.Get(ctx, "tts.openai.api_base")
|
|
req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.openai.voice")
|
|
req.ModelID, _ = h.systemConfigs.Get(ctx, "tts.openai.model")
|
|
req.Params = loadParamsBlob(ctx, h.systemConfigs, "tts.openai.params")
|
|
|
|
case "elevenlabs":
|
|
if key, _ := h.configSecrets.Get(ctx, "tts.elevenlabs.api_key"); key != "" {
|
|
req.APIKey = key
|
|
} else {
|
|
return nil, "", nil, fmt.Errorf("no api key")
|
|
}
|
|
req.APIBase, _ = h.systemConfigs.Get(ctx, "tts.elevenlabs.api_base")
|
|
req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.elevenlabs.voice")
|
|
req.ModelID, _ = h.systemConfigs.Get(ctx, "tts.elevenlabs.model")
|
|
req.Params = loadParamsBlob(ctx, h.systemConfigs, "tts.elevenlabs.params")
|
|
|
|
case "minimax":
|
|
if key, _ := h.configSecrets.Get(ctx, "tts.minimax.api_key"); key != "" {
|
|
req.APIKey = key
|
|
} else {
|
|
return nil, "", nil, fmt.Errorf("no api key")
|
|
}
|
|
req.GroupID, _ = h.configSecrets.Get(ctx, "tts.minimax.group_id")
|
|
req.APIBase, _ = h.systemConfigs.Get(ctx, "tts.minimax.api_base")
|
|
req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.minimax.voice")
|
|
req.ModelID, _ = h.systemConfigs.Get(ctx, "tts.minimax.model")
|
|
req.Params = loadParamsBlob(ctx, h.systemConfigs, "tts.minimax.params")
|
|
|
|
case "edge":
|
|
req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.edge.voice")
|
|
req.Rate, _ = h.systemConfigs.Get(ctx, "tts.edge.rate")
|
|
req.Params = loadParamsBlob(ctx, h.systemConfigs, "tts.edge.params")
|
|
|
|
case "gemini":
|
|
if key, _ := h.configSecrets.Get(ctx, "tts.gemini.api_key"); key != "" {
|
|
req.APIKey = key
|
|
} else {
|
|
return nil, "", nil, fmt.Errorf("no api key")
|
|
}
|
|
req.APIBase, _ = h.systemConfigs.Get(ctx, "tts.gemini.api_base")
|
|
req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.gemini.voice")
|
|
req.ModelID, _ = h.systemConfigs.Get(ctx, "tts.gemini.model")
|
|
req.Params = loadParamsBlob(ctx, h.systemConfigs, "tts.gemini.params")
|
|
|
|
default:
|
|
return nil, "", nil, fmt.Errorf("unsupported provider: %s", providerName)
|
|
}
|
|
|
|
provider, err := createEphemeralTTSProvider(req)
|
|
if err != nil {
|
|
return nil, "", nil, err
|
|
}
|
|
|
|
slog.Debug("tts: using tenant provider", "provider", providerName, "tenant", store.TenantIDFromContext(ctx))
|
|
return provider, providerName, req.Params, nil
|
|
}
|
|
|
|
// paramsMaxBytes is the maximum byte length accepted for a stored params blob.
|
|
// Finding #6: cap at 16 KB to prevent oversized JSON DoS.
|
|
const paramsMaxBytes = 16 * 1024
|
|
|
|
// loadParamsBlob reads a JSON blob from system_configs and returns it as map[string]any.
|
|
// Returns nil when the key is absent, the value exceeds 16 KB, or the value is not valid JSON.
|
|
// Never logs the raw value — logs length only on parse failure (Finding #6).
|
|
func loadParamsBlob(ctx context.Context, sc store.SystemConfigStore, key string) map[string]any {
|
|
raw, err := sc.Get(ctx, key)
|
|
if err != nil || raw == "" {
|
|
return nil
|
|
}
|
|
if len(raw) > paramsMaxBytes {
|
|
slog.Warn("tts: params blob exceeds 16 KB limit, ignoring", "key", key, "length", len(raw))
|
|
return nil
|
|
}
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
|
slog.Warn("tts: invalid params blob", "key", key, "length", len(raw))
|
|
return nil
|
|
}
|
|
return m
|
|
}
|
|
|
|
// loadSavedSpeakers reads the per-tenant multi-speaker JSON blob for providers
|
|
// that persist speaker configuration (currently only Gemini). Returns nil when
|
|
// no config exists or decode fails — caller falls back to single-voice mode.
|
|
func loadSavedSpeakers(ctx context.Context, sc store.SystemConfigStore, providerName string) []audio.SpeakerVoice {
|
|
if sc == nil || providerName != "gemini" {
|
|
return nil
|
|
}
|
|
raw, err := sc.Get(ctx, "tts.gemini.speakers")
|
|
if err != nil || raw == "" {
|
|
return nil
|
|
}
|
|
var speakers []audio.SpeakerVoice
|
|
if err := json.Unmarshal([]byte(raw), &speakers); err != nil {
|
|
return nil
|
|
}
|
|
return speakers
|
|
}
|