feat(tools): tenant-aware TTS primary provider resolution

TTS tool now reads per-tenant primary provider override from
BuiltinToolSettingsFromCtx before falling back to the manager's
default. Same per-call resolution pattern as web_search/web_fetch.

Simpler path A approach (no pool) — tenant can select which
registered provider their agents use. Per-tenant API keys deferred
to a future pool implementation if needed.

- Add resolvePrimary(ctx, mgr) with ttsOverride struct
- Refactor Execute to use resolved primary with fallback chain
- 5 new unit tests for tenant provider override scenarios
This commit is contained in:
viettranx
2026-04-12 11:22:14 +07:00
parent def1712f3b
commit 43ee918b9e
2 changed files with 120 additions and 2 deletions
+40 -2
View File
@@ -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 {
+80
View File
@@ -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)
}
}