From 9e88605cc80b952d8689456788e36fd523a7b131 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:50:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(voice):=20CloneVoice=20=E7=9A=84=20200-with?= =?UTF-8?q?-message=20=E6=B3=84=E6=BC=8F=E6=94=B6=E5=8F=A3=E5=88=B0=20dash?= =?UTF-8?q?scopeOKBodyError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M41 同族的最后一条缺口:HTTP 200 的响应里用顶层 message 报业务失败,而这句话 是上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名 URL,带凭据),上游报错常把它原样回显。 - ERROR 只留「打到了、但 200 是个业务失败」这个形状事实 + 体积摘要; message 原文不进 error,code/type 若真的带回来也只以令牌形态出镜 - errors.Is(err, ErrProviderFailed) 不变(熔断口径不动) - 用例断言失败仍被识别、且 error 文本里搜不到哨兵正文 --- .../runtime/multimodal/voice/dashscope.go | 5 +++- .../multimodal/voice/dashscope_error.go | 21 ++++++++++++++ .../multimodal/voice/voice_clone_test.go | 29 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/internal/runtime/multimodal/voice/dashscope.go b/internal/runtime/multimodal/voice/dashscope.go index de20e40..6320708 100644 --- a/internal/runtime/multimodal/voice/dashscope.go +++ b/internal/runtime/multimodal/voice/dashscope.go @@ -325,7 +325,10 @@ func (d *DashscopeCloner) CloneVoice(ctx context.Context, referenceURL, prefix s return "", fmt.Errorf("voice: %w: decode: %v", ErrProviderFailed, err) } if resp.Message != "" { - return "", fmt.Errorf("voice: %w: %s", ErrProviderFailed, resp.Message) + // 200 + 非空 message 是 DashScope 的「业务级失败」形状。message 原文是 + // 上游自由文本(可能回显参考音频的预签名 URL),因此只留形状事实与摘要 + // (见 dashscopeOKBodyError),不拼原文。 + return "", dashscopeOKBodyError(d.endpoint, body) } if resp.Output.VoiceID == "" { return "", fmt.Errorf("voice: %w: no voice_id in response", ErrProviderFailed) diff --git a/internal/runtime/multimodal/voice/dashscope_error.go b/internal/runtime/multimodal/voice/dashscope_error.go index 858d345..cc3ca9e 100644 --- a/internal/runtime/multimodal/voice/dashscope_error.go +++ b/internal/runtime/multimodal/voice/dashscope_error.go @@ -94,6 +94,27 @@ func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields { return dashscopeErrorFields{code: wire.Code, requestID: wire.RequestID} } +// dashscopeOKBodyError 处理 DashScope 的「HTTP 200 但业务失败」形状:音色复刻 +// (create_voice)用顶层 message 报错,因此**不走** classifyDashscopeStatus —— +// 200 这条路必须单独收口,否则它就成了这一族里唯一还漏的缺口。 +// +// 只出镜一个**形状事实**(打到了、但 200 响应是个业务失败)加上体积摘要; +// message 原文一律不进 error:它是上游自由文本,而这条请求的 input.url 是参考 +// 音频的公开地址(可能是预签名 URL,带凭据),上游报错经常把它整段回显出来。 +// 病因分类保留在 ErrProviderFailed(再试也不会好,与既有的熔断口径一致); +// 确实带回来的 code/type 也只以令牌形态出镜(过 dashscopeErrorToken)。 +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) + 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 形状。两者都是自由文本面,不像令牌的一律丢弃 —— diff --git a/internal/runtime/multimodal/voice/voice_clone_test.go b/internal/runtime/multimodal/voice/voice_clone_test.go index 8a60285..442ed50 100644 --- a/internal/runtime/multimodal/voice/voice_clone_test.go +++ b/internal/runtime/multimodal/voice/voice_clone_test.go @@ -151,6 +151,35 @@ func TestDashscopeCloner_ModelError(t *testing.T) { } } +// TestDashscopeCloner_ModelErrorHidesMessage 覆盖 M41 的最后一条同族缺口: +// HTTP **200** 的响应里用顶层 message 报错(200 不会走非 2xx 的收口),而这句话是 +// 上游自由文本 —— create_voice 的 input.url 是参考音频的公开地址(可能是预签名 +// URL,带凭据),上游经常把它原样回显在 message 里。 +// +// 两个断言缺一不可:① 失败必须**仍被识别**(ErrProviderFailed,熔断口径不变); +// ② error 文本里搜不到哨兵(旧实现 `%s resp.Message` 会整句带出)。 +func TestDashscopeCloner_ModelErrorHidesMessage(t *testing.T) { + t.Parallel() + const echoed = "SENTINEL-CLONE-MESSAGE-3c7b" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message":"cannot fetch ` + echoed + `: signature expired","request_id":"req-abc"}`)) + })) + defer srv.Close() + + cloner := NewDashscopeCloner(config.VoiceConfig{DashscopeBaseURL: srv.URL, DashscopeAPIKey: "sk"}) + _, err := cloner.CloneVoice(context.Background(), "https://example.com/ref.wav", "x") + if !errors.Is(err, ErrProviderFailed) { + t.Fatalf("err = %v, want ErrProviderFailed", err) + } + if strings.Contains(err.Error(), echoed) { + t.Errorf("error text carries the provider message: %v", err) + } + if !strings.Contains(err.Error(), "200") { + t.Errorf("err = %v, want the 200-on-business-failure fact kept", err) + } +} + // TestDashscopeTTS_Instruction_ClonedVoice 断言复刻音色会在请求 input 中 // 携带配置的 instruction。 func TestDashscopeTTS_Instruction_ClonedVoice(t *testing.T) {