Files
Cyrene_v2/internal/runtime/model/upstream.go
T
AskaEth fe3872ee28 fix(trace): 令牌守卫上移进 trace.UpstreamError,构造即安全
M48 复审的中危 finding:error.type 里的散文经 trace.UpstreamError 原样出镜,
因为守卫加在调用方(M46 的 model/voice 两处),构造函数自己不套守卫 ——
任何新调用点默认不安全,httpclient / vision / embedding / weixin 四条出口一直漏。

- trace.UpstreamError 构造时对 error.code / error.type 各走一次 ErrorToken
  ([A-Za-z0-9_.-]{<=64},不合规即空串):默认安全,不是可选加固。
- 构造后注入这条窄路收进 UpstreamErrorInfo.SetCode / SetType(同一道守卫),
  供 DashScope 顶层 code 与 Anthropic 流内 error.type 两处使用 —— 调用方不再
  各抄一份 ErrorToken。
- 删除因此冗余的调用方守卫:model.providerUpstreamError 与
  voice.dashscopeOKBodyError。保留 voice 的 request_id 守卫(它不是 code/type,
  构造函数看不见)。
- 四条漏的出口一行没改,只加用例证明守卫上移后自动安全;摘掉构造函数守卫时
  四条用例 + model/voice 旧用例同时变红。
2026-10-26 19:13:38 +08:00

81 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package model
import (
"fmt"
"io"
"net/http"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件是 model 包**唯一**的「上游错误体 → error」收口。两个 provider 适配
// (OpenAI 兼容 /chat/completions 与 Anthropic /v1/messages,含流内 error 事件)
// 都经这里进 error,绝不各自截一段响应体再拼进去 —— 那样写的人早晚会漏掉一家,
// 而漏掉的方向永远是「她的提问进日志」。
//
// 出镜口径由 internal/runtime/trace.UpstreamError 决定(M40 的共享 helper,契约见
// 其文件头):只留 upstream / status / contentType / bodyLen / body sha256 前缀 /
// error.code / error.type,**error.message 默认带不进**。这里不重新实现任何清洗
// —— 三家各写一份清洗就是三处漂移的起点。
// errBodyCap 限制错误路径**读多少字节**(只用来数长度、取摘要、抽 code/type),
// 不再决定「错误里带多少正文」:正文一个字节都不进 error。上界刻意小 ——
// 错误体是攻击面,不是数据面。
const errBodyCap = 512
// upstreamLabel 报「这次调用实际打到哪」:优先主机(自建网关 / 代理下才看得出
// 真实去向),取不到主机时退回 provider 名(代码里写死的短名)。两种形态都不含
// 路径、查询串与凭据 —— 契约在 trace.HostOf。
func upstreamLabel(name, baseURL string) string {
if host := trace.HostOf(baseURL); host != "" {
return host
}
return name
}
// providerUpstreamError 把一次非 2xx 响应收敛成**可安全进日志**的错误。
//
// 为什么不再带正文:上游把用户的提问原样回显在错误里是真实写法 ——
// `{"error":{"code":"invalid_request_error","message":"Invalid value: '<她的原话>'"}}`,
// 而这类 error 会被 `chat run` / `loop step` 打日志。截断到 512 字节只是把
// 「整段泄漏」变成「前 512 字节泄漏」,不是修复。
//
// error.code 与 error.type 的令牌守卫(trace.ErrorToken)**不在这里**:它已上移进
// trace.UpstreamError 的构造函数(M49),上游报文里不像令牌的字段在那里就变空串。
// 调用点再守一遍是 M46 留下的重复 —— 「同一件事守两遍」的下一步永远是两处口径分叉,
// 所以这里删掉,只保留「构造即安全」这一处真源。
//
// 双 %w:既保持 errors.Is(err, ErrUpstream)(降级链按它分类,主 → 备 → 本地),
// 也让 errors.Is(err, trace.ErrUpstream) 成立(调用方按上游失败分类)。
func providerUpstreamError(name, baseURL string, resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap))
info, ok := trace.UpstreamError(upstreamLabel(name, baseURL), resp.StatusCode,
resp.Header.Get("Content-Type"), body).(*trace.UpstreamErrorInfo)
if !ok {
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。
return fmt.Errorf("model: %w: status %d", ErrUpstream, resp.StatusCode)
}
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
}
// streamEventError 处理 /v1/messages 的**流内** error 事件:HTTP 已经是 200,
// 失败藏在 SSE 里,因此没有状态码。
//
// evt.Message 是上游自由文本,Anthropic 会用它回显请求内容(例如「prompt is too
// long: <片段>」),所以**整段丢弃**(原本是无上限拼进 error)。只保留 evt.Type
// (协议枚举,如 overloaded_error)。
//
// 🔴 这里的类型是**流内事件**,报文不在 trace.UpstreamError 的视野里(HTTP 已 200,
// body 为空),所以它属于「构造后注入」这一族,必须走 SetType(与构造函数同一道
// 令牌守卫):网关把散文塞进 type 时它是空串 —— 宁可少一条分类提示,不让自由文本
// 借道 type 出去。
func streamEventError(upstream string, evt *anthropicStreamError) error {
info, ok := trace.UpstreamError(upstream, 0, "text/event-stream", nil).(*trace.UpstreamErrorInfo)
if !ok {
// helper 的构造是固定的,走不到这里;真走到也只少一条诊断,不破安全口径。
return fmt.Errorf("model: %w: anthropic stream error event", ErrUpstream)
}
info.SetType(evt.Type)
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
}