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>
This commit is contained in:
nguyenha935andnguyenha935 authored and GitHub committed 2026-06-25 12:38:49 +07:00
1 parent 1a607c3e6b
commit 9e97031099
2 files changed
+212 -18

No files matched your search

+53 -18
View File
@@ -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)
}
}
}
+159
View File
@@ -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)
}
}