Files
goclaw/internal/http/tts_test_connection.go
viettranx 04a9938f4f feat(tts): wire tenant timeout + fix Gemini text-only 400
- 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.
2026-04-23 08:31:53 +07:00

280 lines
9.3 KiB
Go

package http
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/edge"
"github.com/nextlevelbuilder/goclaw/internal/audio/elevenlabs"
"github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
"github.com/nextlevelbuilder/goclaw/internal/audio/minimax"
"github.com/nextlevelbuilder/goclaw/internal/audio/openai"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// testConnectionRequest is the JSON body for POST /v1/tts/test-connection.
type testConnectionRequest struct {
Provider string `json:"provider"`
APIKey string `json:"api_key,omitempty"`
APIBase string `json:"api_base,omitempty"`
BaseURL string `json:"base_url,omitempty"`
VoiceID string `json:"voice_id,omitempty"`
ModelID string `json:"model_id,omitempty"`
GroupID string `json:"group_id,omitempty"` // MiniMax requires group_id
Rate string `json:"rate,omitempty"`
TimeoutMs int `json:"timeout_ms,omitempty"`
Params map[string]any `json:"params,omitempty"` // provider-specific params blob
}
// testConnectionResponse is the JSON response for POST /v1/tts/test-connection.
type testConnectionResponse struct {
Success bool `json:"success"`
Provider string `json:"provider,omitempty"`
LatencyMs int64 `json:"latency_ms,omitempty"`
Error string `json:"error,omitempty"`
}
// supportedTestProviders lists providers that support ephemeral test-connection.
var supportedTestProviders = map[string]bool{
"openai": true,
"elevenlabs": true,
"edge": true,
"minimax": true,
"gemini": true,
}
// providersRequiringAPIKey lists providers that need an API key.
var providersRequiringAPIKey = map[string]bool{
"openai": true,
"elevenlabs": true,
"minimax": true,
"gemini": true,
}
const defaultTestConnectionTimeoutMs = 120000 // 120s default; req.TimeoutMs > tenant > default
// handleTestConnection serves POST /v1/tts/test-connection.
// Creates an ephemeral provider from request credentials and tests synthesis.
func (h *TTSHandler) handleTestConnection(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
locale := store.LocaleFromContext(ctx)
// Rate limit (same as synthesize).
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
}
}
r.Body = http.MaxBytesReader(w, r.Body, maxSynthesizeBodyBytes)
var req testConnectionRequest
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.Provider = strings.TrimSpace(req.Provider)
if req.Provider == "" {
http.Error(w, `{"error":"provider is required"}`, http.StatusBadRequest)
return
}
if !supportedTestProviders[req.Provider] {
http.Error(w, fmt.Sprintf(`{"error":"unsupported provider: %s"}`, req.Provider), http.StatusBadRequest)
return
}
// Fall back to saved secrets when the request is testing a previously saved key.
// Frontend masks stored API keys as "***" — without this fallback, retesting
// an existing config would force the user to retype the key.
h.fillMissingTestSecrets(ctx, &req)
if providersRequiringAPIKey[req.Provider] && (req.APIKey == "" || req.APIKey == "***") {
http.Error(w, fmt.Sprintf(`{"error":"api_key is required for %s"}`, req.Provider), http.StatusBadRequest)
return
}
if req.APIBase == "" {
req.APIBase = req.BaseURL
}
if err := validateProviderURL(req.APIBase, req.Provider); err != nil {
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
return
}
// Create ephemeral provider.
provider, err := createEphemeralTTSProvider(req)
if err != nil {
slog.Warn("tts.test-connection.provider-create-failed", "provider", req.Provider, "error", err)
http.Error(w, fmt.Sprintf(`{"error":%q}`, err.Error()), http.StatusBadRequest)
return
}
// Synthesize short test text — req.TimeoutMs overrides tenant which overrides default 120s.
effectiveMs := req.TimeoutMs
if effectiveMs <= 0 {
effectiveMs = loadTenantTTSTimeoutMs(ctx, h.systemConfigs)
}
if effectiveMs <= 0 {
effectiveMs = defaultTestConnectionTimeoutMs
}
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(effectiveMs)*time.Millisecond)
defer cancel()
start := time.Now()
_, err = provider.Synthesize(synthCtx, "test", audio.TTSOptions{Voice: req.VoiceID, Model: req.ModelID, Params: req.Params})
dur := time.Since(start)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
slog.Warn("tts.test-connection.timeout", "provider", req.Provider, "ms", dur.Milliseconds())
writeJSON(w, http.StatusGatewayTimeout, testConnectionResponse{
Success: false, Error: "test timeout",
})
return
}
// Translate Gemini sentinel errors to 422 Unprocessable Entity with i18n message.
if errors.Is(err, gemini.ErrInvalidVoice) {
slog.Warn("tts.test-connection.invalid-params", "provider", req.Provider, "error", err)
writeJSON(w, http.StatusUnprocessableEntity, testConnectionResponse{
Success: false, Error: i18n.T(locale, i18n.MsgTtsGeminiInvalidVoice, req.VoiceID),
})
return
}
if errors.Is(err, gemini.ErrSpeakerLimit) {
slog.Warn("tts.test-connection.invalid-params", "provider", req.Provider, "error", err)
writeJSON(w, http.StatusUnprocessableEntity, testConnectionResponse{
Success: false, Error: i18n.T(locale, i18n.MsgTtsGeminiSpeakerLimit),
})
return
}
if errors.Is(err, gemini.ErrInvalidModel) {
slog.Warn("tts.test-connection.invalid-params", "provider", req.Provider, "error", err)
writeJSON(w, http.StatusUnprocessableEntity, testConnectionResponse{
Success: false, Error: i18n.T(locale, i18n.MsgTtsGeminiInvalidModel, req.ModelID),
})
return
}
if errors.Is(err, gemini.ErrTextOnlyResponse) {
slog.Warn("tts.test-connection.text-only", "provider", req.Provider, "error", err)
writeJSON(w, http.StatusUnprocessableEntity, testConnectionResponse{
Success: false, Error: i18n.T(locale, i18n.MsgTtsGeminiTextOnly),
})
return
}
// Surface upstream error to caller — test-connection is a diagnostic
// endpoint, opacity here just makes debugging harder.
slog.Warn("tts.test-connection.failed", "provider", req.Provider, "error", err)
writeJSON(w, http.StatusBadGateway, testConnectionResponse{
Success: false, Error: err.Error(),
})
return
}
slog.Info("tts.test-connection.ok", "provider", req.Provider, "ms", dur.Milliseconds())
writeJSON(w, http.StatusOK, testConnectionResponse{
Success: true,
Provider: req.Provider,
LatencyMs: dur.Milliseconds(),
})
}
// createEphemeralTTSProvider creates a TTS provider from request credentials.
// The provider is ephemeral — not registered in the manager.
func createEphemeralTTSProvider(req testConnectionRequest) (audio.TTSProvider, error) {
switch req.Provider {
case "openai":
return openai.NewProvider(openai.Config{
APIKey: req.APIKey,
APIBase: req.APIBase,
Model: req.ModelID,
Voice: req.VoiceID,
TimeoutMs: req.TimeoutMs,
}), nil
case "elevenlabs":
return elevenlabs.NewTTSProvider(elevenlabs.Config{
APIKey: req.APIKey,
BaseURL: req.APIBase,
VoiceID: req.VoiceID,
ModelID: req.ModelID,
TimeoutMs: req.TimeoutMs,
}), nil
case "edge":
return edge.NewProvider(edge.Config{
Voice: req.VoiceID,
Rate: req.Rate,
TimeoutMs: req.TimeoutMs,
}), nil
case "minimax":
return minimax.NewProvider(minimax.Config{
APIKey: req.APIKey,
APIBase: req.APIBase,
GroupID: req.GroupID,
VoiceID: req.VoiceID,
Model: req.ModelID,
TimeoutMs: req.TimeoutMs,
}), nil
case "gemini":
return gemini.NewProvider(gemini.Config{
APIKey: req.APIKey,
APIBase: req.APIBase,
Voice: req.VoiceID,
Model: req.ModelID,
TimeoutMs: req.TimeoutMs,
}), nil
default:
return nil, fmt.Errorf("unsupported provider: %s", req.Provider)
}
}
// fillMissingTestSecrets fills empty/masked secret fields on req with values
// from the tenant's saved config. Lets the frontend re-test a stored API key
// without forcing the user to retype it (the key is masked as "***" in GET).
func (h *TTSHandler) fillMissingTestSecrets(ctx context.Context, req *testConnectionRequest) {
if h.configSecrets == nil {
return
}
if providersRequiringAPIKey[req.Provider] && (req.APIKey == "" || req.APIKey == "***") {
if saved, _ := h.configSecrets.Get(ctx, "tts."+req.Provider+".api_key"); saved != "" {
req.APIKey = saved
}
}
if req.Provider == "minimax" && req.GroupID == "" {
if saved, _ := h.configSecrets.Get(ctx, "tts.minimax.group_id"); saved != "" {
req.GroupID = saved
}
}
}
func loadTenantTTSTimeoutMs(ctx context.Context, sc store.SystemConfigStore) int {
if sc == nil {
return 0
}
raw, err := sc.Get(ctx, "tts.timeout_ms")
if err != nil || raw == "" {
return 0
}
timeoutMs, err := strconv.Atoi(raw)
if err != nil || timeoutMs <= 0 {
return 0
}
return timeoutMs
}