Restore TTS config compatibility and fail closed on save errors (#938)

The new per-tenant TTS config surface diverged from the established TTS
schema after the audio refactor, dropping UI alias fields and root-level
settings during round-trip. It also returned ok=true even when non-secret
system config writes failed. This patch accepts backward-compatible field
aliases, round-trips timeout and Edge settings, reuses tenant timeout/rate
when building ephemeral providers, and returns errors on failed saves.

Constraint: Must preserve the existing TTS config schema expected by dashboard flows and legacy config docs
Rejected: Fix only the UI payload shape | backend would still break legacy aliases and direct API callers
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: When extending per-tenant config handlers, keep request/response structs aligned with the established config schema and do not ignore store write errors
Related: #935
Related: #936
Tested: go test ./internal/http -run 'Test(TTSConfig|TestConnection_)' -count=1 -v
Tested: go build ./...
Tested: go build -tags sqliteonly ./...
Not-tested: Full repo race test suite
This commit is contained in:
Junghwan authored and GitHub committed 2026-04-17 20:04:16 +07:00
1 parent 544b6c6532
commit 93ce4fa9d9
4 files changed
+490 -69

No files matched your search

+6 -5
View File
@@ -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)
+200 -44
View File
@@ -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
}
+237
View File
@@ -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())
}
}
+47 -20
View File
@@ -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
}