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 里搜不到哨兵正文」用例,且能变异打红
This commit is contained in:
AskaEth committed 2026-10-26 13:44:32 +08:00
1 parent 46a6da13e0
commit 66dda084cd
16 files changed
+570 -183

No files matched your search

@@ -0,0 +1,115 @@
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}
}
// 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
}