diff --git a/internal/tools/tts.go b/internal/tools/tts.go index 4688e1fd..535c2e63 100644 --- a/internal/tools/tts.go +++ b/internal/tools/tts.go @@ -2,7 +2,9 @@ package tools import ( "context" + "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "sync" @@ -62,6 +64,32 @@ func (t *TtsTool) Parameters() map[string]any { } } +// ttsOverride is the tenant settings shape for tts +// (stored in builtin_tool_tenant_configs.settings). +type ttsOverride struct { + Primary string `json:"primary,omitempty"` // override primary provider name +} + +// resolvePrimary returns the effective primary provider name for the request. +// Checks tenant override via BuiltinToolSettingsFromCtx first. +func (t *TtsTool) resolvePrimary(ctx context.Context, mgr *tts.Manager) string { + if settings := BuiltinToolSettingsFromCtx(ctx); settings != nil { + if raw, ok := settings["tts"]; ok && len(raw) > 0 { + var override ttsOverride + if err := json.Unmarshal(raw, &override); err != nil { + slog.Warn("tts: failed to parse tenant override, using defaults", "error", err) + } else if override.Primary != "" { + // Verify the provider exists in the manager + if _, exists := mgr.GetProvider(override.Primary); exists { + return override.Primary + } + slog.Warn("tts: tenant override references unknown provider", "primary", override.Primary) + } + } + } + return mgr.PrimaryProvider() +} + // SetContext is a no-op; channel is now read from ctx (thread-safe). func (t *TtsTool) SetContext(channel, _ string) {} @@ -90,14 +118,24 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result { var err error if providerName != "" { - // Use specific provider + // Use specific provider (explicit call param takes precedence) p, ok := mgr.GetProvider(providerName) if !ok { return &Result{ForLLM: fmt.Sprintf("error: tts provider not found: %s", providerName), IsError: true} } result, err = p.Synthesize(ctx, text, opts) } else { - result, err = mgr.SynthesizeWithFallback(ctx, text, opts) + // Resolve primary from tenant settings or default + primary := t.resolvePrimary(ctx, mgr) + if p, ok := mgr.GetProvider(primary); ok { + result, err = p.Synthesize(ctx, text, opts) + if err != nil { + slog.Warn("tts primary provider failed, trying fallback", "provider", primary, "error", err) + result, err = mgr.SynthesizeWithFallback(ctx, text, opts) + } + } else { + result, err = mgr.SynthesizeWithFallback(ctx, text, opts) + } } if err != nil { diff --git a/internal/tools/tts_override_test.go b/internal/tools/tts_override_test.go new file mode 100644 index 00000000..728e63ef --- /dev/null +++ b/internal/tools/tts_override_test.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + "encoding/json" + "testing" + + "github.com/nextlevelbuilder/goclaw/internal/tts" +) + +func makeTTSManager(providers ...string) *tts.Manager { + mgr := tts.NewManager(tts.ManagerConfig{Primary: providers[0]}) + for _, name := range providers { + mgr.RegisterProvider(&fakeTTSProvider{name: name}) + } + return mgr +} + +// fakeTTSProvider is a no-op provider for testing provider resolution. +type fakeTTSProvider struct{ name string } + +func (f *fakeTTSProvider) Name() string { return f.name } +func (f *fakeTTSProvider) Synthesize(_ context.Context, _ string, _ tts.Options) (*tts.SynthResult, error) { + return &tts.SynthResult{Audio: []byte("fake"), Extension: "mp3"}, nil +} + +func ctxWithTTSSettings(t *testing.T, override ttsOverride) context.Context { + t.Helper() + raw, err := json.Marshal(override) + if err != nil { + t.Fatal(err) + } + settings := BuiltinToolSettings{"tts": raw} + return WithBuiltinToolSettings(context.Background(), settings) +} + +func TestResolvePrimary_NoOverride(t *testing.T) { + tool := NewTtsTool(makeTTSManager("openai", "edge")) + got := tool.resolvePrimary(context.Background(), tool.manager) + if got != "openai" { + t.Errorf("got %q, want openai", got) + } +} + +func TestResolvePrimary_TenantOverride(t *testing.T) { + tool := NewTtsTool(makeTTSManager("openai", "elevenlabs", "edge")) + ctx := ctxWithTTSSettings(t, ttsOverride{Primary: "elevenlabs"}) + got := tool.resolvePrimary(ctx, tool.manager) + if got != "elevenlabs" { + t.Errorf("got %q, want elevenlabs", got) + } +} + +func TestResolvePrimary_UnknownProvider_FallsBack(t *testing.T) { + tool := NewTtsTool(makeTTSManager("openai", "edge")) + ctx := ctxWithTTSSettings(t, ttsOverride{Primary: "nonexistent"}) + got := tool.resolvePrimary(ctx, tool.manager) + if got != "openai" { + t.Errorf("got %q, want openai (fallback)", got) + } +} + +func TestResolvePrimary_MalformedJSON_FallsBack(t *testing.T) { + tool := NewTtsTool(makeTTSManager("edge")) + settings := BuiltinToolSettings{"tts": []byte(`{bad json`)} + ctx := WithBuiltinToolSettings(context.Background(), settings) + got := tool.resolvePrimary(ctx, tool.manager) + if got != "edge" { + t.Errorf("got %q, want edge (fallback)", got) + } +} + +func TestResolvePrimary_EmptyOverride_FallsBack(t *testing.T) { + tool := NewTtsTool(makeTTSManager("openai")) + ctx := ctxWithTTSSettings(t, ttsOverride{}) + got := tool.resolvePrimary(ctx, tool.manager) + if got != "openai" { + t.Errorf("got %q, want openai (default)", got) + } +}