mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 10:15:48 +00:00
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:
1 parent
544b6c6532
commit
93ce4fa9d9
4 files changed
+490
-69
No files matched your search
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user