Files
Cyrene_v2/internal/runtime/model/upstream.go
T
AskaEth 3001fcc716 fix(model): providerUpstreamError 的 error.code 过令牌守卫
error.code 与 error.type 一样是**自由文本面**:主流上游里是枚举,但网关、代理与
兼容层可以往里塞任何东西(包括回显的用户原文)。M41 已给 type 加了守卫,
providerUpstreamError 的 code 一直原样带出,是这一族里唯一还漏的字段。

守卫本色从 runtime/model(safeErrorToken)与 runtime/multimodal/voice
(dashscopeErrorToken)两份逐字同形的实现上提到 trace.ErrorToken,model 侧改为
调用它(voice 侧仍是自己那份,不在本切片文件面内,见随附 finding)。

唯一的行为变化就是「不像令牌的 code 变空串」:status / type / body 体积与 sha256、
错误文案模板、双 %w 哨兵链(ErrUpstream、trace.ErrUpstream)都不变。

验收:go build ./... + go vet ./... + trace/model 包 test 全绿;新增用例覆盖
「带空格标点的 code 变空串且 status/type 仍在」与「令牌形状的 code 保留」。
2026-10-26 18:45:38 +08:00

77 lines
4.0 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 也要过令牌守卫(trace.ErrorToken):它在主流上游是枚举,但网关、代理与
// 兼容层可以往里面塞任何东西 —— 包括回显的输入。不像令牌的 code 一律变空串,代价只是
// 少一条分类提示;status / body 摘要等诊断面不受影响。
//
// 双 %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)
}
info.Code = trace.ErrorToken(info.Code)
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)且必须像令牌才出镜:网关把散文塞进 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.Type = trace.ErrorToken(evt.Type)
return fmt.Errorf("model: %w: %w", ErrUpstream, info)
}