根因(从 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 用例红)
102 lines
3.1 KiB
Go
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)
|
|
}
|
|
}
|