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 里搜不到哨兵正文」用例,且能变异打红
241 lines
8.6 KiB
Go
241 lines
8.6 KiB
Go
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)
|
||
}
|
||
}
|