mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-05 14:22:59 +00:00
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:
@@ -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`.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
)
|
||||
|
||||
@@ -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
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
"text": "Speak naturally: hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user