Files
Cyrene_v2/internal/runtime/multimodal/voice/dashscope_test.go
T
AskaEth 66dda084cd fix(runtime): 上游错误体统一走 trace.UpstreamError,正文不进 error
M41 的「LLM / embedding / 视觉 上游错误体」一族(M38 #5 #6 #7 #8 #9 #14):
非 2xx 时把响应体截断后拼进 error,而 error 会被 chat run / loop step /
retrieve_fallback 打日志,等于把她的提问、记忆正文、要念的台词写进日志。

- model:openai/anthropic 两处 upstreamError 收口到 upstream.go 的
  providerUpstreamError;anthropic 流内 error 事件的 evt.Error.Message
  (原本无上限)整段丢弃,只保留令牌形状的 type
- embedding:readErrorBody 删除,改走 helper(input 就是她的记忆正文)
- vision:错误路径不再沿用取图的 50 MiB 上限,改为 4 KiB 有界读且不拼原文
- voice:classifyDashscopeStatus 只解 code/request_id,正文只留在内存里做
  分类判据;错误体读取同样改为 4 KiB 有界
- 测试:五个上游各一条「日志/error 里搜不到哨兵正文」用例,且能变异打红
2026-10-26 13:44:32 +08:00

241 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package voice
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
func TestDashscopeSTT(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/generation") {
t.Errorf("path = %q, want .../generation", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer sk" {
t.Errorf("auth = %q, want Bearer sk", r.Header.Get("Authorization"))
}
if r.Header.Get("X-DashScope-SSE") != "disable" {
t.Errorf("sse = %q, want disable", r.Header.Get("X-DashScope-SSE"))
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["model"] != "qwen-audio-3.0-asr-flash" {
t.Errorf("model = %v, want qwen-audio-3.0-asr-flash", body["model"])
}
input := body["input"].(map[string]any)
msgs := input["messages"].([]any)
content := msgs[0].(map[string]any)["content"].([]any)
audio := content[0].(map[string]any)["input_audio"].(map[string]any)["data"].(string)
if !strings.HasPrefix(audio, "data:audio/wav;base64,") {
t.Errorf("data uri = %q, want data:audio/wav;base64,...", audio)
}
params := body["parameters"].(map[string]any)
if params["format"] != "wav" {
t.Errorf("format = %v, want wav", params["format"])
}
hints := params["language_hints"].([]any)
if len(hints) != 1 || hints[0] != "zh" {
t.Errorf("language_hints = %v, want [zh]", hints)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"output":{"output":{"sentence":{"text":"你好世界"}}}}`))
}))
defer srv.Close()
stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("rawaudio")})
if err != nil {
t.Fatalf("STT error: %v", err)
}
if text != "你好世界" {
t.Errorf("text = %q, want 你好世界", text)
}
}
func TestDashscopeSTT_ChoicesText(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"output":{"choices":[{"message":{"content":[{"text":"hello"}]}}]}}`))
}))
defer srv.Close()
stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
text, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")})
if err != nil {
t.Fatalf("STT error: %v", err)
}
if text != "hello" {
t.Errorf("text = %q, want hello", text)
}
}
func TestDashscopeSTT_NotConfigured(t *testing.T) {
t.Parallel()
stt := NewDashscopeSTT(config.VoiceConfig{})
if _, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")}); err != ErrNotConfigured {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
}
func TestDashscopeSTT_EmptyAudio(t *testing.T) {
t.Parallel()
stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
if _, err := stt.STT(context.Background(), Audio{}); !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
}
func TestDashscopeSTT_Non200(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"InvalidParameter","message":"bad"}`))
}))
defer srv.Close()
stt := NewDashscopeSTT(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
_, err := stt.STT(context.Background(), Audio{Format: "wav", Data: []byte("x")})
if !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
if !strings.Contains(err.Error(), "400") {
t.Errorf("err = %v, want it to mention status 400", err)
}
}
// TestDashscopeTTS_Non200DropsSpokenText 覆盖 M41 的 DashScope 一处:TTS 请求的
// input 就是**她要念的那段话**,而 DashScope 的 4xx 错误体会把它(或回显它的
// message)带回来。旧实现把整包响应体(cloudMaxResponseBytes = 8 MiB,无截断)
// 拼进 error,error 又被语音链打日志。
//
// 断言「文本里搜不到台词」,同时保留两类可诊断字段:分类哨兵链与 code /
// request_id(DashScope 报障时确实要看这两个)。
func TestDashscopeTTS_Non200DropsSpokenText(t *testing.T) {
t.Parallel()
const spoken = "SENTINEL-SPOKEN-TEXT-1e4f"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"code":"InvalidParameter","message":"text: '` + spoken +
`' is invalid","request_id":"req-0123456789abcdef"}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
_, err := tts.TTS(context.Background(), spoken)
if !errors.Is(err, ErrProviderFailed) {
t.Fatalf("err = %v, want ErrProviderFailed", err)
}
if strings.Contains(err.Error(), spoken) {
t.Errorf("error text echoes the text to be spoken: %v", err)
}
if !strings.Contains(err.Error(), "InvalidParameter") {
t.Errorf("err = %v, want the error code kept for triage", err)
}
if !strings.Contains(err.Error(), "req-0123456789abcdef") {
t.Errorf("err = %v, want the request id kept for provider support", err)
}
}
func TestDashscopeTTS_URL(t *testing.T) {
t.Parallel()
audioBytes := []byte("RIFFxxxxWAVE")
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/SpeechSynthesizer"):
if r.Header.Get("Authorization") != "Bearer sk" {
t.Errorf("auth = %q, want Bearer sk", r.Header.Get("Authorization"))
}
if sse := r.Header.Get("X-DashScope-SSE"); sse != "" {
t.Errorf("sse = %q, want empty (non-streaming URL)", sse)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["model"] != "cosyvoice-v3-flash" {
t.Errorf("model = %v, want cosyvoice-v3-flash", body["model"])
}
input := body["input"].(map[string]any)
if input["text"] != "你好" {
t.Errorf("text = %v, want 你好", input["text"])
}
if input["voice"] != "longxiaochun_v3" {
t.Errorf("voice = %v, want longxiaochun_v3", input["voice"])
}
if input["format"] != "wav" {
t.Errorf("format = %v, want wav", input["format"])
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"output":{"audio":{"url":"` + srv.URL + `/audio/out.wav"}}}`))
case strings.HasSuffix(r.URL.Path, "/audio/out.wav"):
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(audioBytes)
default:
t.Errorf("unexpected path %q", r.URL.Path)
}
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
audio, err := tts.TTS(context.Background(), "你好")
if err != nil {
t.Fatalf("TTS error: %v", err)
}
if audio.Format != "wav" {
t.Errorf("format = %q, want wav", audio.Format)
}
if hex.EncodeToString(audio.Data) != hex.EncodeToString(audioBytes) {
t.Errorf("data = %s, want %s", hex.EncodeToString(audio.Data), hex.EncodeToString(audioBytes))
}
}
func TestDashscopeTTS_DataBlob(t *testing.T) {
t.Parallel()
audioBytes := []byte{9, 8, 7}
b64 := base64.StdEncoding.EncodeToString(audioBytes)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"output":{"audio":{"data":"` + b64 + `"}}}`))
}))
defer srv.Close()
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"})
audio, err := tts.TTS(context.Background(), "hi")
if err != nil {
t.Fatalf("TTS error: %v", err)
}
if hex.EncodeToString(audio.Data) != hex.EncodeToString(audioBytes) {
t.Errorf("data = %s, want %s", hex.EncodeToString(audio.Data), hex.EncodeToString(audioBytes))
}
}
func TestDashscopeTTS_EmptyText(t *testing.T) {
t.Parallel()
tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: "http://x", DashscopeAPIKey: "sk"})
if _, err := tts.TTS(context.Background(), " "); err != ErrEmptyText {
t.Fatalf("err = %v, want ErrEmptyText", err)
}
}
func TestDashscopeTTS_NotConfigured(t *testing.T) {
t.Parallel()
tts := NewDashscopeTTS(config.VoiceConfig{})
if _, err := tts.TTS(context.Background(), "hi"); err != ErrNotConfigured {
t.Fatalf("err = %v, want ErrNotConfigured", err)
}
}