mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-03 22:17:04 +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.
280 lines
9.3 KiB
Go
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
|
|
}
|