package voice // 本文件是 DashScope 的「非 2xx 响应 → error」收口,与 M41 的 LLM / embedding / // vision 三处同口径:走 internal/runtime/trace.UpstreamError(M40 共享 helper), // 不自己再写一份「截一段响应体拼进 error」。 // // 这一家的敏感面是 **TTS 的 input = 她要念的那段话**(与 emotionTTS 同型): // DashScope 的 4xx 错误体里既有 `code` / `request_id`,也有自由文本 `message`, // 而 message 会回显请求内容。因此正文只留在内存里做分类判据,出镜的只有 // 状态、体积摘要以及两个受控字段(code / request_id)。 import ( "encoding/json" "fmt" "net/http" "strings" "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 的熔断据此判定「再试是不是白试」)。 // // 判据只用**状态码 + 错误码/消息里的两类关键字**(阿里云官方口径:`Arrearage`、 // `Throttling.*`、额度上限),绝不匹配自由文本描述——把「格式不支持」误判成「欠费」 // 会让一条被拒的语音关掉整个转写面。 // // 🔴 错误文案里**没有正文**:响应体只用来(a)在内存里做上面的关键字分类、 // (b)数长度 / 取 sha256 前缀、(c)解出 code 与 request_id。出镜的只有 // status / contentType / bodyLen / sha256 / code / request_id —— TTS 的请求 // input 就是**她要念的那段话**,把它(或回显它的 message)拼进 error 等于 // 把台词写进日志。 // // 双 %w:errors.Is 既能命中分类哨兵(熔断判据),也能命中 trace.ErrUpstream。 func classifyDashscopeStatus(endpoint string, status int, contentType string, body []byte) error { sentinel := dashscopeSentinelFor(status, strings.ToLower(string(body))) fields := decodeDashscopeErrorFields(body) info, ok := trace.UpstreamError(trace.HostOf(endpoint), status, contentType, body).(*trace.UpstreamErrorInfo) if !ok { // helper 的构造是固定的,走不到这里;真走到也只少一条诊断。 return fmt.Errorf("voice: %w", sentinel) } info.Code = dashscopeErrorToken(fields.code) if rid := dashscopeErrorToken(fields.requestID); rid != "" { return fmt.Errorf("voice: %w: request_id=%s: %w", sentinel, rid, info) } return fmt.Errorf("voice: %w: %w", sentinel, info) } // dashscopeSentinelFor 给出状态码/错误码对应的**分类**哨兵,无法分类时返回 // ErrProviderFailed。loweredBody 只用于关键字判定,绝不外泄 —— 分类是「再试 // 是不是白试」的判据,不是一条给人看的描述。 func dashscopeSentinelFor(status int, loweredBody string) error { switch status { case http.StatusUnauthorized, http.StatusForbidden: return ErrAuthFailed case http.StatusTooManyRequests: return ErrRateLimited } // 欠费/额度耗尽:阿里云用 HTTP 400(少数情况 402)配上 Arrearage / // AllocatedQuotaExceeded 这类错误码,因此按错误码关键字归类(不按状态码)。 switch { case strings.Contains(loweredBody, "throttling"): return ErrRateLimited case strings.Contains(loweredBody, "arrearage"), strings.Contains(loweredBody, "quota"): return ErrQuotaExceeded } return ErrProviderFailed } // dashscopeErrorFields 是 DashScope 非 2xx 响应体里**唯一**允许出镜的两个字段。 // message 不在此列:它是上游自由文本,TTS 场景下可能回显她要念的文本。 type dashscopeErrorFields struct { code string requestID string } // decodeDashscopeErrorFields 尽力解出 code / request_id。不是 JSON(纯文本或 // 被截断的报文)⇒ 两个字段都是空串,错误里只剩 status/体积/摘要 —— 正文一个 // 字节都不留。 func decodeDashscopeErrorFields(body []byte) dashscopeErrorFields { var wire struct { Code string `json:"code"` RequestID string `json:"request_id"` } if err := json.Unmarshal(body, &wire); err != nil { return 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 形状。两者都是自由文本面,不像令牌的一律丢弃 —— // 宁可少一条分类提示,不让散文借道出镜(同口径见 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 }