From 9e97031099db9d8edbf4d566c2d3f6b3ba51b8d3 Mon Sep 17 00:00:00 2001 From: nguyenha935 Date: Thu, 25 Jun 2026 12:38:49 +0700 Subject: [PATCH] fix(tts): route agent tool through tenant provider (#1277) * fix(tts): route agent tool through tenant provider * fix(tts): preserve fallback after tenant provider failure --------- Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> --- internal/tools/tts.go | 71 ++++++--- internal/tools/tts_tenant_provider_test.go | 159 +++++++++++++++++++++ 2 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 internal/tools/tts_tenant_provider_test.go diff --git a/internal/tools/tts.go b/internal/tools/tts.go index 7202a443..0ca014aa 100644 --- a/internal/tools/tts.go +++ b/internal/tools/tts.go @@ -70,7 +70,7 @@ func (t *TtsTool) Parameters() map[string]any { }, "provider": map[string]any{ "type": "string", - "description": "TTS provider: openai, elevenlabs, edge, minimax. Optional — uses primary if omitted.", + "description": "TTS provider: openai, elevenlabs, edge, minimax, gemini. Optional — uses configured primary if omitted.", }, }, "required": []string{"text"}, @@ -88,11 +88,11 @@ type ttsOverride struct { // agentAudioConfig is the JSON shape read from AgentAudioSnapshot.OtherConfig // for per-agent TTS tuning. Keys match the agents.other_config column. type agentAudioConfig struct { - TTSVoiceID string `json:"tts_voice_id,omitempty"` - TTSModelID string `json:"tts_model_id,omitempty"` + TTSVoiceID string `json:"tts_voice_id,omitempty"` + TTSModelID string `json:"tts_model_id,omitempty"` // TTSParams carries the per-agent generic TTS override keys (speed, emotion, style). // Stored as generic keys; AdaptAgentParams converts to provider-specific keys per attempt. - TTSParams map[string]any `json:"tts_params,omitempty"` + TTSParams map[string]any `json:"tts_params,omitempty"` } // resolveVoiceAndModel computes the effective voice + model IDs for the @@ -170,6 +170,23 @@ func (t *TtsTool) resolvePrimary(ctx context.Context, mgr *tts.Manager) string { return mgr.PrimaryProvider() } +// resolveTenantProvider returns the DB-configured TTS provider for this request. +// When requestedProvider is non-empty, only the matching tenant provider is +// accepted so an explicit provider never silently routes elsewhere. +func (t *TtsTool) resolveTenantProvider(ctx context.Context, mgr *tts.Manager, requestedProvider string) (audio.TTSProvider, string, bool) { + if mgr == nil { + return nil, "", false + } + p, name, _, ok := mgr.ResolveTenantProvider(ctx) + if !ok || p == nil || name == "" { + return nil, "", false + } + if requestedProvider != "" && requestedProvider != name { + return nil, "", false + } + return p, name, true +} + // resolveAgentGenericTTSParams reads the per-agent TTSParams generic map from // the dispatcher-injected AgentAudioSnapshot. Returns nil when no snapshot // is present or no tts_params are configured. The caller is responsible for @@ -240,31 +257,49 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result { // Adapt generic agent params to this specific provider's native keys. p, ok := mgr.GetProvider(providerName) if !ok { - return &Result{ForLLM: fmt.Sprintf("error: tts provider not found: %s", providerName), IsError: true} + var tenantName string + p, tenantName, ok = t.resolveTenantProvider(ctx, mgr, providerName) + if !ok { + return &Result{ForLLM: fmt.Sprintf("error: tts provider not found: %s", providerName), IsError: true} + } + providerName = tenantName } if adapted := audio.AdaptAgentParams(genericAgentParams, providerName); len(adapted) > 0 { opts.Params = mergeParams(opts.Params, adapted) } result, err = p.Synthesize(ctx, text, opts) } else { - // Resolve primary from tenant settings or default. - primary := t.resolvePrimary(ctx, mgr) - if p, ok := mgr.GetProvider(primary); ok { - // Adapt for the primary provider attempt specifically. - primaryOpts := opts - if adapted := audio.AdaptAgentParams(genericAgentParams, primary); len(adapted) > 0 { - primaryOpts.Params = mergeParams(opts.Params, adapted) + // Prefer the DB-configured tenant provider used by /tts and auto-TTS. + if p, tenantName, ok := t.resolveTenantProvider(ctx, mgr, ""); ok { + tenantOpts := opts + if adapted := audio.AdaptAgentParams(genericAgentParams, tenantName); len(adapted) > 0 { + tenantOpts.Params = mergeParams(opts.Params, adapted) } - result, err = p.Synthesize(ctx, text, primaryOpts) + result, err = p.Synthesize(ctx, text, tenantOpts) if err != nil { - slog.Warn("tts primary provider failed, trying fallback", "provider", primary, "error", err) - // SynthesizeWithFallbackAdapted adapts genericAgentParams per-attempt - // (Finding #1 CRITICAL): each fallback provider receives its own - // provider-native keys, not the primary's adapted map. + slog.Warn("tts tenant provider failed, trying fallback", "provider", tenantName, "error", err) result, err = mgr.SynthesizeWithFallbackAdapted(ctx, text, opts, genericAgentParams) } } else { - result, err = mgr.SynthesizeWithFallbackAdapted(ctx, text, opts, genericAgentParams) + // Resolve primary from tenant settings or default. + primary := t.resolvePrimary(ctx, mgr) + if p, ok := mgr.GetProvider(primary); ok { + // Adapt for the primary provider attempt specifically. + primaryOpts := opts + if adapted := audio.AdaptAgentParams(genericAgentParams, primary); len(adapted) > 0 { + primaryOpts.Params = mergeParams(opts.Params, adapted) + } + result, err = p.Synthesize(ctx, text, primaryOpts) + if err != nil { + slog.Warn("tts primary provider failed, trying fallback", "provider", primary, "error", err) + // SynthesizeWithFallbackAdapted adapts genericAgentParams per-attempt + // (Finding #1 CRITICAL): each fallback provider receives its own + // provider-native keys, not the primary's adapted map. + result, err = mgr.SynthesizeWithFallbackAdapted(ctx, text, opts, genericAgentParams) + } + } else { + result, err = mgr.SynthesizeWithFallbackAdapted(ctx, text, opts, genericAgentParams) + } } } diff --git a/internal/tools/tts_tenant_provider_test.go b/internal/tools/tts_tenant_provider_test.go new file mode 100644 index 00000000..b3f47ee7 --- /dev/null +++ b/internal/tools/tts_tenant_provider_test.go @@ -0,0 +1,159 @@ +package tools + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/audio" + "github.com/nextlevelbuilder/goclaw/internal/tts" +) + +type tenantRoutingTTSProvider struct { + name string + calls int + err error +} + +func (p *tenantRoutingTTSProvider) Name() string { return p.name } + +func (p *tenantRoutingTTSProvider) Synthesize(_ context.Context, _ string, _ tts.Options) (*tts.SynthResult, error) { + p.calls++ + if p.err != nil { + return nil, p.err + } + return &tts.SynthResult{Audio: []byte("audio"), Extension: "mp3", MimeType: "audio/mpeg"}, nil +} + +func newTenantRoutingManager(primary string, providers ...*tenantRoutingTTSProvider) *tts.Manager { + mgr := tts.NewManager(tts.ManagerConfig{Primary: primary}) + for _, provider := range providers { + mgr.RegisterTTS(provider) + } + return mgr +} + +func TestTtsTool_UsesTenantProviderWhenProviderOmitted(t *testing.T) { + t.Parallel() + + edgeProvider := &tenantRoutingTTSProvider{name: "edge"} + geminiProvider := &tenantRoutingTTSProvider{name: "gemini"} + mgr := newTenantRoutingManager("edge", edgeProvider) + mgr.SetTenantResolver(func(context.Context) (audio.TTSProvider, string, audio.AutoMode, error) { + return geminiProvider, "gemini", audio.AutoOff, nil + }) + + tool := NewTtsTool(mgr) + result := tool.Execute(context.Background(), map[string]any{ + "text": "hello from tenant provider", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if geminiProvider.calls != 1 { + t.Fatalf("tenant gemini calls = %d, want 1", geminiProvider.calls) + } + if edgeProvider.calls != 0 { + t.Fatalf("global edge calls = %d, want 0", edgeProvider.calls) + } +} + +func TestTtsTool_ExplicitTenantProviderWhenMissingFromGlobalManager(t *testing.T) { + t.Parallel() + + edgeProvider := &tenantRoutingTTSProvider{name: "edge"} + geminiProvider := &tenantRoutingTTSProvider{name: "gemini"} + mgr := newTenantRoutingManager("edge", edgeProvider) + mgr.SetTenantResolver(func(context.Context) (audio.TTSProvider, string, audio.AutoMode, error) { + return geminiProvider, "gemini", audio.AutoOff, nil + }) + + tool := NewTtsTool(mgr) + result := tool.Execute(context.Background(), map[string]any{ + "text": "hello from explicit tenant provider", + "provider": "gemini", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if geminiProvider.calls != 1 { + t.Fatalf("tenant gemini calls = %d, want 1", geminiProvider.calls) + } + if edgeProvider.calls != 0 { + t.Fatalf("global edge calls = %d, want 0", edgeProvider.calls) + } +} + +func TestTtsTool_TenantProviderFailureFallsBackWhenProviderOmitted(t *testing.T) { + t.Parallel() + + edgeProvider := &tenantRoutingTTSProvider{name: "edge"} + geminiProvider := &tenantRoutingTTSProvider{name: "gemini", err: errors.New("tenant unavailable")} + mgr := newTenantRoutingManager("edge", edgeProvider) + mgr.SetTenantResolver(func(context.Context) (audio.TTSProvider, string, audio.AutoMode, error) { + return geminiProvider, "gemini", audio.AutoOff, nil + }) + + tool := NewTtsTool(mgr) + result := tool.Execute(context.Background(), map[string]any{ + "text": "hello with tenant fallback", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if geminiProvider.calls != 1 { + t.Fatalf("tenant gemini calls = %d, want 1", geminiProvider.calls) + } + if edgeProvider.calls != 1 { + t.Fatalf("global edge calls = %d, want 1", edgeProvider.calls) + } +} + +func TestTtsTool_ExplicitProviderMismatchStillErrors(t *testing.T) { + t.Parallel() + + geminiProvider := &tenantRoutingTTSProvider{name: "gemini"} + mgr := newTenantRoutingManager("edge", &tenantRoutingTTSProvider{name: "edge"}) + mgr.SetTenantResolver(func(context.Context) (audio.TTSProvider, string, audio.AutoMode, error) { + return geminiProvider, "gemini", audio.AutoOff, nil + }) + + tool := NewTtsTool(mgr) + result := tool.Execute(context.Background(), map[string]any{ + "text": "hello from mismatched provider", + "provider": "openai", + }) + + if !result.IsError { + t.Fatalf("expected error, got %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "tts provider not found: openai") { + t.Fatalf("error = %q, want provider not found for openai", result.ForLLM) + } + if geminiProvider.calls != 0 { + t.Fatalf("tenant gemini calls = %d, want 0", geminiProvider.calls) + } +} + +func TestTtsTool_GlobalFallbackStillWorksWithoutTenantProvider(t *testing.T) { + t.Parallel() + + edgeProvider := &tenantRoutingTTSProvider{name: "edge"} + mgr := newTenantRoutingManager("edge", edgeProvider) + + tool := NewTtsTool(mgr) + result := tool.Execute(context.Background(), map[string]any{ + "text": "hello from global provider", + }) + + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if edgeProvider.calls != 1 { + t.Fatalf("global edge calls = %d, want 1", edgeProvider.calls) + } +}