根因(从 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 用例红)
63 lines
3.5 KiB
Go
63 lines
3.5 KiB
Go
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, " ")
|
||
}
|