Files
AskaEth 789e3af74e fix(multimodal): 视觉工具的空/畸形 data: URI 明确拒绝 + 入参嗅探与可行动报错
根因(从 checkpoints 取到的那一轮 tool_call 实际 ARGS 逐字节核实):
模型交给工具的 image 参数是 "data:image/png;base64," —— 只有前缀、
载荷为空(全长 22 字符)。两个加载器早就会剥 data: 前缀,但把空载荷
解码成零字节后照样挂上合法 MIME 放行:
  image-crop → 零字节进 image.Decode ⇒ image: unknown format
  vision     → DataURI("image/png", nil) 发给上游 ⇒ 400 unsupported image
(模型复述不出入参里那条 58583 字符的 data: URI,只抄下了前缀。)

- 新增 checkInlinePayload:data: URI 解码后做内容嗅探(复用既有裁决)
- 空载荷 / 缺 ;base64 段 / 缺逗号 / 非法 base64 ⇒ 明确拒绝
- 报错含「不是图片」+ 嗅到的类型 + 支持格式 + 十六进制物证 + 可行动提示,
  且不回显正文或 base64
- 裸 base64、http(s) URL、工具 schema、安全策略一律未动
- 测试:新增 16 个用例;全树 61 包 ok;4 个变异(含停用嗅探 ⇒ 5 用例红)
2026-10-07 18:50:45 +08:00

63 lines
3.5 KiB
Go
Raw Permalink 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 vision
import (
"fmt"
"strings"
)
// 本文件管的是 **data:/base64 入参**的内容嗅探与可行动报错 —— 与 mime.go 的
// imageMIMEFor 是同一套裁决(真实字节是否决权、声明只作参考),只是换了入口:
// mime.go 管 HTTP 响应与文件读取,这里管「调用方把图片正文直接塞进参数」那条路。
//
// 起因(2026-10-07 18:32:51 实测,客户端从后端逐字节取过证据):模型交给
// vision 工具的 image 是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
// 旧代码把它 base64 解码得到零字节,却照样报 MIME=image/png,于是那串空载荷
// 挂着 image/png 的声明白白发给上游,换回一句「You have uploaded an unsupported
// image」。报错指向不了真正的毛病(参数里根本没有图片字节),模型也无从自纠;
// image-crop 在同一秒也栽在同一处,报的是 `image: unknown format`。
// 现在这类输入在本地就被明确否决,并附上「它看起来是什么」的物证与可行动提示。
// supportedFormats 是视觉上游接受的图像格式(与上游白名单同口径:
// webp / png / jpeg / gif)。写进错误文案,模型据此换格式重试。
const supportedFormats = "webp, png, jpeg, gif"
// reuseHint 是「把非图片当图片传」时的可行动提示。模型复述不出会话里那张图的
// 几万字符 base64(实测就是把前缀抄了下来),正确做法是传它的 URL 或本地路径。
const reuseHint = "pass the image's http(s) URL or local path instead of retyping a data: URI"
// evidenceBytes 是报错时作为物证给出的头部字节数。
const evidenceBytes = 16
// checkInlinePayload 校验 data: URI 解码后的载荷,返回应当声明的 MIME 与载荷
// 本身。判据与路径/URL 来源完全一致(复用 imageMIMEFor):判出文本一律否决,
// 判不出图像而声明也立不住时同样否决 —— 绝不把一段不是图片的字节挂着
// image/* 的声明送进视觉模型。
//
// 返回的 MIME 取 data: 前缀里**声明**的那个,而不是嗅探结果:这条路径的既有
// 契约是「客户端送来的 data: URI 逐字节不变地继续工作」
// (url_test.go 的 TestOpenAICompatibleAdapter_Base64Unchanged),换掉声明
// 就等于改客户端的线上形状;嗅探在这里只当否决权用。
func checkInlinePayload(declared string, data []byte) (string, []byte, error) {
if len(data) == 0 {
return "", nil, fmt.Errorf("vision: %w: %s payload is empty — the data: URI carries no base64 bytes; hint: %s",
ErrNotImage, declaredLabel(declared), reuseHint)
}
if _, err := imageMIMEFor(declared, data); err != nil {
return "", nil, fmt.Errorf("vision: %w: %s payload is %s; supported formats: %s; leading bytes: %s; hint: %s",
ErrNotImage, declaredLabel(declared), mediaType(sniffMIME(data)), supportedFormats, hexHead(data), reuseHint)
}
return declared, data, nil
}
// hexHead 把载荷开头若干字节渲染成十六进制,作为「它看起来是什么」的物证。
// 只给固定长度的头部 —— **绝不回显正文**:载荷可能很大,也可能含隐私内容
// (同 imageMIMEFor 的口径:错误信息只含类型名,不含图像字节)。
func hexHead(data []byte) string {
n := min(len(data), evidenceBytes)
parts := make([]string, 0, n)
for _, b := range data[:n] {
parts = append(parts, fmt.Sprintf("%02x", b))
}
return strings.Join(parts, " ")
}