diff --git a/internal/runtime/multimodal/voice/dashscope_error.go b/internal/runtime/multimodal/voice/dashscope_error.go index cc3ca9e..1b50270 100644 --- a/internal/runtime/multimodal/voice/dashscope_error.go +++ b/internal/runtime/multimodal/voice/dashscope_error.go @@ -18,10 +18,6 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) -// maxErrorTokenBytes 是 error.code / request_id 允许出镜的长度上限(见 -// dashscopeErrorToken)。 -const maxErrorTokenBytes = 64 - // classifyDashscopeStatus 把一个非 200 的 DashScope 响应翻成**可分类**的失败:鉴权失败 / // 欠费额度 / 限流各自成一条哨兵链(都以 ErrProviderFailed 为底),其余原样归入 // ErrProviderFailed(切片 IM-ASR 的熔断据此判定「再试是不是白试」)。 @@ -45,8 +41,8 @@ func classifyDashscopeStatus(endpoint string, status int, contentType string, bo // helper 的构造是固定的,走不到这里;真走到也只少一条诊断。 return fmt.Errorf("voice: %w", sentinel) } - info.Code = dashscopeErrorToken(fields.code) - if rid := dashscopeErrorToken(fields.requestID); rid != "" { + info.Code = trace.ErrorToken(fields.code) + if rid := trace.ErrorToken(fields.requestID); rid != "" { return fmt.Errorf("voice: %w: request_id=%s: %w", sentinel, rid, info) } return fmt.Errorf("voice: %w: %w", sentinel, info) @@ -102,35 +98,15 @@ func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields { // message 原文一律不进 error:它是上游自由文本,而这条请求的 input.url 是参考 // 音频的公开地址(可能是预签名 URL,带凭据),上游报错经常把它整段回显出来。 // 病因分类保留在 ErrProviderFailed(再试也不会好,与既有的熔断口径一致); -// 确实带回来的 code/type 也只以令牌形态出镜(过 dashscopeErrorToken)。 +// 确实带回来的 code/type 也只以令牌形态出镜(过 trace.ErrorToken)。 func dashscopeOKBodyError(endpoint string, body []byte) error { info, ok := trace.UpstreamError(trace.HostOf(endpoint), http.StatusOK, "", body).(*trace.UpstreamErrorInfo) if !ok { // helper 的构造是固定的,走不到这里;真走到也只少一条诊断。 return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response", ErrProviderFailed) } - info.Code = dashscopeErrorToken(info.Code) - info.Type = dashscopeErrorToken(info.Type) + info.Code = trace.ErrorToken(info.Code) + info.Type = trace.ErrorToken(info.Type) return fmt.Errorf("voice: %w: dashscope reported a business failure on a 200 response: %w", ErrProviderFailed, info) } - -// dashscopeErrorToken 只放行「像协议令牌」的短串(字母、数字与 `_ . -`):code -// 在 DashScope 是枚举(InvalidParameter / Throttling.RateQuota / Arrearage …), -// request_id 是十六进制/UUID 形状。两者都是自由文本面,不像令牌的一律丢弃 —— -// 宁可少一条分类提示,不让散文借道出镜(同口径见 runtime/model 的 safeErrorToken)。 -func dashscopeErrorToken(s string) string { - s = strings.TrimSpace(s) - if s == "" || len(s) > maxErrorTokenBytes { - return "" - } - for _, r := range s { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': - case r == '_' || r == '.' || r == '-': - default: - return "" - } - } - return s -} diff --git a/internal/runtime/multimodal/voice/dashscope_error_test.go b/internal/runtime/multimodal/voice/dashscope_error_test.go new file mode 100644 index 0000000..54578de --- /dev/null +++ b/internal/runtime/multimodal/voice/dashscope_error_test.go @@ -0,0 +1,57 @@ +package voice + +// 本文件钉住 DashScope 错误收口里的**令牌守卫**(trace.ErrorToken):code 与 +// request_id 都是上游自由文本面 —— code 在 DashScope 是枚举(InvalidParameter / +// Throttling.RateQuota / Arrearage …),但网关 / 兼容层可以往里塞任何东西。 +// +// 已有的用例(TestDashscopeTTS_Non200DropsSpokenText)只断言「令牌形状的 code 被 +// 保留」,去掉守卫它照样通过 —— 那是个半张网。这里补上另一半:**不像令牌的 code +// 必须变空串**,而 status / request_id 这些诊断面仍在。 +// +// 形状是「把守去掉就变红」:断言的是错误文本,也就是真正进日志的字符串。 + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" +) + +// TestDashscopeTTS_Non200DropsFreeTextCode 4xx 的 code 里放一段散文(带空格与标点) +// ⇒ 该字段整体消失(连 `code=` 这个键都不出现),而 status 400 与 request_id 仍在。 +func TestDashscopeTTS_Non200DropsFreeTextCode(t *testing.T) { + t.Parallel() + const prose = "Invalid value: '她刚才说的那句话'" + 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":"` + prose + `","request_id":"req-0123456789abcdef"}`)) + })) + defer srv.Close() + + tts := NewDashscopeTTS(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"}) + _, err := tts.TTS(context.Background(), "随便说点什么") + if err == nil { + t.Fatal("400 却报成功") + } + if !errors.Is(err, ErrProviderFailed) { + t.Fatalf("err = %v, want ErrProviderFailed(熔断口径不变)", err) + } + msg := err.Error() + if strings.Contains(msg, prose) { + t.Fatalf("code 里的散文进了错误:%v", msg) + } + if strings.Contains(msg, "code=") { + t.Fatalf("非令牌形状的 code 没有被丢弃:%v", msg) + } + // 诊断面不能一起丢:状态码与 request_id(对厂商报障时要给的那个)。 + for _, want := range []string{"status=400", "req-0123456789abcdef"} { + if !strings.Contains(msg, want) { + t.Fatalf("错误里缺少 %q(诊断面不能一起丢):%v", want, msg) + } + } +}