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

102 lines
3.1 KiB
Go

package vision
import (
"bytes"
"context"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
func TestDetectMultimodal(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msgs []model.Message
want bool
}{
{name: "text only", msgs: []model.Message{model.TextMessage(model.RoleUser, "hi")}, want: false},
{name: "empty", msgs: nil, want: false},
{name: "single image", msgs: []model.Message{model.ImageMessage(model.RoleUser, "what", "data:image/png;base64,abc")}, want: true},
{name: "image no text", msgs: []model.Message{model.ImageMessage(model.RoleUser, "", "https://x/y.png")}, want: true},
{name: "image in later turn", msgs: []model.Message{
model.TextMessage(model.RoleUser, "hi"),
model.ImageMessage(model.RoleAssistant, "", "http://x/z.png"),
}, want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := DetectMultimodal(tt.msgs); got != tt.want {
t.Errorf("DetectMultimodal() = %v, want %v", got, tt.want)
}
})
}
}
func TestParseImageRef(t *testing.T) {
t.Parallel()
tests := []struct {
name string
src string
wantSource ImageSource
}{
{name: "http url", src: "https://a/b.png", wantSource: SourceURL},
{name: "https url", src: "https://a/b.png", wantSource: SourceURL},
{name: "file uri", src: "file:///tmp/a.png", wantSource: SourcePath},
{name: "data uri", src: "data:image/png;base64,abc", wantSource: SourceBase64},
{name: "raw base64", src: "aGVsbG8=", wantSource: SourceBase64},
{name: "empty rejected", src: "", wantSource: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ref, err := ParseImageRef(tt.src)
if tt.src == "" {
if err == nil {
t.Fatal("want error for empty source, got nil")
}
return
}
if err != nil {
t.Fatalf("ParseImageRef error: %v", err)
}
if ref.Source != tt.wantSource {
t.Errorf("Source = %q, want %q", ref.Source, tt.wantSource)
}
})
}
}
// TestResolve_Base64 钉住 data: URI 那条路:载荷被解码成原始字节,MIME 取前缀
// 里声明的那个。**载荷必须是真的图片字节** —— data: URI 带声明,解码后要过内容
// 嗅探(image.go 的 checkInlinePayload),拿非图片字节(如 "hello")当载荷
// 现在会被明确否决,而不是挂着 image/png 的声明送进模型。
func TestResolve_Base64(t *testing.T) {
t.Parallel()
png := pngBytes(t)
ref, err := ParseImageRef(DataURI("image/png", png))
if err != nil {
t.Fatalf("ParseImageRef error: %v", err)
}
mime, data, err := Resolve(context.Background(), ref, nil)
if err != nil {
t.Fatalf("Resolve error: %v", err)
}
if mime != "image/png" {
t.Errorf("mime = %q, want image/png", mime)
}
if !bytes.Equal(data, png) {
t.Errorf("data = %d bytes, want the decoded payload (%d bytes)", len(data), len(png))
}
}
func TestResolve_DataURI(t *testing.T) {
t.Parallel()
got := DataURI("image/webp", []byte{1, 2, 3})
if !strings.HasPrefix(got, "data:image/webp;base64,") {
t.Errorf("DataURI = %q, want data:image/webp;base64,", got)
}
}