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 用例红)
This commit is contained in:
AskaEth committed 2026-10-07 18:50:45 +08:00
1 parent da1d23dd23
commit 789e3af74e
8 files changed
+636 -24

No files matched your search

@@ -0,0 +1,62 @@
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, " ")
}