| 1 | //go:build live |
| 2 | |
| 3 | package imageinput_test |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "crypto/rand" |
| 9 | "encoding/base64" |
| 10 | "image" |
| 11 | "image/color" |
| 12 | "image/draw" |
| 13 | "image/png" |
| 14 | "os" |
| 15 | "strings" |
| 16 | "testing" |
| 17 | "time" |
| 18 | |
| 19 | "golang.org/x/image/font" |
| 20 | "golang.org/x/image/font/basicfont" |
| 21 | "golang.org/x/image/math/fixed" |
| 22 | "reasonix/internal/config" |
| 23 | "reasonix/internal/event" |
| 24 | "reasonix/internal/imageinput" |
| 25 | "reasonix/internal/provider" |
| 26 | "reasonix/internal/provider/anthropic" |
| 27 | "reasonix/internal/provider/openai" |
| 28 | "reasonix/internal/provider/responses" |
| 29 | ) |
| 30 | |
| 31 | func fixture(t *testing.T) (string, string) { |
| 32 | t.Helper() |
| 33 | var b [4]byte |
| 34 | if _, err := rand.Read(b[:]); err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | code := "" |
| 38 | for _, v := range b { |
| 39 | code += string("KRTXYZ"[int(v)%6]) |
| 40 | } |
| 41 | im := image.NewRGBA(image.Rect(0, 0, 400, 160)) |
| 42 | draw.Draw(im, im.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) |
| 43 | draw.Draw(im, image.Rect(10, 70, 180, 150), image.NewUniform(color.RGBA{255, 0, 0, 255}), image.Point{}, draw.Src) |
| 44 | draw.Draw(im, image.Rect(220, 70, 390, 150), image.NewUniform(color.RGBA{0, 0, 255, 255}), image.Point{}, draw.Src) |
| 45 | // Scale a small bitmap font so random text is legible without external assets. |
| 46 | label := image.NewRGBA(image.Rect(0, 0, 80, 20)) |
| 47 | draw.Draw(label, label.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src) |
| 48 | d := font.Drawer{Dst: label, Src: image.NewUniform(color.Black), Face: basicfont.Face7x13, Dot: fixed.P(2, 14)} |
| 49 | d.DrawString(strings.Join(strings.Split(code, ""), " ")) |
| 50 | for y := range 60 { |
| 51 | for x := range 240 { |
| 52 | im.Set(60+x, y, label.At(x/3, y/3)) |
| 53 | } |
| 54 | } |
| 55 | var out bytes.Buffer |
| 56 | if err := png.Encode(&out, im); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | return "data:image/png;base64," + base64.StdEncoding.EncodeToString(out.Bytes()), code |
| 60 | } |
| 61 | func collect(t *testing.T, p provider.Provider, r provider.Request) string { |
| 62 | t.Helper() |
| 63 | ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) |
| 64 | defer cancel() |
| 65 | ch, err := p.Stream(ctx, r) |
| 66 | if err != nil { |
| 67 | t.Fatalf("request failed (%T); no request or credentials logged", err) |
| 68 | } |
| 69 | var out strings.Builder |
| 70 | for { |
| 71 | select { |
| 72 | case <-ctx.Done(): |
| 73 | t.Fatal("request timed out") |
| 74 | case c, ok := <-ch: |
| 75 | if !ok { |
| 76 | return out.String() |
| 77 | } |
| 78 | if c.Err != nil { |
| 79 | t.Fatalf("stream failed (%T); no payload logged", c.Err) |
| 80 | } |
| 81 | if c.Type == provider.ChunkText { |
| 82 | out.WriteString(c.Text) |
| 83 | } |
| 84 | } |
| 85 | } |
| 86 | } |
| 87 | func TestLiveToolImages(t *testing.T) { |
| 88 | if os.Getenv("REASONIX_LIVE_TOOL_IMAGES") != "1" { |
| 89 | t.Skip("live probe disabled") |
| 90 | } |
| 91 | cfg, err := config.LoadForRootReadOnly("../..") |
| 92 | if err != nil { |
| 93 | t.Fatal("configuration unavailable") |
| 94 | } |
| 95 | key := "" |
| 96 | for _, p := range cfg.Providers { |
| 97 | if openai.IsDeepSeek(p.BaseURL) && p.APIKey() != "" { |
| 98 | key = p.APIKey() |
| 99 | break |
| 100 | } |
| 101 | } |
| 102 | if key == "" { |
| 103 | t.Skip("official DeepSeek credentials unavailable") |
| 104 | } |
| 105 | makeProvider := func(protocol, model string) provider.Provider { |
| 106 | cfg := provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: model, APIKey: key, Extra: map[string]any{"thinking": "disabled"}} |
| 107 | if protocol == "responses" { |
| 108 | return responses.New(responses.Config{Name: "deepseek", BaseURL: cfg.BaseURL, Model: model, APIKey: key, Mode: "stateless", MaxOutputTokens: 2048, Extra: cfg.Extra}) |
| 109 | } |
| 110 | var p provider.Provider |
| 111 | var e error |
| 112 | if protocol == "anthropic" { |
| 113 | cfg.BaseURL += "/anthropic" |
| 114 | p, e = anthropic.New(cfg) |
| 115 | } else { |
| 116 | p, e = openai.New(cfg) |
| 117 | } |
| 118 | if e != nil { |
| 119 | t.Fatal("provider construction failed") |
| 120 | } |
| 121 | return p |
| 122 | } |
| 123 | for _, protocol := range []string{"chat", "responses", "anthropic"} { |
| 124 | t.Run(protocol, func(t *testing.T) { |
| 125 | ref, code := fixture(t) |
| 126 | p := makeProvider(protocol, openai.OfficialDeepSeekVisionModel) |
| 127 | r := provider.Request{MaxTokens: 2048, Messages: []provider.Message{ |
| 128 | {Role: provider.RoleUser, Content: "Return JSON with fields code (the printed letters and digits at the top of the image), left_color, right_color. Do not omit code. If unreadable say unreadable."}, |
| 129 | {Role: provider.RoleAssistant, ReasoningContent: "I will inspect the image using the tool.", ToolCalls: []provider.ToolCall{{ID: "image1", Name: "view_image", Arguments: `{"path":"fixture.png"}`}}}, |
| 130 | {Role: provider.RoleTool, ToolCallID: "image1", Name: "view_image", Content: "image loaded", Images: []string{ref}}, |
| 131 | }} |
| 132 | out := collect(t, p, r) |
| 133 | if !strings.Contains(strings.ReplaceAll(strings.ToUpper(out), " ", ""), code) || !strings.Contains(strings.ToLower(out), "red") || !strings.Contains(strings.ToLower(out), "blue") { |
| 134 | t.Fatalf("image semantic verification failed: %s", out) |
| 135 | } |
| 136 | }) |
| 137 | } |
| 138 | vp := makeProvider("chat", openai.OfficialDeepSeekVisionModel) |
| 139 | for _, mode := range []string{"explicit", "auto"} { |
| 140 | t.Run(mode, func(t *testing.T) { |
| 141 | target := "deepseek/" + openai.OfficialDeepSeekVisionModel |
| 142 | selected := 0 |
| 143 | c := imageinput.Config{Model: target, Resolve: func(string) (provider.Provider, error) { return vp, nil }, Select: func(current, mode string) (string, bool) { selected++; return target, true }} |
| 144 | if mode == "auto" { |
| 145 | c.Model = "auto" |
| 146 | } |
| 147 | svc := imageinput.New(c) |
| 148 | for range 2 { |
| 149 | ref, code := fixture(t) |
| 150 | ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) |
| 151 | summary, e := svc.Understand(ctx, "deepseek/deepseek-v4-flash", []string{ref}, nil, event.Discard) |
| 152 | cancel() |
| 153 | if e != nil { |
| 154 | t.Fatalf("summary failed (%T)", e) |
| 155 | } |
| 156 | text := collect(t, makeProvider("chat", "deepseek-v4-flash"), provider.Request{MaxTokens: 1024, Messages: []provider.Message{{Role: provider.RoleUser, Content: imageinput.AppendSummary("Return JSON with code, left_color, right_color from the supplied summary. Do not omit code.", summary)}}}) |
| 157 | if !strings.Contains(strings.ReplaceAll(strings.ToUpper(text), " ", ""), code) { |
| 158 | t.Fatalf("summary relay failed: summary=%s answer=%s", summary.Summary, text) |
| 159 | } |
| 160 | } |
| 161 | if mode == "auto" && selected != 2 { |
| 162 | t.Fatal("auto selector was not used") |
| 163 | } |
| 164 | }) |
| 165 | } |
| 166 | } |
| 167 |