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 里搜不到哨兵正文」用例,且能变异打红
249 lines
10 KiB
Go
249 lines
10 KiB
Go
package vision
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// 读取图像字节与响应体的体积上限,使恶意或
|
||
// 畸形来源无法耗尽内存。50MB 上限与
|
||
// DevDocs/02-development-constraints.md §11 的文件上传限制一致。
|
||
const (
|
||
maxImageBytes = 50 << 20
|
||
maxFetchedBody = 50 << 20
|
||
// maxErrorBodyBytes 是非 2xx 响应的读取上限。它与上面两个是**不同口径**:
|
||
// 那两个是数据面(图像字节)的预算,这一条是攻击面的预算 —— 错误体只用来
|
||
// 数长度 / 取 sha256 前缀 / 抽 error.code,因此 4 KiB 足够,绝不沿用 50 MiB
|
||
// (那会把「一个超大错误响应」变成一次内存分配与一次日志面暴露)。
|
||
maxErrorBodyBytes = 4 << 10
|
||
)
|
||
|
||
// imageFetchTimeout 限制一次图像 URL 取回的时长。适配器与消息解析器会
|
||
// 为模型代取 URL,没有上限时一个卡住或恶意的地址会一直占着这一轮
|
||
// (fetchURL 经 guardedImageClient 给未设超时的 client 补上它,
|
||
// 拨号守卫也用同一个值作单次建连上限)。
|
||
const imageFetchTimeout = 30 * time.Second
|
||
|
||
// DataURI 由 mime 与原始字节构造一个 base64 data: 图像 URI。
|
||
func DataURI(mime string, data []byte) string {
|
||
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
|
||
}
|
||
|
||
// Resolve 加载一个 ImageRef 对应的字节。路径与 base64 载荷在
|
||
// 本地读取;URL 用 http.Client 抓取(传入的 client,为 nil 时
|
||
// 用默认 client)。对 base64 输入,Data 字段原样作为
|
||
// 载荷返回,MIME 类型从 data: 前缀推断;data: 载荷解码后还要过
|
||
// 内容嗅探(见 checkInlinePayload),不是图片一律否决。
|
||
func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime string, data []byte, err error) {
|
||
switch ref.Source {
|
||
case SourcePath:
|
||
return readFile(ref.Data)
|
||
case SourceBase64:
|
||
return decodeBase64(ref.Data)
|
||
case SourceURL:
|
||
return fetchURL(ctx, ref.Data, client)
|
||
default:
|
||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, ref.Source)
|
||
}
|
||
}
|
||
|
||
// readFile 读取一个图像文件。扩展名给出声明的 MIME,真实字节嗅探是否决权
|
||
// (见 imageMIMEFor):改名的 HTML 不会被当成图片读进模型。
|
||
func readFile(path string) (string, []byte, error) {
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
|
||
}
|
||
defer func() { _ = f.Close() }()
|
||
data, err := io.ReadAll(io.LimitReader(f, maxImageBytes))
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
|
||
}
|
||
mime, err := imageMIMEFor(mimeByExt(path), data)
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
return mime, data, nil
|
||
}
|
||
|
||
// decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。
|
||
// 长度预检先于解码与 data: 解析(见 checkInlineImageLength):超限载荷在
|
||
// 分配之前就被拦下,而不是先按约 4/3 分配再报错。
|
||
//
|
||
// data: URI 解码后要过内容嗅探(checkInlinePayload):前缀里的声明与真实字节
|
||
// 对不上时否决,绝不把「挂着 image/* 声明、内容却不是图片」的载荷转成 data: URI
|
||
// 交给上游(2026-10-07 的实测缺陷:`data:image/png;base64,` 只带前缀不带载荷,
|
||
// 旧代码照样报 image/png,空载荷被当成图片发给上游)。
|
||
//
|
||
// 裸 base64 路径保持原状:它没有声明可对照,包内契约定的是「按 image/png 处理」
|
||
// (见 Resolve 的文档),加嗅探就是改这条契约。
|
||
func decodeBase64(src string) (string, []byte, error) {
|
||
if err := checkInlineImageLength(src); err != nil {
|
||
return "", nil, err
|
||
}
|
||
mime, encoded, isDataURI := splitDataURI(src)
|
||
if !isDataURI {
|
||
// 以 data: 开头却没解析出 (mime, payload) 只有一个成因:**畸形**的
|
||
// data: URI(缺逗号分隔符,或没有 ;base64 段 —— 本包只处理 base64 载荷,
|
||
// 与 image-crop 同口径)。放它走下面的裸 base64 解码,报出来的是
|
||
// base64 语法错误,指不到真正的毛病,故在此明确拒绝。
|
||
if strings.HasPrefix(src, "data:") {
|
||
return "", nil, fmt.Errorf("vision: %w: malformed data: URI (want data:<mime>;base64,<base64 payload>)", ErrBadImage)
|
||
}
|
||
decoded, err := base64.StdEncoding.DecodeString(src)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
|
||
}
|
||
return "image/png", decoded, nil
|
||
}
|
||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
|
||
}
|
||
return checkInlinePayload(mime, decoded)
|
||
}
|
||
|
||
// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。
|
||
// 取回前、重定向每一跳与真正建连前都有守卫(见 guard.go):链路本地与
|
||
// 云元数据地址被拒,环回/私网/公网照旧可达(同源自取需要它们)。
|
||
// 声明的 Content-Type 只作参考,真实字节嗅探说了算(见 imageMIMEFor):
|
||
// 网关/PROXY 返回的 HTML 错误页绝不会被当成 png 送进模型。
|
||
func fetchURL(ctx context.Context, rawURL string, client *http.Client) (string, []byte, error) {
|
||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||
return "", nil, fmt.Errorf("vision: %w: %q", ErrUnsupportedSource, rawURL)
|
||
}
|
||
u, err := url.Parse(rawURL)
|
||
if err != nil {
|
||
// 不回显 URL:解析失败的原文里带着整条地址(可能含凭据)。
|
||
return "", nil, fmt.Errorf("vision: %w: URL 无法解析", ErrFetch)
|
||
}
|
||
if err := guardImageURL(u); err != nil {
|
||
return "", nil, err
|
||
}
|
||
guarded := guardedImageClient(client)
|
||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("vision: %w: 无法构造请求", ErrFetch)
|
||
}
|
||
resp, err := guarded.Do(req)
|
||
if err != nil {
|
||
// 被拒地址直接返回类型化错误:它只含主机与网段原因,
|
||
// 不像 client.Do 的 *url.Error 那样把整条 URL(可能含凭据)带出来。
|
||
if blocked, ok := blockedAddressOf(err); ok {
|
||
return "", nil, blocked
|
||
}
|
||
return "", nil, fmt.Errorf("vision: %w: %w", ErrFetch, fetchCause(err))
|
||
}
|
||
defer func() { _ = resp.Body.Close() }()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return "", nil, fmt.Errorf("vision: %w: status %d", ErrFetch, resp.StatusCode)
|
||
}
|
||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxFetchedBody))
|
||
if err != nil {
|
||
return "", nil, fmt.Errorf("vision: %w: %v", ErrFetch, err)
|
||
}
|
||
mime, err := imageMIMEFor(resp.Header.Get("Content-Type"), data)
|
||
if err != nil {
|
||
return "", nil, err
|
||
}
|
||
return mime, data, nil
|
||
}
|
||
|
||
// inlinedImage 是一次「取回 + 内联」的产物。MIME 与 Raw 只为按内容哈希
|
||
// 缓存而返回(见 cache.go 的 imageCache),绝不写进日志。
|
||
type inlinedImage struct {
|
||
// URI 是可供模型直接消费的 data: URI。
|
||
URI string
|
||
// MIME 是归一化后的图像 MIME;data: 输入(纯短路路径)时为空。
|
||
MIME string
|
||
// Raw 是内联前的原始字节;data: 输入(纯短路路径)时为 nil。
|
||
Raw []byte
|
||
}
|
||
|
||
// inlineImageSrc 把图像来源字符串取回并内联为 data: URI,供主模型
|
||
// 直接消费。已是 data: URI 的原样返回(客户端现有的 base64 路径因此
|
||
// 逐字节不变),但**短路之前**也要过长度预检:这条路径不解码,不预检
|
||
// 就等于把体积上限完全交给入口层。URL 取回复用 Resolve→fetchURL 的既有
|
||
// 守卫(协议白名单 + 体积上限 + 地址守卫),并加取回超时。
|
||
func inlineImageSrc(ctx context.Context, src string) (string, error) {
|
||
img, err := inlineImage(ctx, src)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return img.URI, nil
|
||
}
|
||
|
||
// inlineImage 与 inlineImageSrc 同一条路径,但额外返回内联前的 MIME 与
|
||
// 原始字节,供 cache.go 按内容哈希 memo 化(同一 URL 不再每回合重复取回)。
|
||
func inlineImage(ctx context.Context, src string) (inlinedImage, error) {
|
||
if strings.HasPrefix(src, "data:") {
|
||
if err := checkInlineImageLength(src); err != nil {
|
||
return inlinedImage{}, err
|
||
}
|
||
return inlinedImage{URI: src}, nil
|
||
}
|
||
ref, err := ParseImageRef(src)
|
||
if err != nil {
|
||
return inlinedImage{}, err
|
||
}
|
||
if ref.Source == SourceURL {
|
||
var cancel context.CancelFunc
|
||
ctx, cancel = context.WithTimeout(ctx, imageFetchTimeout)
|
||
defer cancel()
|
||
}
|
||
mime, data, err := Resolve(ctx, ref, nil)
|
||
if err != nil {
|
||
return inlinedImage{}, err
|
||
}
|
||
return inlinedImage{URI: DataURI(mime, data), MIME: mime, Raw: data}, nil
|
||
}
|
||
|
||
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是 data: URI、
|
||
// 或虽是 data: URI 但不带 ;base64 段时返回 false。
|
||
//
|
||
// 只认 base64 载荷(与 image-crop 的 decodeCropDataURI 同口径):百分号编码的
|
||
// data: URI 从来就不是本包的输入,把它当 base64 解码只会得到一段垃圾字节,
|
||
// 还挂着 image/png 的假声明送进模型 —— 这类串现在按畸形 data: URI 拒绝。
|
||
// 返回的 MIME 按包内统一口径归一化(去参数、转小写,见 mediaType);声明缺失
|
||
// (`data:;base64,…`)时给 image/png,与本函数历来的默认一致。
|
||
func splitDataURI(src string) (mime, payload string, ok bool) {
|
||
if !strings.HasPrefix(src, "data:") {
|
||
return "", "", false
|
||
}
|
||
rest := strings.TrimPrefix(src, "data:")
|
||
comma := strings.IndexByte(rest, ',')
|
||
if comma < 0 {
|
||
return "", "", false
|
||
}
|
||
meta := rest[:comma]
|
||
idx := strings.Index(meta, ";base64")
|
||
if idx < 0 {
|
||
return "", "", false
|
||
}
|
||
mime = mediaType(meta[:idx])
|
||
if mime == "" {
|
||
mime = "image/png"
|
||
}
|
||
return mime, rest[comma+1:], true
|
||
}
|
||
|
||
// fileExists 报告 path 是否存在于磁盘且为常规文件。
|
||
func fileExists(path string) bool {
|
||
info, err := os.Stat(path)
|
||
return err == nil && info.Mode().IsRegular()
|
||
}
|
||
|
||
// defaultHTTPClient 构造 DevDocs/02-development-constraints.md §2.3
|
||
// 所述的 HTTP client。这里每次调用返回一个新 client,
|
||
// 而不是包级全局变量——后者为约束所禁止。
|
||
func defaultHTTPClient() *http.Client {
|
||
return &http.Client{Transport: http.DefaultTransport}
|
||
}
|