package vision import ( "bytes" "context" "encoding/json" "errors" "image" "image/png" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" "github.com/kami/maven/internal/media" ) func testImage(t *testing.T) media.Image { t.Helper() var buf bytes.Buffer if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 32, 32))); err != nil { t.Fatal(err) } im, err := media.PrepareImage(buf.Bytes(), "test", 32) if err != nil { t.Fatal(err) } return im } func TestDisabledAlwaysRefuses(t *testing.T) { _, err := Disabled{}.Describe(context.Background(), testImage(t), "что тут?") if !errors.Is(err, ErrDisabled) { t.Fatalf("got %v, want ErrDisabled", err) } } // The whole reason this package has its own HTTP client instead of reusing // internal/llm.Client: a vision endpoint that is not on this box is refused. func TestNewLocalRefusesNonPrivateEndpoints(t *testing.T) { bad := []string{ "https://api.openai.com", "http://8.8.8.8:8080", "https://vision.example.com", // a name could resolve anywhere "ftp://127.0.0.1:8080", // wrong scheme "", // nothing to talk to } for _, ep := range bad { if _, err := NewLocal(Config{Endpoint: ep}); err == nil { t.Errorf("NewLocal(%q) was accepted", ep) } } } func TestNewLocalAcceptsLocalEndpoints(t *testing.T) { for _, ep := range []string{"http://127.0.0.1:8081", "http://localhost:8081/", "http://192.168.1.104:8081", "http://[::1]:8081"} { p, err := NewLocal(Config{Endpoint: ep}) if err != nil { t.Errorf("NewLocal(%q): %v", ep, err) continue } if strings.HasSuffix(p.Endpoint(), "/") { t.Errorf("trailing slash kept: %q", p.Endpoint()) } } } func TestDescribeSendsImageAsDataURIAndReturnsText(t *testing.T) { var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/chat/completions" { t.Errorf("path = %s", r.URL.Path) } raw, _ := io.ReadAll(r.Body) if err := json.Unmarshal(raw, &gotBody); err != nil { t.Errorf("unmarshal request: %v", err) } _, _ = w.Write([]byte(`{"choices":[{"message":{"content":" На картинке кот "}}]}`)) })) defer srv.Close() p, err := NewLocal(Config{Endpoint: srv.URL, Model: "qwen-vl"}) if err != nil { t.Fatal(err) } text, err := p.Describe(context.Background(), testImage(t), "кто на фото?") if err != nil { t.Fatalf("describe: %v", err) } if text != "На картинке кот" { t.Errorf("text = %q (should be trimmed)", text) } msgs, ok := gotBody["messages"].([]any) if !ok || len(msgs) != 1 { t.Fatalf("messages = %#v", gotBody["messages"]) } parts, ok := msgs[0].(map[string]any)["content"].([]any) if !ok || len(parts) != 2 { t.Fatalf("content parts = %#v", msgs[0]) } if got := parts[0].(map[string]any)["text"]; got != "кто на фото?" { t.Errorf("prompt = %v", got) } url := parts[1].(map[string]any)["image_url"].(map[string]any)["url"].(string) if !strings.HasPrefix(url, "data:image/jpeg;base64,") { t.Errorf("image not sent as a jpeg data uri: %.40s", url) } } func TestDescribeUsesDefaultPromptWhenNoQuestion(t *testing.T) { var sentPrompt string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { Messages []struct { Content []struct { Text string `json:"text"` } `json:"content"` } `json:"messages"` } _ = json.NewDecoder(r.Body).Decode(&body) sentPrompt = body.Messages[0].Content[0].Text _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ок"}}]}`)) })) defer srv.Close() p, err := NewLocal(Config{Endpoint: srv.URL, Prompt: "Опиши по-русски."}) if err != nil { t.Fatal(err) } if _, err := p.Describe(context.Background(), testImage(t), " "); err != nil { t.Fatal(err) } if sentPrompt != "Опиши по-русски." { t.Errorf("prompt = %q", sentPrompt) } } // A Thinking model sometimes leaves content empty and puts the answer in // reasoning_content; internal/llm has the same fallback and vision needs it too. func TestDescribeFallsBackToReasoningContent(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","reasoning_content":"схема платы"}}]}`)) })) defer srv.Close() p, _ := NewLocal(Config{Endpoint: srv.URL}) text, err := p.Describe(context.Background(), testImage(t), "") if err != nil { t.Fatal(err) } if text != "схема платы" { t.Errorf("text = %q", text) } } func TestDescribeErrors(t *testing.T) { t.Run("no choices", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"choices":[]}`)) })) defer srv.Close() p, _ := NewLocal(Config{Endpoint: srv.URL}) if _, err := p.Describe(context.Background(), testImage(t), ""); !errors.Is(err, ErrEmptyReply) { t.Errorf("got %v, want ErrEmptyReply", err) } }) t.Run("server error", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) defer srv.Close() p, _ := NewLocal(Config{Endpoint: srv.URL}) if _, err := p.Describe(context.Background(), testImage(t), ""); err == nil { t.Error("500 was not an error") } }) t.Run("empty image", func(t *testing.T) { p, _ := NewLocal(Config{Endpoint: "http://127.0.0.1:1"}) if _, err := p.Describe(context.Background(), media.Image{}, ""); !errors.Is(err, media.ErrEmpty) { t.Errorf("got %v, want media.ErrEmpty", err) } }) t.Run("context cancelled", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { time.Sleep(200 * time.Millisecond) _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"поздно"}}]}`)) })) defer srv.Close() p, _ := NewLocal(Config{Endpoint: srv.URL}) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() if _, err := p.Describe(ctx, testImage(t), ""); err == nil { t.Error("cancelled context returned no error") } }) } // checkPrivate validates the configured literal and used to validate nothing // else. A 302 from the local llama-server would have sent the photo, as a data // URI in the POST body, wherever the redirect named. func TestLocalProviderDoesNotFollowARedirect(t *testing.T) { var elsewhere int32 away := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { atomic.AddInt32(&elsewhere, 1) _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"leaked"}}]}`) })) defer away.Close() local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, away.URL+"/v1/chat/completions", http.StatusFound) })) defer local.Close() p, err := NewLocal(Config{Endpoint: local.URL}) if err != nil { t.Fatal(err) } im := media.Image{JPEG: []byte{0xFF, 0xD8, 0xFF}} if _, err := p.Describe(context.Background(), im, "что это"); err == nil { t.Fatal("a redirected describe must fail, not follow") } if n := atomic.LoadInt32(&elsewhere); n != 0 { t.Fatalf("the image was sent to the redirect target %d time(s)", n) } } // The reply is read through a cap. A stuck endpoint should not cost the daemon // its memory. func TestLocalProviderCapsTheReply(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"`) for written := 0; written < MaxReplyBytes+(1<<20); written += 1 << 16 { if _, err := io.WriteString(w, strings.Repeat("a", 1<<16)); err != nil { return } } })) defer srv.Close() p, err := NewLocal(Config{Endpoint: srv.URL}) if err != nil { t.Fatal(err) } im := media.Image{JPEG: []byte{0xFF, 0xD8, 0xFF}} if _, err := p.Describe(context.Background(), im, ""); err == nil { t.Fatal("an unbounded reply must fail rather than being read whole") } } // ValidateEndpoint is what config calls at startup, and it must agree with the // constructor. func TestValidateEndpointMatchesTheConstructor(t *testing.T) { for _, raw := range []string{"http://127.0.0.1:8081", "http://localhost:8081/"} { if err := ValidateEndpoint(raw); err != nil { t.Errorf("ValidateEndpoint(%q) = %v", raw, err) } } for _, raw := range []string{"http://8.8.8.8:8081", "http://vision.example.com", "ftp://127.0.0.1"} { if err := ValidateEndpoint(raw); err == nil { t.Errorf("ValidateEndpoint(%q) accepted a non-private endpoint", raw) } if _, err := NewLocal(Config{Endpoint: raw}); err == nil { t.Errorf("NewLocal(%q) accepted what ValidateEndpoint should refuse", raw) } } }