diff --git a/internal/http/tts.go b/internal/http/tts.go index e97e4c11..3fb3602f 100644 --- a/internal/http/tts.go +++ b/internal/http/tts.go @@ -23,9 +23,9 @@ import ( 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 + 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. @@ -69,7 +69,7 @@ type synthesizeRequest struct { } const ( - maxSynthesizeBodyBytes = 4 << 10 // 4KB — enough for 500 chars + metadata + maxSynthesizeBodyBytes = 4 << 10 // 4KB — enough for 500 chars + metadata maxSynthesizeTextChars = 500 synthesizeTimeout = 15 * time.Second ) @@ -188,7 +188,7 @@ func (h *TTSHandler) resolveTenantProvider(ctx context.Context, explicitProvider } // Build ephemeral provider from tenant config - req := testConnectionRequest{Provider: providerName} + req := testConnectionRequest{Provider: providerName, TimeoutMs: loadTenantTTSTimeoutMs(ctx, h.systemConfigs)} switch providerName { case "openai": @@ -224,6 +224,7 @@ func (h *TTSHandler) resolveTenantProvider(ctx context.Context, explicitProvider case "edge": req.VoiceID, _ = h.systemConfigs.Get(ctx, "tts.edge.voice") + req.Rate, _ = h.systemConfigs.Get(ctx, "tts.edge.rate") default: return nil, "", fmt.Errorf("unsupported provider: %s", providerName) diff --git a/internal/http/tts_config.go b/internal/http/tts_config.go index 3ff77f47..c1915285 100644 --- a/internal/http/tts_config.go +++ b/internal/http/tts_config.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "github.com/google/uuid" @@ -34,22 +35,28 @@ func (h *TTSConfigHandler) RegisterRoutes(mux *http.ServeMux) { // ttsConfigResponse is the response for GET /v1/tts/config. type ttsConfigResponse struct { - Provider string `json:"provider"` - Auto string `json:"auto"` - Mode string `json:"mode"` - MaxLength int `json:"max_length"` - OpenAI ttsProviderConfigResponse `json:"openai"` + Provider string `json:"provider"` + Auto string `json:"auto"` + Mode string `json:"mode"` + MaxLength int `json:"max_length"` + TimeoutMs int `json:"timeout_ms"` + OpenAI ttsProviderConfigResponse `json:"openai"` ElevenLabs ttsProviderConfigResponse `json:"elevenlabs"` - Edge ttsProviderConfigResponse `json:"edge"` - MiniMax ttsProviderConfigResponse `json:"minimax"` + Edge ttsProviderConfigResponse `json:"edge"` + MiniMax ttsProviderConfigResponse `json:"minimax"` } type ttsProviderConfigResponse struct { - APIKey string `json:"api_key,omitempty"` // masked + APIKey string `json:"api_key,omitempty"` // masked APIBase string `json:"api_base,omitempty"` + BaseURL string `json:"base_url,omitempty"` Voice string `json:"voice,omitempty"` + VoiceID string `json:"voice_id,omitempty"` Model string `json:"model,omitempty"` + ModelID string `json:"model_id,omitempty"` GroupID string `json:"group_id,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Rate string `json:"rate,omitempty"` } // handleGet returns TTS config for the current tenant. @@ -65,6 +72,7 @@ func (h *TTSConfigHandler) handleGet(w http.ResponseWriter, r *http.Request) { Auto: "off", Mode: "final", MaxLength: 1500, + TimeoutMs: 30000, } // Load from system_configs @@ -79,11 +87,15 @@ func (h *TTSConfigHandler) handleGet(w http.ResponseWriter, r *http.Request) { resp.Mode = v } if v, _ := h.systemConfigs.Get(ctx, "tts.max_length"); v != "" { - var ml int - if json.Unmarshal([]byte(v), &ml) == nil && ml > 0 { + if ml, err := strconv.Atoi(v); err == nil && ml > 0 { resp.MaxLength = ml } } + if v, _ := h.systemConfigs.Get(ctx, "tts.timeout_ms"); v != "" { + if timeoutMs, err := strconv.Atoi(v); err == nil && timeoutMs > 0 { + resp.TimeoutMs = timeoutMs + } + } // Provider-specific non-secrets if v, _ := h.systemConfigs.Get(ctx, "tts.openai.api_base"); v != "" { resp.OpenAI.APIBase = v @@ -96,21 +108,37 @@ func (h *TTSConfigHandler) handleGet(w http.ResponseWriter, r *http.Request) { } if v, _ := h.systemConfigs.Get(ctx, "tts.elevenlabs.api_base"); v != "" { resp.ElevenLabs.APIBase = v + resp.ElevenLabs.BaseURL = v } if v, _ := h.systemConfigs.Get(ctx, "tts.elevenlabs.voice"); v != "" { resp.ElevenLabs.Voice = v + resp.ElevenLabs.VoiceID = v } if v, _ := h.systemConfigs.Get(ctx, "tts.elevenlabs.model"); v != "" { resp.ElevenLabs.Model = v + resp.ElevenLabs.ModelID = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.edge.voice"); v != "" { + resp.Edge.Voice = v + resp.Edge.VoiceID = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.edge.rate"); v != "" { + resp.Edge.Rate = v + } + if v, _ := h.systemConfigs.Get(ctx, "tts.edge.enabled"); v != "" { + enabled := v == "true" + resp.Edge.Enabled = &enabled } if v, _ := h.systemConfigs.Get(ctx, "tts.minimax.api_base"); v != "" { resp.MiniMax.APIBase = v } if v, _ := h.systemConfigs.Get(ctx, "tts.minimax.voice"); v != "" { resp.MiniMax.Voice = v + resp.MiniMax.VoiceID = v } if v, _ := h.systemConfigs.Get(ctx, "tts.minimax.model"); v != "" { resp.MiniMax.Model = v + resp.MiniMax.ModelID = v } } @@ -136,21 +164,28 @@ func (h *TTSConfigHandler) handleGet(w http.ResponseWriter, r *http.Request) { // ttsConfigSaveRequest is the request body for POST /v1/tts/config. type ttsConfigSaveRequest struct { - Provider string `json:"provider"` - Auto string `json:"auto"` - Mode string `json:"mode"` - MaxLength int `json:"max_length"` - OpenAI *ttsProviderSaveRequest `json:"openai,omitempty"` - ElevenLabs *ttsProviderSaveRequest `json:"elevenlabs,omitempty"` - MiniMax *ttsProviderSaveRequest `json:"minimax,omitempty"` + Provider string `json:"provider"` + Auto string `json:"auto"` + Mode string `json:"mode"` + MaxLength int `json:"max_length"` + TimeoutMs int `json:"timeout_ms"` + OpenAI *ttsProviderSaveRequest `json:"openai,omitempty"` + ElevenLabs *ttsProviderSaveRequest `json:"elevenlabs,omitempty"` + Edge *ttsProviderSaveRequest `json:"edge,omitempty"` + MiniMax *ttsProviderSaveRequest `json:"minimax,omitempty"` } type ttsProviderSaveRequest struct { APIKey string `json:"api_key,omitempty"` APIBase string `json:"api_base,omitempty"` + BaseURL string `json:"base_url,omitempty"` Voice string `json:"voice,omitempty"` + VoiceID string `json:"voice_id,omitempty"` Model string `json:"model,omitempty"` + ModelID string `json:"model_id,omitempty"` GroupID string `json:"group_id,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Rate string `json:"rate,omitempty"` } // handleSave saves TTS config for the current tenant. @@ -171,50 +206,132 @@ func (h *TTSConfigHandler) handleSave(w http.ResponseWriter, r *http.Request) { // Save to system_configs (non-secrets) if h.systemConfigs != nil { if req.Provider != "" { - h.systemConfigs.Set(ctx, "tts.provider", req.Provider) + if err := h.systemConfigs.Set(ctx, "tts.provider", req.Provider); err != nil { + slog.Error("tts.config: failed to save provider", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save provider: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } if req.Auto != "" { - h.systemConfigs.Set(ctx, "tts.auto", req.Auto) + if err := h.systemConfigs.Set(ctx, "tts.auto", req.Auto); err != nil { + slog.Error("tts.config: failed to save auto", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save auto: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } if req.Mode != "" { - h.systemConfigs.Set(ctx, "tts.mode", req.Mode) + if err := h.systemConfigs.Set(ctx, "tts.mode", req.Mode); err != nil { + slog.Error("tts.config: failed to save mode", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save mode: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } if req.MaxLength > 0 { - h.systemConfigs.Set(ctx, "tts.max_length", fmt.Sprintf("%d", req.MaxLength)) + if err := h.systemConfigs.Set(ctx, "tts.max_length", fmt.Sprintf("%d", req.MaxLength)); err != nil { + slog.Error("tts.config: failed to save max_length", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save max_length: %s"}`, err.Error()), http.StatusInternalServerError) + return + } + } + if req.TimeoutMs > 0 { + if err := h.systemConfigs.Set(ctx, "tts.timeout_ms", fmt.Sprintf("%d", req.TimeoutMs)); err != nil { + slog.Error("tts.config: failed to save timeout_ms", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save timeout_ms: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } // Provider-specific non-secrets if req.OpenAI != nil { - if req.OpenAI.APIBase != "" { - h.systemConfigs.Set(ctx, "tts.openai.api_base", req.OpenAI.APIBase) + if apiBase := req.OpenAI.resolvedAPIBase(); apiBase != "" { + if err := h.systemConfigs.Set(ctx, "tts.openai.api_base", apiBase); err != nil { + slog.Error("tts.config: failed to save openai api_base", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save openai api_base: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } - if req.OpenAI.Voice != "" { - h.systemConfigs.Set(ctx, "tts.openai.voice", req.OpenAI.Voice) + if voice := req.OpenAI.resolvedVoice(); voice != "" { + if err := h.systemConfigs.Set(ctx, "tts.openai.voice", voice); err != nil { + slog.Error("tts.config: failed to save openai voice", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save openai voice: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } - if req.OpenAI.Model != "" { - h.systemConfigs.Set(ctx, "tts.openai.model", req.OpenAI.Model) + if model := req.OpenAI.resolvedModel(); model != "" { + if err := h.systemConfigs.Set(ctx, "tts.openai.model", model); err != nil { + slog.Error("tts.config: failed to save openai model", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save openai model: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } } if req.ElevenLabs != nil { - if req.ElevenLabs.APIBase != "" { - h.systemConfigs.Set(ctx, "tts.elevenlabs.api_base", req.ElevenLabs.APIBase) + if apiBase := req.ElevenLabs.resolvedAPIBase(); apiBase != "" { + if err := h.systemConfigs.Set(ctx, "tts.elevenlabs.api_base", apiBase); err != nil { + slog.Error("tts.config: failed to save elevenlabs api_base", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save elevenlabs api_base: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } - if req.ElevenLabs.Voice != "" { - h.systemConfigs.Set(ctx, "tts.elevenlabs.voice", req.ElevenLabs.Voice) + if voice := req.ElevenLabs.resolvedVoice(); voice != "" { + if err := h.systemConfigs.Set(ctx, "tts.elevenlabs.voice", voice); err != nil { + slog.Error("tts.config: failed to save elevenlabs voice", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save elevenlabs voice: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } - if req.ElevenLabs.Model != "" { - h.systemConfigs.Set(ctx, "tts.elevenlabs.model", req.ElevenLabs.Model) + if model := req.ElevenLabs.resolvedModel(); model != "" { + if err := h.systemConfigs.Set(ctx, "tts.elevenlabs.model", model); err != nil { + slog.Error("tts.config: failed to save elevenlabs model", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save elevenlabs model: %s"}`, err.Error()), http.StatusInternalServerError) + return + } + } + } + if req.Edge != nil { + if voice := req.Edge.resolvedVoice(); voice != "" { + if err := h.systemConfigs.Set(ctx, "tts.edge.voice", voice); err != nil { + slog.Error("tts.config: failed to save edge voice", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save edge voice: %s"}`, err.Error()), http.StatusInternalServerError) + return + } + } + if req.Edge.Rate != "" { + if err := h.systemConfigs.Set(ctx, "tts.edge.rate", req.Edge.Rate); err != nil { + slog.Error("tts.config: failed to save edge rate", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save edge rate: %s"}`, err.Error()), http.StatusInternalServerError) + return + } + } + if req.Edge.Enabled != nil { + if err := h.systemConfigs.Set(ctx, "tts.edge.enabled", fmt.Sprintf("%t", *req.Edge.Enabled)); err != nil { + slog.Error("tts.config: failed to save edge enabled", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save edge enabled: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } } if req.MiniMax != nil { - if req.MiniMax.APIBase != "" { - h.systemConfigs.Set(ctx, "tts.minimax.api_base", req.MiniMax.APIBase) + if apiBase := req.MiniMax.resolvedAPIBase(); apiBase != "" { + if err := h.systemConfigs.Set(ctx, "tts.minimax.api_base", apiBase); err != nil { + slog.Error("tts.config: failed to save minimax api_base", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save minimax api_base: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } - if req.MiniMax.Voice != "" { - h.systemConfigs.Set(ctx, "tts.minimax.voice", req.MiniMax.Voice) + if voice := req.MiniMax.resolvedVoice(); voice != "" { + if err := h.systemConfigs.Set(ctx, "tts.minimax.voice", voice); err != nil { + slog.Error("tts.config: failed to save minimax voice", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save minimax voice: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } - if req.MiniMax.Model != "" { - h.systemConfigs.Set(ctx, "tts.minimax.model", req.MiniMax.Model) + if model := req.MiniMax.resolvedModel(); model != "" { + if err := h.systemConfigs.Set(ctx, "tts.minimax.model", model); err != nil { + slog.Error("tts.config: failed to save minimax model", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save minimax model: %s"}`, err.Error()), http.StatusInternalServerError) + return + } } } } @@ -223,23 +340,31 @@ func (h *TTSConfigHandler) handleSave(w http.ResponseWriter, r *http.Request) { if h.configSecrets != nil { if req.OpenAI != nil && req.OpenAI.APIKey != "" && req.OpenAI.APIKey != "***" { if err := h.configSecrets.Set(ctx, "tts.openai.api_key", req.OpenAI.APIKey); err != nil { - slog.Warn("tts.config: failed to save openai api_key", "error", err) + slog.Error("tts.config: failed to save openai api_key", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save openai api_key: %s"}`, err.Error()), http.StatusInternalServerError) + return } } if req.ElevenLabs != nil && req.ElevenLabs.APIKey != "" && req.ElevenLabs.APIKey != "***" { if err := h.configSecrets.Set(ctx, "tts.elevenlabs.api_key", req.ElevenLabs.APIKey); err != nil { - slog.Warn("tts.config: failed to save elevenlabs api_key", "error", err) + slog.Error("tts.config: failed to save elevenlabs api_key", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save elevenlabs api_key: %s"}`, err.Error()), http.StatusInternalServerError) + return } } if req.MiniMax != nil { if req.MiniMax.APIKey != "" && req.MiniMax.APIKey != "***" { if err := h.configSecrets.Set(ctx, "tts.minimax.api_key", req.MiniMax.APIKey); err != nil { - slog.Warn("tts.config: failed to save minimax api_key", "error", err) + slog.Error("tts.config: failed to save minimax api_key", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save minimax api_key: %s"}`, err.Error()), http.StatusInternalServerError) + return } } if req.MiniMax.GroupID != "" { if err := h.configSecrets.Set(ctx, "tts.minimax.group_id", req.MiniMax.GroupID); err != nil { - slog.Warn("tts.config: failed to save minimax group_id", "error", err) + slog.Error("tts.config: failed to save minimax group_id", "error", err) + http.Error(w, fmt.Sprintf(`{"error":"save minimax group_id: %s"}`, err.Error()), http.StatusInternalServerError) + return } } } @@ -272,7 +397,7 @@ func NewTenantTTSResolver(sc store.SystemConfigStore, cs store.ConfigSecretsStor } // Build ephemeral provider from tenant config - req := testConnectionRequest{Provider: providerName} + req := testConnectionRequest{Provider: providerName, TimeoutMs: loadTenantTTSTimeoutMs(ctx, sc)} switch providerName { case "openai": @@ -308,6 +433,7 @@ func NewTenantTTSResolver(sc store.SystemConfigStore, cs store.ConfigSecretsStor case "edge": req.VoiceID, _ = sc.Get(ctx, "tts.edge.voice") + req.Rate, _ = sc.Get(ctx, "tts.edge.rate") default: return nil, "", "", fmt.Errorf("unsupported provider: %s", providerName) @@ -321,3 +447,33 @@ func NewTenantTTSResolver(sc store.SystemConfigStore, cs store.ConfigSecretsStor return provider, providerName, auto, nil } } + +func (r *ttsProviderSaveRequest) resolvedAPIBase() string { + if r == nil { + return "" + } + if r.APIBase != "" { + return r.APIBase + } + return r.BaseURL +} + +func (r *ttsProviderSaveRequest) resolvedVoice() string { + if r == nil { + return "" + } + if r.Voice != "" { + return r.Voice + } + return r.VoiceID +} + +func (r *ttsProviderSaveRequest) resolvedModel() string { + if r == nil { + return "" + } + if r.Model != "" { + return r.Model + } + return r.ModelID +} diff --git a/internal/http/tts_config_test.go b/internal/http/tts_config_test.go new file mode 100644 index 00000000..e21dcbc0 --- /dev/null +++ b/internal/http/tts_config_test.go @@ -0,0 +1,237 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type validationSystemConfigStore struct { + mu sync.Mutex + data map[string]string + setErr error + setKeys []string +} + +func (s *validationSystemConfigStore) Get(ctx context.Context, key string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.data[key], nil +} + +func (s *validationSystemConfigStore) Set(ctx context.Context, key, value string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.setKeys = append(s.setKeys, key) + if s.setErr != nil { + return s.setErr + } + if s.data == nil { + s.data = map[string]string{} + } + s.data[key] = value + return nil +} + +func (s *validationSystemConfigStore) Delete(ctx context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.data, key) + return nil +} + +func (s *validationSystemConfigStore) List(ctx context.Context) (map[string]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[string]string, len(s.data)) + for k, v := range s.data { + out[k] = v + } + return out, nil +} + +type validationSecretsStore struct { + mu sync.Mutex + data map[string]string +} + +func (s *validationSecretsStore) Get(ctx context.Context, key string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.data[key], nil +} + +func (s *validationSecretsStore) Set(ctx context.Context, key, value string) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.data == nil { + s.data = map[string]string{} + } + s.data[key] = value + return nil +} + +func (s *validationSecretsStore) Delete(ctx context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.data, key) + return nil +} + +func (s *validationSecretsStore) GetAll(ctx context.Context) (map[string]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := make(map[string]string, len(s.data)) + for k, v := range s.data { + out[k] = v + } + return out, nil +} + +func newValidationTTSConfigMux(sc store.SystemConfigStore, cs store.ConfigSecretsStore) *http.ServeMux { + h := NewTTSConfigHandler(sc, cs) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + return mux +} + +func TestTTSConfigSave_AcceptsLegacyAndUISchemaAliases(t *testing.T) { + setupTestToken(t, "") + + sc := &validationSystemConfigStore{data: map[string]string{}} + cs := &validationSecretsStore{data: map[string]string{}} + mux := newValidationTTSConfigMux(sc, cs) + + req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{ + "provider": "elevenlabs", + "timeout_ms": 1234, + "elevenlabs": map[string]string{ + "base_url": "https://custom.elevenlabs.invalid", + "voice_id": "voice-123", + "model_id": "model-123", + "api_key": "xi-test", + }, + "edge": map[string]any{ + "voice": "en-US-AvaMultilingualNeural", + "rate": "+15%", + "enabled": false, + }, + })) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) + } + if got := sc.data["tts.elevenlabs.api_base"]; got != "https://custom.elevenlabs.invalid" { + t.Fatalf("want elevenlabs api_base persisted, got %q", got) + } + if got := sc.data["tts.elevenlabs.voice"]; got != "voice-123" { + t.Fatalf("want elevenlabs voice persisted, got %q", got) + } + if got := sc.data["tts.elevenlabs.model"]; got != "model-123" { + t.Fatalf("want elevenlabs model persisted, got %q", got) + } + if got := sc.data["tts.timeout_ms"]; got != "1234" { + t.Fatalf("want timeout_ms persisted, got %q", got) + } + if got := sc.data["tts.edge.voice"]; got != "en-US-AvaMultilingualNeural" { + t.Fatalf("want edge voice persisted, got %q", got) + } + if got := sc.data["tts.edge.rate"]; got != "+15%" { + t.Fatalf("want edge rate persisted, got %q", got) + } + if got := sc.data["tts.edge.enabled"]; got != "false" { + t.Fatalf("want edge enabled persisted, got %q", got) + } + if got := cs.data["tts.elevenlabs.api_key"]; got != "xi-test" { + t.Fatalf("want elevenlabs api_key persisted, got %q", got) + } +} + +func TestTTSConfigGet_RoundTripsCompatibilityFields(t *testing.T) { + setupTestToken(t, "") + + sc := &validationSystemConfigStore{data: map[string]string{ + "tts.provider": "elevenlabs", + "tts.timeout_ms": "1234", + "tts.elevenlabs.api_base": "https://custom.elevenlabs.invalid", + "tts.elevenlabs.voice": "voice-123", + "tts.elevenlabs.model": "model-123", + "tts.edge.voice": "en-US-AvaMultilingualNeural", + "tts.edge.rate": "+15%", + "tts.edge.enabled": "false", + }} + cs := &validationSecretsStore{data: map[string]string{}} + mux := newValidationTTSConfigMux(sc, cs) + + req := httptest.NewRequest("GET", "/v1/tts/config", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", rr.Code, rr.Body.String()) + } + var body map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if got, _ := body["timeout_ms"].(float64); got != 1234 { + t.Fatalf("want timeout_ms 1234, got %v", body["timeout_ms"]) + } + elevenlabs, _ := body["elevenlabs"].(map[string]any) + if got, _ := elevenlabs["api_base"].(string); got != "https://custom.elevenlabs.invalid" { + t.Fatalf("want elevenlabs api_base, got %q", got) + } + if got, _ := elevenlabs["base_url"].(string); got != "https://custom.elevenlabs.invalid" { + t.Fatalf("want elevenlabs base_url alias, got %q", got) + } + if got, _ := elevenlabs["voice_id"].(string); got != "voice-123" { + t.Fatalf("want elevenlabs voice_id alias, got %q", got) + } + if got, _ := elevenlabs["model_id"].(string); got != "model-123" { + t.Fatalf("want elevenlabs model_id alias, got %q", got) + } + edge, _ := body["edge"].(map[string]any) + if got, _ := edge["voice"].(string); got != "en-US-AvaMultilingualNeural" { + t.Fatalf("want edge voice, got %q", got) + } + if got, _ := edge["rate"].(string); got != "+15%" { + t.Fatalf("want edge rate, got %q", got) + } + if got, ok := edge["enabled"].(bool); !ok || got { + t.Fatalf("want edge enabled=false, got %#v", edge["enabled"]) + } +} + +func TestTTSConfigSave_WriteFailuresReturnError(t *testing.T) { + setupTestToken(t, "") + + sc := &validationSystemConfigStore{data: map[string]string{}, setErr: errors.New("boom")} + cs := &validationSecretsStore{data: map[string]string{}} + mux := newValidationTTSConfigMux(sc, cs) + + req := httptest.NewRequest("POST", "/v1/tts/config", ttsBody(t, map[string]any{ + "provider": "openai", + "openai": map[string]string{ + "api_base": "https://example.invalid/v1", + }, + })) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("want 500, got %d: %s", rr.Code, rr.Body.String()) + } + if rr.Body.String() == "{\"ok\":true}\n" { + t.Fatalf("unexpected success body: %q", rr.Body.String()) + } +} diff --git a/internal/http/tts_test_connection.go b/internal/http/tts_test_connection.go index ea24ee09..fec16af2 100644 --- a/internal/http/tts_test_connection.go +++ b/internal/http/tts_test_connection.go @@ -7,6 +7,7 @@ import ( "fmt" "log/slog" "net/http" + "strconv" "strings" "time" @@ -21,12 +22,15 @@ import ( // 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"` - VoiceID string `json:"voice_id,omitempty"` - ModelID string `json:"model_id,omitempty"` - GroupID string `json:"group_id,omitempty"` // MiniMax requires group_id + 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"` } // testConnectionResponse is the JSON response for POST /v1/tts/test-connection. @@ -97,6 +101,9 @@ func (h *TTSHandler) handleTestConnection(w http.ResponseWriter, r *http.Request return } + if req.APIBase == "" { + req.APIBase = req.BaseURL + } // Create ephemeral provider. provider, err := createEphemeralTTSProvider(req) if err != nil { @@ -142,31 +149,51 @@ func createEphemeralTTSProvider(req testConnectionRequest) (audio.TTSProvider, e switch req.Provider { case "openai": return openai.NewProvider(openai.Config{ - APIKey: req.APIKey, - APIBase: req.APIBase, - Model: req.ModelID, - Voice: req.VoiceID, + 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, + 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, + 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, + APIKey: req.APIKey, + APIBase: req.APIBase, + GroupID: req.GroupID, + VoiceID: req.VoiceID, + Model: req.ModelID, + TimeoutMs: req.TimeoutMs, }), nil default: return nil, fmt.Errorf("unsupported provider: %s", req.Provider) } } + +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 +}