From 04a9938f4f6500d5e1d089d32c80e8a67e31f46a Mon Sep 17 00:00:00 2001 From: viettranx Date: Mon, 20 Apr 2026 22:27:40 +0700 Subject: [PATCH] feat(tts): wire tenant timeout + fix Gemini text-only 400 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/codebase-summary.md | 3 +- docs/project-changelog.md | 23 ++ docs/tts-provider-capabilities.md | 2 +- internal/audio/gemini/client.go | 2 +- internal/audio/gemini/client_test.go | 19 ++ internal/audio/gemini/errors.go | 6 + internal/audio/gemini/models.go | 2 +- .../gemini/testdata/default_body.golden.json | 2 +- internal/audio/gemini/tts.go | 98 ++++++- internal/audio/gemini/tts_test.go | 273 +++++++++++++++++- internal/audio/manager.go | 10 +- .../audio/manager_fallback_sentinel_test.go | 65 +++++ internal/http/tts.go | 20 +- internal/http/tts_test.go | 69 +++++ internal/http/tts_test_connection.go | 20 +- internal/http/tts_timeout_test.go | 209 ++++++++++++++ internal/i18n/catalog_en.go | 1 + internal/i18n/catalog_vi.go | 1 + internal/i18n/catalog_zh.go | 1 + internal/i18n/keys.go | 1 + internal/i18n/tts_gemini_text_only_test.go | 21 ++ internal/tools/tts.go | 8 + internal/tools/tts_text_only_test.go | 62 ++++ .../pages/tts/sections/behavior-section.tsx | 1 + 24 files changed, 891 insertions(+), 28 deletions(-) create mode 100644 internal/audio/manager_fallback_sentinel_test.go create mode 100644 internal/http/tts_timeout_test.go create mode 100644 internal/i18n/tts_gemini_text_only_test.go create mode 100644 internal/tools/tts_text_only_test.go diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 01cf2c61..4fcba5f9 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -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 `` / 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`. diff --git a/docs/project-changelog.md b/docs/project-changelog.md index 4fb1c6fa..84fbccb0 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -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** diff --git a/docs/tts-provider-capabilities.md b/docs/tts-provider-capabilities.md index 4c18e37a..8c8aa3cb 100644 --- a/docs/tts-provider-capabilities.md +++ b/docs/tts-provider-capabilities.md @@ -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`). diff --git a/internal/audio/gemini/client.go b/internal/audio/gemini/client.go index 972b71a9..088e83ba 100644 --- a/internal/audio/gemini/client.go +++ b/internal/audio/gemini/client.go @@ -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} } diff --git a/internal/audio/gemini/client_test.go b/internal/audio/gemini/client_test.go index 82b370d9..b01dc014 100644 --- a/internal/audio/gemini/client_test.go +++ b/internal/audio/gemini/client_test.go @@ -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) + } +} diff --git a/internal/audio/gemini/errors.go b/internal/audio/gemini/errors.go index e9bedda8..86b00bf8 100644 --- a/internal/audio/gemini/errors.go +++ b/internal/audio/gemini/errors.go @@ -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)") ) diff --git a/internal/audio/gemini/models.go b/internal/audio/gemini/models.go index e939843e..b0171bc1 100644 --- a/internal/audio/gemini/models.go +++ b/internal/audio/gemini/models.go @@ -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 { diff --git a/internal/audio/gemini/testdata/default_body.golden.json b/internal/audio/gemini/testdata/default_body.golden.json index cfbe67a1..9b7ffc3f 100644 --- a/internal/audio/gemini/testdata/default_body.golden.json +++ b/internal/audio/gemini/testdata/default_body.golden.json @@ -3,7 +3,7 @@ { "parts": [ { - "text": "hello" + "text": "Speak naturally: hello" } ] } diff --git a/internal/audio/gemini/tts.go b/internal/audio/gemini/tts.go index 6a32870d..a196245d 100644 --- a/internal/audio/gemini/tts.go +++ b/internal/audio/gemini/tts.go @@ -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 diff --git a/internal/audio/gemini/tts_test.go b/internal/audio/gemini/tts_test.go index 05b38cd7..a69f4bc6 100644 --- a/internal/audio/gemini/tts_test.go +++ b/internal/audio/gemini/tts_test.go @@ -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) + } +} diff --git a/internal/audio/manager.go b/internal/audio/manager.go index c7442e95..5257876f 100644 --- a/internal/audio/manager.go +++ b/internal/audio/manager.go @@ -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 diff --git a/internal/audio/manager_fallback_sentinel_test.go b/internal/audio/manager_fallback_sentinel_test.go new file mode 100644 index 00000000..9ca30aa3 --- /dev/null +++ b/internal/audio/manager_fallback_sentinel_test.go @@ -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) + } + }) +} diff --git a/internal/http/tts.go b/internal/http/tts.go index c7296fb7..98b73112 100644 --- a/internal/http/tts.go +++ b/internal/http/tts.go @@ -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) diff --git a/internal/http/tts_test.go b/internal/http/tts_test.go index 538423bc..cb1259aa 100644 --- a/internal/http/tts_test.go +++ b/internal/http/tts_test.go @@ -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). diff --git a/internal/http/tts_test_connection.go b/internal/http/tts_test_connection.go index 4eff3724..ab2fc11d 100644 --- a/internal/http/tts_test_connection.go +++ b/internal/http/tts_test_connection.go @@ -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) diff --git a/internal/http/tts_timeout_test.go b/internal/http/tts_timeout_test.go new file mode 100644 index 00000000..f8f92075 --- /dev/null +++ b/internal/http/tts_timeout_test.go @@ -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) + } +} diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index 73f556db..61af216a 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -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", diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index 0aa40417..93ba0d97 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -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", diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index 43e25352..0d840cdb 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -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", diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index c9bb57e6..348012ff 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -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" diff --git a/internal/i18n/tts_gemini_text_only_test.go b/internal/i18n/tts_gemini_text_only_test.go new file mode 100644 index 00000000..a4d27ce8 --- /dev/null +++ b/internal/i18n/tts_gemini_text_only_test.go @@ -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) + } + }) + } +} diff --git a/internal/tools/tts.go b/internal/tools/tts.go index 89b29d3f..7202a443 100644 --- a/internal/tools/tts.go +++ b/internal/tools/tts.go @@ -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} } diff --git a/internal/tools/tts_text_only_test.go b/internal/tools/tts_text_only_test.go new file mode 100644 index 00000000..3de520d6 --- /dev/null +++ b/internal/tools/tts_text_only_test.go @@ -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 + }) + } +} diff --git a/ui/web/src/pages/tts/sections/behavior-section.tsx b/ui/web/src/pages/tts/sections/behavior-section.tsx index 3e7e8879..4c59aaf7 100644 --- a/ui/web/src/pages/tts/sections/behavior-section.tsx +++ b/ui/web/src/pages/tts/sections/behavior-section.tsx @@ -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} />