diff --git a/internal/ai/bailian_tts.go b/internal/ai/bailian_tts.go index ddfa003..113e982 100644 --- a/internal/ai/bailian_tts.go +++ b/internal/ai/bailian_tts.go @@ -11,6 +11,7 @@ import ( "net/http" "net/url" "os/exec" + "strconv" "strings" ) @@ -109,6 +110,36 @@ func synthesizeBailianTTS(ctx context.Context, approved TTSConfig, text string) return pcm.Bytes(), nil } +// bailianFailureSummary emits only fixed categories and HTTP status codes; +// provider responses, signed URLs, credentials and prompt text stay private. +func bailianFailureSummary(err error) (string, int) { + if err == nil { + return "none", 0 + } + message := err.Error() + for _, item := range []struct{ prefix, kind string }{ + {"Bailian TTS rejected request: HTTP ", "generation_http"}, + {"Bailian audio download rejected: HTTP ", "audio_http"}, + } { + if status, err := strconv.Atoi(strings.TrimPrefix(message, item.prefix)); strings.HasPrefix(message, item.prefix) && err == nil && status >= 100 && status <= 599 { + return item.kind, status + } + } + for _, item := range []struct{ prefix, kind string }{ + {"Bailian TTS response has no complete audio reference", "missing_audio"}, + {"Bailian TTS request failed:", "generation_transport"}, + {"Bailian audio download failed:", "audio_transport"}, + {"Bailian audio conversion failed:", "audio_conversion"}, + {"Bailian TTS deadline or cancellation:", "deadline"}, + {"Bailian TTS returned incomplete PCM16 audio", "incomplete_audio"}, + } { + if strings.HasPrefix(message, item.prefix) { + return item.kind, 0 + } + } + return "unknown", 0 +} + type boundedPCM struct{ bytes.Buffer } func (w *boundedPCM) Write(data []byte) (int, error) { diff --git a/internal/ai/bailian_tts_test.go b/internal/ai/bailian_tts_test.go index 1c66eee..5cae02c 100644 --- a/internal/ai/bailian_tts_test.go +++ b/internal/ai/bailian_tts_test.go @@ -3,6 +3,7 @@ package ai import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os/exec" @@ -50,6 +51,23 @@ func mockBailianTTS(t *testing.T, pcm []byte, observe func(string)) (string, *at return server.URL + "/api/v1/services/aigc/multimodal-generation/generation", calls } +func TestBailianFailureSummaryNeverLogsProviderPayload(t *testing.T) { + for _, tc := range []struct { + message, kind string + status int + }{ + {"Bailian TTS rejected request: HTTP 403", "generation_http", 403}, + {"Bailian audio download rejected: HTTP 502", "audio_http", 502}, + {"Bailian TTS response has no complete audio reference", "missing_audio", 0}, + {"Authorization: Bearer private-signature and prompt", "unknown", 0}, + } { + kind, status := bailianFailureSummary(errors.New(tc.message)) + if kind != tc.kind || status != tc.status || strings.Contains(kind, "private") { + t.Fatalf("unsafe failure summary: kind=%q status=%d", kind, status) + } + } +} + func TestBailianTTSDownloadFailureDoesNotLeakSignedURLOrRetry(t *testing.T) { if _, err := exec.LookPath("ffmpeg"); err != nil { t.Skip("Bailian TTS conversion requires ffmpeg") diff --git a/internal/ai/pipeline.go b/internal/ai/pipeline.go index e0148c4..06e2019 100644 --- a/internal/ai/pipeline.go +++ b/internal/ai/pipeline.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net/url" "strings" "sync" @@ -55,6 +56,7 @@ func (c *Call) Open(ctx context.Context) ([]byte, error) { } audio, err := c.bound.Synthesize(ctx, c.bound.Opening) if err != nil { + slog.Error("approved opening synthesis failed", "stage", "opening") return nil, err } c.mu.Lock() @@ -233,6 +235,8 @@ func (b Binding) synthesize(ctx context.Context, text string, alreadyPending int // missing or failed response never counts as queued audio. audio, err := synthesizeBailianTTS(ctx, *b.TTS, text) if err != nil { + kind, status := bailianFailureSummary(err) + slog.Error("approved TTS synthesis failed", "category", kind, "http_status", status, "cause_type", fmt.Sprintf("%T", err)) return nil, 0, err } return audio, 1, nil