feat(tts): wire tenant timeout + fix Gemini text-only 400

- HTTP synthesize + test-connection now read tenant tts.timeout_ms
  (default 120s, was hardcoded 15s/10s). Gemini client default also
  bumped 30s→120s so both layers align when tenant config unset.
- Inline prefix "Speak naturally: " prepended to single-voice text;
  multi-speaker transcripts pass through unchanged.
- ErrTextOnlyResponse sentinel for 400 "text generation" bodies;
  single-voice retries once with stronger prefix. Narrowed needle
  list avoids false positives on unrelated 400s.
- SynthesizeWithFallbackAdapted now returns errors.Join so sentinel
  survives fallback chain; HTTP 422 mapping + locale-translated
  ForLLM in agent tool (EN/VI/ZH catalogs).
- Default Gemini model bumped to gemini-3.1-flash-tts-preview.
This commit is contained in:
viettranx
2026-04-23 08:31:53 +07:00
parent 4d6ebe9c58
commit 04a9938f4f
24 changed files with 891 additions and 28 deletions
+2 -1
View File
@@ -100,7 +100,7 @@ old clients see flat keys; new clients see the full params blob.
### Gemini Specifics
- Models: `gemini-2.5-flash-preview-tts`, `gemini-2.5-pro-preview-tts` (preview).
- Models: `gemini-3.1-flash-tts-preview` (default), `gemini-2.5-flash-preview-tts`, `gemini-2.5-pro-preview-tts` (preview).
- Multi-speaker: up to 2 simultaneous speakers, each with distinct voice + name annotation.
- Audio tags: inline `<say-as>` / style directives via bracketed prompts.
- Sentinel errors: `ErrInvalidVoice`, `ErrInvalidModel`, `ErrSpeakerLimit` → HTTP 422 with i18n message.
@@ -143,6 +143,7 @@ Native `image_generation` support in the Codex provider (`POST /codex/responses`
## Key Conventions
- **Store layer:** Interface-based; PG (`store/pg/`) + SQLite (`store/sqlitestore/`). Raw SQL, `$1/$2` params.
- **Session token display:** v3 compaction now uses dynamic max_tokens; session token display reads from `sessions.metadata.last_prompt_tokens`.
- **Context propagation:** `store.WithLocale`, `store.WithUserID`, `store.WithTenantID`, etc.
- **Security logs:** `slog.Warn("security.*")` for all security events.
- **SSRF prevention:** `validateProviderURL()` in `internal/http/tts_validate.go`.
+23
View File
@@ -37,6 +37,29 @@ Implementation is evidence-backed against the native ChatGPT Responses API event
## 2026-04-20
### TTS: timeout tenant-config + Gemini text-only 400 fix
**Features & Fixes**
- **Tenant-config timeout:** HTTP `/v1/tts/synthesize` and `/v1/tts/test-connection` now read `tts.timeout_ms` from system_configs (default 120s, was hardcoded 15s/10s). Gemini client default bumped 30s→120s for end-to-end alignment.
- **Gemini text-only error recovery:** Gemini preview models occasionally emit 400 "text generation" responses. Fixed by: (1) prepending inline prefix `"Speak naturally: "` to single-voice synthesis (multi-speaker untouched), (2) 1-retry with stronger prefix `"Read the following text aloud without translating, commenting, or modifying: "`, (3) new sentinel `gemini.ErrTextOnlyResponse` preserved through fallback chain via `errors.Join`.
- **Error UX:** HTTP returns 422 with localized `MsgTtsGeminiTextOnly` message. Agent TTS tool branches on sentinel to emit locale-translated ForLLM response.
- **Model default:** Gemini default model bumped `gemini-2.5-flash-preview-tts``gemini-3.1-flash-tts-preview` for higher stability.
- **UI bounds:** TTS timeout input now has `max=300000` (5 min).
**i18n**
- New key `MsgTtsGeminiTextOnly` in EN/VI/ZH catalogs for HTTP 422 + agent-tool ForLLM mapping.
**Code**
- `internal/audio/tts.go` — read tenant timeout in synthesize handlers.
- `internal/audio/gemini/` — inline prefix logic, retry budget, text-only sentinel.
- `internal/tools/tts.go` — agent-tool i18n branching on sentinel.
- `internal/http/methods/tts.go` — HTTP 422 error mapping.
---
### Tools: `send_file` — explicit workspace file delivery
**Features**
+1 -1
View File
@@ -323,9 +323,9 @@ For each `ParamSchema`, add:
Gemini TTS uses preview models only (as of 2026-04):
- `gemini-3.1-flash-tts-preview` (**default** — higher Elo, more stable)
- `gemini-2.5-flash-preview-tts`
- `gemini-2.5-pro-preview-tts`
- `gemini-3.1-flash-tts-preview`
The frontend displays a "Preview" badge (i18n key `tts.gemini.previewBadge`).
+1 -1
View File
@@ -25,7 +25,7 @@ func newClient(apiKey, apiBase string, timeoutMs int) *client {
base = defaultAPIBase
}
if timeoutMs <= 0 {
timeoutMs = 30000
timeoutMs = 120000 // match handler default; tenant Config.TimeoutMs=0 → 120s (was 30s)
}
return &client{apiKey: apiKey, apiBase: base, timeoutMs: timeoutMs}
}
+19
View File
@@ -21,3 +21,22 @@ func TestBuildURL_TrimsTrailingSlash(t *testing.T) {
t.Errorf("buildURL = %q, want %q", got, want)
}
}
// TestProviderClient_DefaultTimeoutIs120s pins the validation-locked decision that
// the Gemini HTTP client defaults to 120000ms when timeoutMs<=0, matching the handler
// default. Without this alignment, unset tenant configs silently cap at 30s.
func TestProviderClient_DefaultTimeoutIs120s(t *testing.T) {
c := newClient("key", "", 0)
if c.timeoutMs != 120000 {
t.Errorf("newClient timeoutMs=0 should default to 120000, got %d", c.timeoutMs)
}
}
// TestProviderClient_ExplicitTimeoutIsHonored verifies that an explicit timeoutMs
// is preserved and not overwritten by the default.
func TestProviderClient_ExplicitTimeoutIsHonored(t *testing.T) {
c := newClient("key", "", 45000)
if c.timeoutMs != 45000 {
t.Errorf("newClient timeoutMs=45000 should stay 45000, got %d", c.timeoutMs)
}
}
+6
View File
@@ -20,4 +20,10 @@ var (
// finishReason=OTHER). These are flaky on the preview TTS endpoints and
// usually succeed on a single retry.
errTransientNoAudio = errors.New("gemini: transient no-audio response")
// ErrTextOnlyResponse is returned when Gemini TTS responds 400 indicating it
// attempted text generation rather than speech synthesis. This typically
// happens when the input is vague or contains translation/manipulation
// intent. Retryable once with a stronger prefix (see tts.go retry logic).
ErrTextOnlyResponse = errors.New("gemini: text-only response (model refused to synthesize audio)")
)
+1 -1
View File
@@ -11,7 +11,7 @@ var geminiModels = []string{
}
// defaultModel is the model used when none is specified.
const defaultModel = "gemini-2.5-flash-preview-tts"
const defaultModel = "gemini-3.1-flash-tts-preview"
// isValidModel reports whether id is in the static model catalog.
func isValidModel(id string) bool {
+1 -1
View File
@@ -3,7 +3,7 @@
{
"parts": [
{
"text": "hello"
"text": "Speak naturally: hello"
}
]
}
+86 -12
View File
@@ -7,18 +7,38 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// DefaultTextPrefix is the inline style directive prepended to user text
// for every Gemini TTS single-voice request. Gemini TTS preview models do not
// accept systemInstruction; inline prefix is the ONLY supported style control.
// See research/researcher-01-gemini-tts-api.md Q1,Q3.
const DefaultTextPrefix = "Speak naturally: "
// StrongerTextPrefix is the retry prefix used after a 400 "text generation"
// response. Explicitly forbids translation/commentary to force TTS-only mode.
const StrongerTextPrefix = "Read the following text aloud without translating, commenting, or modifying: "
// BuildStyledText prepends prefix to text. Empty prefix returns text unchanged.
// Exported for retry logic that may use a stronger prefix (Phase 03).
func BuildStyledText(prefix, text string) string {
if prefix == "" {
return text
}
return prefix + text
}
// Config bundles credentials and TTS defaults for Google Gemini.
type Config struct {
APIKey string
APIBase string // custom endpoint (optional); must pass validateProviderURL
Voice string // default "Kore"
Model string // default "gemini-2.5-flash-preview-tts"
TimeoutMs int // default 30000
Model string // default "gemini-3.1-flash-tts-preview"
TimeoutMs int // default 120000
}
// Provider implements audio.TTSProvider and audio.DescribableProvider for Gemini.
@@ -124,21 +144,33 @@ func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOp
generationConfig["frequencyPenalty"] = fp
}
reqBody := map[string]any{
"contents": []map[string]any{
{"parts": []map[string]any{{"text": text}}},
},
"generationConfig": generationConfig,
// Phase 02 gating: multi-speaker keeps raw transcript; single-voice gets prefix.
isSingleVoice := len(opts.Speakers) == 0
// buildBody constructs the request JSON with the given style prefix.
// Multi-speaker mode ignores prefix — raw transcript is passed unchanged.
buildBody := func(prefix string) ([]byte, error) {
sendText := text
if isSingleVoice {
sendText = BuildStyledText(prefix, text)
}
rb := map[string]any{
"contents": []map[string]any{
{"parts": []map[string]any{{"text": sendText}}},
},
"generationConfig": generationConfig,
}
return json.Marshal(rb)
}
bodyBytes, err := json.Marshal(reqBody)
bodyBytes, err := buildBody(DefaultTextPrefix)
if err != nil {
return nil, fmt.Errorf("gemini: marshal request: %w", err)
}
// Single retry on transient no-audio responses (finishReason=OTHER) — the
// preview TTS endpoint is flaky and usually succeeds on the second try.
// Anything else (auth, rate limit, safety, invalid model) is returned as-is.
// Retry logic — two independent retry branches, mutually exclusive:
// 1. errTransientNoAudio (200 OK, finishReason=OTHER): retry with SAME body.
// 2. ErrTextOnlyResponse (400 text-only): retry with STRONGER prefix body (single-voice only).
res, err := p.requestAudio(ctx, model, bodyBytes)
if err != nil && errors.Is(err, errTransientNoAudio) {
select {
@@ -146,7 +178,19 @@ func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOp
return nil, ctx.Err()
case <-time.After(retryBackoff):
}
res, err = p.requestAudio(ctx, model, bodyBytes)
res, err = p.requestAudio(ctx, model, bodyBytes) // SAME body
} else if err != nil && errors.Is(err, ErrTextOnlyResponse) && isSingleVoice {
// Multi-speaker + text-only → return sentinel unretried; caller decides.
strongerBody, bErr := buildBody(StrongerTextPrefix)
if bErr != nil {
return nil, fmt.Errorf("gemini: marshal retry request: %w", bErr)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryBackoff):
}
res, err = p.requestAudio(ctx, model, strongerBody) // NEW body with stronger prefix
}
return res, err
}
@@ -171,6 +215,13 @@ func (p *Provider) requestAudio(ctx context.Context, model string, bodyBytes []b
case http.StatusTooManyRequests:
return nil, fmt.Errorf("gemini: rate limit exceeded (429)")
}
if isTextOnlyError(status, respBytes) {
snippet := string(respBytes)
if len(snippet) > 200 {
snippet = snippet[:200] + "…"
}
return nil, fmt.Errorf("%w: %s", ErrTextOnlyResponse, snippet)
}
if status != http.StatusOK {
return nil, fmt.Errorf("gemini: unexpected status %d: %s", status, string(respBytes))
}
@@ -296,6 +347,29 @@ func resolveGeminiIntExplicit(params map[string]any, key string) (int, bool) {
return 0, false
}
// isTextOnlyError returns true when the response is an HTTP 400 whose body
// suggests the model returned text instead of audio. Case-insensitive
// substring match on known Gemini error phrasings. Needles are kept narrow to
// avoid false positives on unrelated "generate text" errors.
func isTextOnlyError(status int, body []byte) bool {
if status != http.StatusBadRequest || len(body) == 0 {
return false
}
lower := strings.ToLower(string(body))
for _, needle := range []string{
"model tried to generate text", // exact phrase from user bug report
"returned text", // "returned text when audio was expected"
"text instead of audio",
"text-only",
"text output",
} {
if strings.Contains(lower, needle) {
return true
}
}
return false
}
// isTransientFinishReason reports whether a Gemini finishReason represents a
// non-deterministic failure that's worth retrying. OTHER is the catch-all the
// preview TTS endpoint emits when it just fails to produce audio for no
+271 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
@@ -116,8 +117,9 @@ func TestSynthesize_SingleVoice_RequestShape(t *testing.T) {
part0 := contents[0].(map[string]any)
parts, _ := part0["parts"].([]any)
text, _ := parts[0].(map[string]any)["text"].(string)
if text != "Hello world" {
t.Errorf("text = %q, want Hello world", text)
wantText := DefaultTextPrefix + "Hello world"
if text != wantText {
t.Errorf("text = %q, want %q", text, wantText)
}
// result
@@ -146,6 +148,17 @@ func TestSynthesize_MultiSpeaker_RequestShape(t *testing.T) {
t.Fatalf("Synthesize error: %v", err)
}
// Verify transcript passed through unchanged — no inline prefix in multi-speaker mode.
contents, _ := cap.body["contents"].([]any)
if len(contents) == 0 {
t.Fatal("contents empty")
}
msparts, _ := contents[0].(map[string]any)["parts"].([]any)
mstext, _ := msparts[0].(map[string]any)["text"].(string)
if mstext != "Joe: Hi\nJane: Hello" {
t.Errorf("multi-speaker text = %q, want %q (no prefix)", mstext, "Joe: Hi\nJane: Hello")
}
gc, _ := cap.body["generationConfig"].(map[string]any)
sc, _ := gc["speechConfig"].(map[string]any)
if _, hasRoot := cap.body["speechConfig"]; hasRoot {
@@ -365,3 +378,259 @@ func TestSynthesize_BadBase64(t *testing.T) {
t.Fatal("expected base64 decode error")
}
}
// TestSynthesize_PrependsInlinePrefix verifies the inline style prefix is prepended
// to user text in contents[0].parts[0].text for single-voice synthesis.
func TestSynthesize_PrependsInlinePrefix(t *testing.T) {
pcm := make([]byte, 64)
b64 := base64.StdEncoding.EncodeToString(pcm)
srv, cap := newMockServer(t, http.StatusOK, geminiResponseWith(b64))
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
if _, err := p.Synthesize(context.Background(), "hello", audio.TTSOptions{}); err != nil {
t.Fatalf("Synthesize error: %v", err)
}
contents, _ := cap.body["contents"].([]any)
if len(contents) == 0 {
t.Fatal("contents empty")
}
parts, _ := contents[0].(map[string]any)["parts"].([]any)
text, _ := parts[0].(map[string]any)["text"].(string)
want := DefaultTextPrefix + "hello"
if text != want {
t.Errorf("text = %q, want %q (prefix must be prepended)", text, want)
}
}
// TestBuildStyledText verifies BuildStyledText pure helper behaviour.
func TestBuildStyledText(t *testing.T) {
cases := []struct {
prefix, text, want string
}{
{"Say: ", "hi", "Say: hi"},
{"", "hi", "hi"},
{"P: ", "", "P: "},
}
for _, c := range cases {
got := BuildStyledText(c.prefix, c.text)
if got != c.want {
t.Errorf("BuildStyledText(%q, %q) = %q, want %q", c.prefix, c.text, got, c.want)
}
}
}
// TestSynthesize_Returns_ErrTextOnlyResponse_On400 verifies that a 400 with
// text-only phrasing is detected and returned as ErrTextOnlyResponse.
// Both calls return 400 (retry also fails); final error must match sentinel.
func TestSynthesize_Returns_ErrTextOnlyResponse_On400(t *testing.T) {
body := []byte(`{"error":{"message":"The model returned text when audio was expected","code":400}}`)
srv, _ := newMockServer(t, http.StatusBadRequest, body)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
_, err := p.Synthesize(context.Background(), "x", audio.TTSOptions{})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrTextOnlyResponse) {
t.Errorf("got %v, want ErrTextOnlyResponse", err)
}
}
// TestSynthesize_Retries_With_StrongerPrefix_On_TextOnly400 verifies that on a
// 400 text-only error the second call uses StrongerTextPrefix and succeeds.
func TestSynthesize_Retries_With_StrongerPrefix_On_TextOnly400(t *testing.T) {
pcm := make([]byte, 64)
b64 := base64.StdEncoding.EncodeToString(pcm)
successBody := geminiResponseWith(b64)
textOnlyBody := []byte(`{"error":{"message":"returned text instead of audio","code":400}}`)
var calls int
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
bodies = append(bodies, b)
if calls == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(textOnlyBody)
} else {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(successBody)
}
}))
t.Cleanup(srv.Close)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
_, err := p.Synthesize(context.Background(), "hello", audio.TTSOptions{})
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if calls != 2 {
t.Errorf("expected 2 calls, got %d", calls)
}
extractText := func(b map[string]any) string {
contents, _ := b["contents"].([]any)
if len(contents) == 0 {
return ""
}
parts, _ := contents[0].(map[string]any)["parts"].([]any)
if len(parts) == 0 {
return ""
}
text, _ := parts[0].(map[string]any)["text"].(string)
return text
}
want1 := DefaultTextPrefix + "hello"
if got := extractText(bodies[0]); got != want1 {
t.Errorf("call1 text = %q, want %q", got, want1)
}
want2 := StrongerTextPrefix + "hello"
if got := extractText(bodies[1]); got != want2 {
t.Errorf("call2 text = %q, want %q", got, want2)
}
}
// TestIsTextOnlyError is a table-driven unit test for the isTextOnlyError helper.
func TestIsTextOnlyError(t *testing.T) {
cases := []struct {
status int
body string
want bool
}{
{400, `{"error":{"message":"returned text when audio was expected"}}`, true},
{400, `{"error":{"message":"The model tried to generate text"}}`, true}, // case-insensitive
{400, `{"error":{"message":"got text instead of audio"}}`, true},
{400, `{"error":{"message":"unable to generate text in format"}}`, false}, // bare "generate text" not in list
{400, `{"error":{"message":"rate limit"}}`, false},
{400, `{"error":{"message":"invalid voice"}}`, false},
{400, `not-json`, false}, // no substring match
{500, `{"error":{"message":"returned text"}}`, false}, // only 400
{400, ``, false}, // empty
{400, `{"error":{"message":"text-only output detected"}}`, true},
{400, `{"error":{"message":"text output returned"}}`, true},
}
for _, c := range cases {
got := isTextOnlyError(c.status, []byte(c.body))
if got != c.want {
t.Errorf("isTextOnlyError(%d, %q) = %v, want %v", c.status, c.body, got, c.want)
}
}
}
// TestSynthesize_Generic400_Unchanged verifies non-text-only 400 errors do not
// match ErrTextOnlyResponse and still surface "unexpected status 400".
func TestSynthesize_Generic400_Unchanged(t *testing.T) {
body := []byte(`{"error":{"message":"invalid voice name"}}`)
srv, _ := newMockServer(t, http.StatusBadRequest, body)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
_, err := p.Synthesize(context.Background(), "x", audio.TTSOptions{})
if err == nil {
t.Fatal("expected error")
}
if errors.Is(err, ErrTextOnlyResponse) {
t.Errorf("non-text-only 400 should not match ErrTextOnlyResponse")
}
if !strings.Contains(err.Error(), "unexpected status 400") {
t.Errorf("error %q should contain 'unexpected status 400'", err.Error())
}
}
// TestSynthesize_RetryRespectsContextCancel verifies that context cancellation
// during the retry backoff aborts without issuing a second request.
func TestSynthesize_RetryRespectsContextCancel(t *testing.T) {
textOnlyBody := []byte(`{"error":{"message":"returned text when audio was expected","code":400}}`)
var calls int
// firstCallDone is closed after the first request handler returns,
// so the test can cancel ctx immediately after the first call completes.
firstCallDone := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(textOnlyBody)
// Signal after first call and cancel immediately so backoff sees ctx.Done().
if calls == 1 {
close(firstCallDone)
cancel()
}
}))
t.Cleanup(srv.Close)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
_, err := p.Synthesize(ctx, "x", audio.TTSOptions{})
if err == nil {
t.Fatal("expected error")
}
if calls != 1 {
t.Errorf("expected 1 call (no retry after cancel), got %d", calls)
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
// TestSynthesize_MultiSpeaker_TextOnly_NotRetried verifies that multi-speaker
// mode returns ErrTextOnlyResponse unretried (exactly 1 call, no stronger prefix retry).
func TestSynthesize_MultiSpeaker_TextOnly_NotRetried(t *testing.T) {
body := []byte(`{"error":{"message":"returned text when audio was expected","code":400}}`)
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
opts := audio.TTSOptions{
Speakers: []audio.SpeakerVoice{
{Speaker: "Joe", VoiceID: "Kore"},
{Speaker: "Jane", VoiceID: "Puck"},
},
}
_, err := p.Synthesize(context.Background(), "Joe: Hi\nJane: Hello", opts)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrTextOnlyResponse) {
t.Errorf("got %v, want ErrTextOnlyResponse", err)
}
if calls != 1 {
t.Errorf("multi-speaker must not retry: expected 1 call, got %d", calls)
}
}
// TestSynthesize_MultiSpeaker_NoPrefix pins the invariant that multi-speaker
// transcripts pass through unchanged — no inline prefix applied.
func TestSynthesize_MultiSpeaker_NoPrefix(t *testing.T) {
pcm := make([]byte, 64)
b64 := base64.StdEncoding.EncodeToString(pcm)
srv, cap := newMockServer(t, http.StatusOK, geminiResponseWith(b64))
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
opts := audio.TTSOptions{
Speakers: []audio.SpeakerVoice{
{Speaker: "Joe", VoiceID: "Kore"},
{Speaker: "Jane", VoiceID: "Puck"},
},
}
transcript := "Joe: Hi\nJane: Hello"
if _, err := p.Synthesize(context.Background(), transcript, opts); err != nil {
t.Fatalf("Synthesize error: %v", err)
}
contents, _ := cap.body["contents"].([]any)
if len(contents) == 0 {
t.Fatal("contents empty")
}
parts, _ := contents[0].(map[string]any)["parts"].([]any)
text, _ := parts[0].(map[string]any)["text"].(string)
if text != transcript {
t.Errorf("multi-speaker text = %q, want %q (prefix must NOT apply)", text, transcript)
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ package audio
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
@@ -303,12 +304,14 @@ func (m *Manager) SynthesizeWithFallback(ctx context.Context, text string, opts
// genericAgentParams must use the generic allow-list keys (speed, emotion, style).
// Passing nil is safe and produces the same behaviour as SynthesizeWithFallback.
func (m *Manager) SynthesizeWithFallbackAdapted(ctx context.Context, text string, opts TTSOptions, genericAgentParams map[string]any) (*SynthResult, error) {
var providerErrs []error
if p, ok := m.ttsProviders[m.primary]; ok {
attemptOpts := m.withAdaptedParams(opts, m.primary, genericAgentParams)
if result, err := p.Synthesize(ctx, text, attemptOpts); err == nil {
return result, nil
} else {
slog.Warn("tts primary provider failed, trying fallback", "provider", m.primary, "error", err)
providerErrs = append(providerErrs, fmt.Errorf("%s: %w", m.primary, err))
}
}
for name, p := range m.ttsProviders {
@@ -322,8 +325,13 @@ func (m *Manager) SynthesizeWithFallbackAdapted(ctx context.Context, text string
return result, nil
}
slog.Warn("tts fallback provider failed", "provider", name, "error", err)
providerErrs = append(providerErrs, fmt.Errorf("%s: %w", name, err))
}
return nil, fmt.Errorf("all tts providers failed")
if len(providerErrs) == 0 {
return nil, fmt.Errorf("no tts providers registered")
}
// errors.Join preserves all sentinel errors so errors.Is(err, sentinel) works downstream.
return nil, errors.Join(providerErrs...)
}
// withAdaptedParams returns a copy of opts with genericAgentParams adapted
@@ -0,0 +1,65 @@
package audio_test
import (
"context"
"errors"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
)
// mockSentinelTTS returns a configurable error from Synthesize.
type mockSentinelTTS struct {
providerName string
err error
}
func (m *mockSentinelTTS) Name() string { return m.providerName }
func (m *mockSentinelTTS) Synthesize(_ context.Context, _ string, _ audio.TTSOptions) (*audio.SynthResult, error) {
return nil, m.err
}
// TestSynthesizeWithFallbackAdapted_PreservesTextOnlySentinel verifies that
// ErrTextOnlyResponse survives through SynthesizeWithFallbackAdapted so that
// errors.Is(err, gemini.ErrTextOnlyResponse) returns true at the call site.
func TestSynthesizeWithFallbackAdapted_PreservesTextOnlySentinel(t *testing.T) {
t.Run("primary_only_returns_sentinel", func(t *testing.T) {
// Single provider: primary returns ErrTextOnlyResponse. No fallback.
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "gemini",
err: gemini.ErrTextOnlyResponse,
})
_, err := mgr.SynthesizeWithFallbackAdapted(context.Background(), "hello", audio.TTSOptions{}, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, gemini.ErrTextOnlyResponse) {
t.Errorf("errors.Is(err, ErrTextOnlyResponse) = false; err = %v", err)
}
})
t.Run("primary_sentinel_plus_fallback_other_error", func(t *testing.T) {
// Primary returns ErrTextOnlyResponse; fallback returns a different error.
// Sentinel must survive errors.Join.
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "gemini",
err: gemini.ErrTextOnlyResponse,
})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "openai",
err: errors.New("openai: connection refused"),
})
_, err := mgr.SynthesizeWithFallbackAdapted(context.Background(), "hello", audio.TTSOptions{}, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, gemini.ErrTextOnlyResponse) {
t.Errorf("errors.Is(err, ErrTextOnlyResponse) = false after errors.Join; err = %v", err)
}
})
}
+15 -5
View File
@@ -71,9 +71,9 @@ type synthesizeRequest struct {
}
const (
maxSynthesizeBodyBytes = 4 << 10 // 4KB — enough for 500 chars + metadata
maxSynthesizeTextChars = 500
synthesizeTimeout = 15 * time.Second
maxSynthesizeBodyBytes = 4 << 10 // 4KB — enough for 500 chars + metadata
maxSynthesizeTextChars = 500
defaultSynthesizeTimeoutMs = 120000 // 120s default; tenant tts.timeout_ms overrides
)
// handleSynthesize serves POST /v1/tts/synthesize.
@@ -169,8 +169,12 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
}
}
// Synthesize with a 15-second deadline.
synthCtx, cancel := context.WithTimeout(ctx, synthesizeTimeout)
// Synthesize with tenant-configured deadline; fall back to 120s default.
timeoutMs := loadTenantTTSTimeoutMs(ctx, h.systemConfigs)
if timeoutMs <= 0 {
timeoutMs = defaultSynthesizeTimeoutMs
}
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutMs)*time.Millisecond)
defer cancel()
opts := audio.TTSOptions{Voice: req.VoiceID, Model: req.ModelID, Params: tenantParams}
@@ -208,6 +212,12 @@ func (h *TTSHandler) handleSynthesize(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity)
return
}
if errors.Is(err, gemini.ErrTextOnlyResponse) {
slog.Warn("tts.synthesize.text-only", "provider", name, "error", err)
msg := i18n.T(locale, i18n.MsgTtsGeminiTextOnly)
http.Error(w, fmt.Sprintf(`{"error":%q}`, msg), http.StatusUnprocessableEntity)
return
}
// Surface upstream error to caller — opaque "upstream synthesis failed"
// makes the test playground useless for debugging provider config.
slog.Warn("tts.synthesize.failed", "provider", name, "error", err)
+69
View File
@@ -392,6 +392,75 @@ func TestSynthesize_ValidElevenLabsModel(t *testing.T) {
}
}
// TestSynthesize_TextOnlyErrorMappedTo422 verifies that ErrTextOnlyResponse
// is mapped to HTTP 422 with the EN i18n message in the response body.
func TestSynthesize_TextOnlyErrorMappedTo422(t *testing.T) {
setupTestToken(t, "") // dev mode
mock := &mockTTSProvider{
name: "gemini",
stateless: true,
err: fmt.Errorf("wrap: %w", geminiPkg.ErrTextOnlyResponse),
}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterProvider(mock)
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]any{"text": "hello", "provider": "gemini"}))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
got, _ := resp["error"].(string)
want := i18n.T("en", i18n.MsgTtsGeminiTextOnly)
if got != want {
t.Errorf("want error %q, got %q", want, got)
}
}
// TestSynthesize_TextOnly_LocaleVI verifies that the VI locale translation
// is returned when Accept-Language: vi is set.
func TestSynthesize_TextOnly_LocaleVI(t *testing.T) {
setupTestToken(t, "") // dev mode
mock := &mockTTSProvider{
name: "gemini",
stateless: true,
err: fmt.Errorf("wrap: %w", geminiPkg.ErrTextOnlyResponse),
}
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterProvider(mock)
mux := newTTSMux(mgr)
req := httptest.NewRequest("POST", "/v1/tts/synthesize",
ttsBody(t, map[string]any{"text": "hello", "provider": "gemini"}))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", "vi")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusUnprocessableEntity {
t.Fatalf("want 422, got %d: %s", rr.Code, rr.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
got, _ := resp["error"].(string)
want := i18n.T("vi", i18n.MsgTtsGeminiTextOnly)
if got != want {
t.Errorf("want VI error %q, got %q", want, got)
}
}
// TestSynthesize_GeminiInvalidVoice_I18n verifies that 422 responses for
// Gemini ErrInvalidVoice use i18n.T(locale, ...) — not err.Error() — so
// VI and ZH callers receive translated messages (M2-b carry-over).
+17 -3
View File
@@ -60,7 +60,7 @@ var providersRequiringAPIKey = map[string]bool{
"gemini": true,
}
const testConnectionTimeout = 10 * time.Second
const defaultTestConnectionTimeoutMs = 120000 // 120s default; req.TimeoutMs > tenant > default
// handleTestConnection serves POST /v1/tts/test-connection.
// Creates an ephemeral provider from request credentials and tests synthesis.
@@ -127,8 +127,15 @@ func (h *TTSHandler) handleTestConnection(w http.ResponseWriter, r *http.Request
return
}
// Synthesize short test text.
synthCtx, cancel := context.WithTimeout(ctx, testConnectionTimeout)
// Synthesize short test text — req.TimeoutMs overrides tenant which overrides default 120s.
effectiveMs := req.TimeoutMs
if effectiveMs <= 0 {
effectiveMs = loadTenantTTSTimeoutMs(ctx, h.systemConfigs)
}
if effectiveMs <= 0 {
effectiveMs = defaultTestConnectionTimeoutMs
}
synthCtx, cancel := context.WithTimeout(ctx, time.Duration(effectiveMs)*time.Millisecond)
defer cancel()
start := time.Now()
@@ -165,6 +172,13 @@ func (h *TTSHandler) handleTestConnection(w http.ResponseWriter, r *http.Request
})
return
}
if errors.Is(err, gemini.ErrTextOnlyResponse) {
slog.Warn("tts.test-connection.text-only", "provider", req.Provider, "error", err)
writeJSON(w, http.StatusUnprocessableEntity, testConnectionResponse{
Success: false, Error: i18n.T(locale, i18n.MsgTtsGeminiTextOnly),
})
return
}
// Surface upstream error to caller — test-connection is a diagnostic
// endpoint, opacity here just makes debugging harder.
slog.Warn("tts.test-connection.failed", "provider", req.Provider, "error", err)
+209
View File
@@ -0,0 +1,209 @@
package http
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// sleepingTTSProvider is a test-only TTS provider that sleeps for a configurable
// duration before returning, used to exercise handler timeout paths.
type sleepingTTSProvider struct {
sleepMs int
}
func (s *sleepingTTSProvider) Name() string { return "sleep" }
func (s *sleepingTTSProvider) Synthesize(ctx context.Context, text string, opts audio.TTSOptions) (*audio.SynthResult, error) {
select {
case <-time.After(time.Duration(s.sleepMs) * time.Millisecond):
return &audio.SynthResult{Audio: []byte("ok"), MimeType: "audio/mpeg"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// stubSystemConfigStore returns configured values for "tts.timeout_ms" and ignores others.
type stubSystemConfigStore struct {
timeoutMsValue string // raw string returned for "tts.timeout_ms"
}
func (s *stubSystemConfigStore) Get(_ context.Context, key string) (string, error) {
if key == "tts.timeout_ms" {
return s.timeoutMsValue, nil
}
return "", nil
}
func (s *stubSystemConfigStore) Set(_ context.Context, _, _ string) error { return nil }
func (s *stubSystemConfigStore) Delete(_ context.Context, _ string) error { return nil }
func (s *stubSystemConfigStore) List(_ context.Context) (map[string]string, error) {
return map[string]string{}, nil
}
// newTTSMuxWithStore builds a TTSHandler backed by mgr and systemConfigs, wires routes.
func newTTSMuxWithStore(mgr *audio.Manager, sc *stubSystemConfigStore) *http.ServeMux {
h := NewTTSHandler(mgr)
h.SetStores(sc, nil)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
return mux
}
// synthRequestBody builds the POST /v1/tts/synthesize JSON body.
func synthRequestBody(t *testing.T, text string) *bytes.Buffer {
t.Helper()
b, _ := json.Marshal(map[string]string{"text": text})
return bytes.NewBuffer(b)
}
// --- Synthesize timeout tests ---
// TestSynthesize_UsesTenantTimeoutMs verifies the handler applies tts.timeout_ms from
// tenant config. Backend sleeps 1000ms with tenant timeout=500ms → expect 504.
// Backend sleeps 100ms with tenant timeout=500ms → expect 200.
func TestSynthesize_UsesTenantTimeoutMs(t *testing.T) {
setupTestToken(t, "") // dev mode — no auth required
sc := &stubSystemConfigStore{timeoutMsValue: "500"}
// Slow path: backend sleeps 1000ms, tenant timeout 500ms → 504.
provider := &sleepingTTSProvider{sleepMs: 1000}
mgr := audio.NewManager(audio.ManagerConfig{})
mgr.RegisterTTS(provider)
mux := newTTSMuxWithStore(mgr, sc)
req := httptest.NewRequest("POST", "/v1/tts/synthesize", synthRequestBody(t, "hello"))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusGatewayTimeout {
t.Errorf("want 504 (tenant timeout 500ms, backend sleeps 1000ms), got %d: %s", rr.Code, rr.Body.String())
}
// Fast path: backend sleeps 100ms, tenant timeout 500ms → 200.
provider2 := &sleepingTTSProvider{sleepMs: 100}
mgr2 := audio.NewManager(audio.ManagerConfig{})
mgr2.RegisterTTS(provider2)
mux2 := newTTSMuxWithStore(mgr2, sc)
req2 := httptest.NewRequest("POST", "/v1/tts/synthesize", synthRequestBody(t, "hello"))
req2.Header.Set("Content-Type", "application/json")
rr2 := httptest.NewRecorder()
mux2.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Errorf("want 200 (tenant timeout 500ms, backend sleeps 100ms), got %d: %s", rr2.Code, rr2.Body.String())
}
}
// TestSynthesize_DefaultTimeoutWhenTenantUnset verifies that when tts.timeout_ms is
// unset, the handler uses defaultSynthesizeTimeoutMs (>=120000ms, not old 15s).
func TestSynthesize_DefaultTimeoutWhenTenantUnset(t *testing.T) {
setupTestToken(t, "") // dev mode
sc := &stubSystemConfigStore{timeoutMsValue: ""} // no tenant timeout
provider := &sleepingTTSProvider{sleepMs: 100}
mgr := audio.NewManager(audio.ManagerConfig{})
mgr.RegisterTTS(provider)
mux := newTTSMuxWithStore(mgr, sc)
req := httptest.NewRequest("POST", "/v1/tts/synthesize", synthRequestBody(t, "hello"))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("want 200 (default timeout unset, backend fast), got %d: %s", rr.Code, rr.Body.String())
}
// Assert the default constant is >=120000ms (not the old 15s).
if defaultSynthesizeTimeoutMs < 120000 {
t.Errorf("defaultSynthesizeTimeoutMs must be >=120000, got %d", defaultSynthesizeTimeoutMs)
}
}
// TestSynthesize_TenantTimeoutInvalidFallsBackToDefault verifies that an invalid
// (non-numeric) tts.timeout_ms falls back to the 120s default and allows fast backends.
func TestSynthesize_TenantTimeoutInvalidFallsBackToDefault(t *testing.T) {
setupTestToken(t, "") // dev mode
sc := &stubSystemConfigStore{timeoutMsValue: "abc"} // invalid
provider := &sleepingTTSProvider{sleepMs: 100}
mgr := audio.NewManager(audio.ManagerConfig{})
mgr.RegisterTTS(provider)
mux := newTTSMuxWithStore(mgr, sc)
req := httptest.NewRequest("POST", "/v1/tts/synthesize", synthRequestBody(t, "hello"))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("want 200 (invalid tenant value → default, backend fast), got %d: %s", rr.Code, rr.Body.String())
}
}
// --- Test-connection timeout resolution tests ---
// TestTestConnection_ReqTimeoutOverridesTenant verifies that a non-zero req.TimeoutMs
// overrides the tenant config value (req=500, tenant=5000 → effective=500).
func TestTestConnection_ReqTimeoutOverridesTenant(t *testing.T) {
sc := &stubSystemConfigStore{timeoutMsValue: "5000"} // tenant=5000ms
tenantMs := loadTenantTTSTimeoutMs(context.Background(), sc)
if tenantMs != 5000 {
t.Fatalf("precondition: tenant timeout should be 5000, got %d", tenantMs)
}
// Simulate handler priority: req.TimeoutMs > 0 → use req value.
reqTimeoutMs := 500
effectiveMs := reqTimeoutMs
if effectiveMs <= 0 {
effectiveMs = tenantMs
}
if effectiveMs <= 0 {
effectiveMs = defaultTestConnectionTimeoutMs
}
if effectiveMs != 500 {
t.Errorf("effectiveMs should be 500 (req override), got %d", effectiveMs)
}
}
// TestTestConnection_TenantFallbackWhenReqZero verifies that when req.TimeoutMs=0,
// the handler falls back to the saved tenant config value (tenant=800 → effective=800).
func TestTestConnection_TenantFallbackWhenReqZero(t *testing.T) {
sc := &stubSystemConfigStore{timeoutMsValue: "800"} // tenant=800ms
tenantMs := loadTenantTTSTimeoutMs(context.Background(), sc)
if tenantMs != 800 {
t.Fatalf("precondition: tenant timeout should be 800, got %d", tenantMs)
}
// Simulate handler priority: req.TimeoutMs=0 → fall back to tenant.
reqTimeoutMs := 0
effectiveMs := reqTimeoutMs
if effectiveMs <= 0 {
effectiveMs = tenantMs
}
if effectiveMs <= 0 {
effectiveMs = defaultTestConnectionTimeoutMs
}
if effectiveMs != 800 {
t.Errorf("effectiveMs should be 800 (tenant fallback), got %d", effectiveMs)
}
}
+1
View File
@@ -204,6 +204,7 @@ func init() {
MsgTtsGeminiInvalidVoice: "invalid Gemini voice: %s",
MsgTtsGeminiSpeakerLimit: "Gemini TTS supports at most 2 speakers",
MsgTtsGeminiInvalidModel: "invalid Gemini TTS model: %s",
MsgTtsGeminiTextOnly: "Gemini refused to generate audio. Try simpler text without translation or commentary.",
MsgTtsParamOutOfRange: "TTS param %q value %v is out of range [%v, %v]",
MsgTtsParamUnknownKey: "TTS param %q is not supported by this provider",
MsgTtsMiniMaxVoicesFailed: "failed to fetch MiniMax voices: %s",
+1
View File
@@ -204,6 +204,7 @@ func init() {
MsgTtsGeminiInvalidVoice: "giọng đọc Gemini không hợp lệ: %s",
MsgTtsGeminiSpeakerLimit: "Gemini TTS hỗ trợ tối đa 2 người nói",
MsgTtsGeminiInvalidModel: "mô hình Gemini TTS không hợp lệ: %s",
MsgTtsGeminiTextOnly: "Gemini từ chối tạo âm thanh. Vui lòng thử văn bản đơn giản hơn, không dịch hay bình luận.",
MsgTtsParamOutOfRange: "tham số TTS %q có giá trị %v nằm ngoài phạm vi [%v, %v]",
MsgTtsParamUnknownKey: "tham số TTS %q không được nhà cung cấp này hỗ trợ",
MsgTtsMiniMaxVoicesFailed: "không tải được danh sách giọng đọc MiniMax: %s",
+1
View File
@@ -204,6 +204,7 @@ func init() {
MsgTtsGeminiInvalidVoice: "无效的 Gemini 声音:%s",
MsgTtsGeminiSpeakerLimit: "Gemini TTS 最多支持 2 位发言人",
MsgTtsGeminiInvalidModel: "无效的 Gemini TTS 模型:%s",
MsgTtsGeminiTextOnly: "Gemini 拒绝生成音频。请尝试更简单的文本,不要翻译或添加评论。",
MsgTtsParamOutOfRange: "TTS 参数 %q 的值 %v 超出范围 [%v, %v]",
MsgTtsParamUnknownKey: "TTS 参数 %q 不受此提供商支持",
MsgTtsMiniMaxVoicesFailed: "获取 MiniMax 声音列表失败:%s",
+1
View File
@@ -202,6 +202,7 @@ const (
MsgTtsGeminiInvalidVoice = "error.tts_gemini_invalid_voice" // "invalid Gemini voice: %s"
MsgTtsGeminiSpeakerLimit = "error.tts_gemini_speaker_limit" // "Gemini TTS supports at most 2 speakers"
MsgTtsGeminiInvalidModel = "error.tts_gemini_invalid_model" // "invalid Gemini TTS model: %s"
MsgTtsGeminiTextOnly = "error.tts_gemini_text_only" // "Gemini refused to generate audio; try simpler text without translation or commentary"
MsgTtsParamOutOfRange = "error.tts_param_out_of_range" // "TTS param %q value %v is out of range [%v, %v]"
MsgTtsParamUnknownKey = "error.tts_param_unknown_key" // "TTS param %q is not supported by this provider"
MsgTtsMiniMaxVoicesFailed = "error.tts_minimax_voices_failed" // "failed to fetch MiniMax voices: %s"
@@ -0,0 +1,21 @@
package i18n
import "testing"
// TestI18nKey_TtsGeminiTextOnly_AllCatalogs verifies that MsgTtsGeminiTextOnly
// is present in all three locale catalogs and returns a translated string
// (not the key literal itself).
func TestI18nKey_TtsGeminiTextOnly_AllCatalogs(t *testing.T) {
locales := []string{LocaleEN, LocaleVI, LocaleZH}
for _, locale := range locales {
t.Run(locale, func(t *testing.T) {
got := T(locale, MsgTtsGeminiTextOnly)
if got == "" {
t.Errorf("locale %q: T returned empty string for MsgTtsGeminiTextOnly", locale)
}
if got == MsgTtsGeminiTextOnly {
t.Errorf("locale %q: T returned key literal %q — key missing from catalog", locale, MsgTtsGeminiTextOnly)
}
})
}
}
+8
View File
@@ -3,6 +3,7 @@ package tools
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"maps"
@@ -14,7 +15,9 @@ import (
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tts"
)
@@ -266,6 +269,11 @@ func (t *TtsTool) Execute(ctx context.Context, args map[string]any) *Result {
}
if err != nil {
if errors.Is(err, gemini.ErrTextOnlyResponse) {
locale := store.LocaleFromContext(ctx)
msg := i18n.T(locale, i18n.MsgTtsGeminiTextOnly)
return &Result{ForLLM: "error: " + msg, IsError: true}
}
return &Result{ForLLM: fmt.Sprintf("error: tts failed: %s", err.Error()), IsError: true}
}
+62
View File
@@ -0,0 +1,62 @@
package tools
import (
"context"
"errors"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tts"
)
// stubTextOnlyProvider returns ErrTextOnlyResponse on every Synthesize call.
type stubTextOnlyProvider struct{ name string }
func (s *stubTextOnlyProvider) Name() string { return s.name }
func (s *stubTextOnlyProvider) Synthesize(_ context.Context, _ string, _ audio.TTSOptions) (*audio.SynthResult, error) {
return nil, gemini.ErrTextOnlyResponse
}
// TestTtsTool_TextOnlyErrorMappedToLocale verifies that when the underlying
// provider returns ErrTextOnlyResponse the tool result:
// - IsError == true
// - ForLLM contains the locale-appropriate i18n translation (not raw "all tts providers failed")
func TestTtsTool_TextOnlyErrorMappedToLocale(t *testing.T) {
for _, tc := range []struct {
locale string
wantSubstr string
}{
{locale: "en", wantSubstr: i18n.T("en", i18n.MsgTtsGeminiTextOnly)},
{locale: "vi", wantSubstr: i18n.T("vi", i18n.MsgTtsGeminiTextOnly)},
} {
t.Run("locale="+tc.locale, func(t *testing.T) {
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterTTS(&stubTextOnlyProvider{name: "gemini"})
tool := NewTtsTool((*tts.Manager)(mgr))
ctx := store.WithLocale(context.Background(), tc.locale)
result := tool.Execute(ctx, map[string]any{"text": "hello"})
if result == nil {
t.Fatal("Execute returned nil")
}
if !result.IsError {
t.Error("expected IsError=true")
}
if !strings.Contains(result.ForLLM, tc.wantSubstr) {
t.Errorf("ForLLM = %q; want substring %q", result.ForLLM, tc.wantSubstr)
}
// Must NOT contain the old collapsed message.
if strings.Contains(result.ForLLM, "all tts providers failed") {
t.Errorf("ForLLM still contains old collapsed message: %q", result.ForLLM)
}
// Sentinel must be detectable from the raw error path — checked via tool returning translated msg.
_ = errors.Is(gemini.ErrTextOnlyResponse, gemini.ErrTextOnlyResponse) // compile guard
})
}
}
@@ -114,6 +114,7 @@ export function BehaviorSection({ draft, onUpdate }: Props) {
value={draft.timeout_ms}
onChange={(e) => onUpdate({ timeout_ms: Number(e.target.value) })}
min={1000}
max={300000}
/>
</div>
</div>