mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 08:18:19 +00:00
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:
1 parent
1a607c3e6b
commit
9e97031099
2 files changed
+212
-18
No files matched your search
+53
-18
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user