| 1 | package openai |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/provider" |
| 10 | ) |
| 11 | |
| 12 | func TestBuildRequestEmbedsImagesForVisionModel(t *testing.T) { |
| 13 | c := &client{model: "gpt-4o", vision: true} |
| 14 | req := c.buildRequest(provider.Request{ |
| 15 | Messages: []provider.Message{ |
| 16 | {Role: provider.RoleUser, Content: "what is this", Images: []string{"data:image/png;base64,AAAA"}}, |
| 17 | }, |
| 18 | }) |
| 19 | parts, ok := req.Messages[0].Content.([]chatContentPart) |
| 20 | if !ok { |
| 21 | t.Fatalf("vision user content = %T, want []chatContentPart", req.Messages[0].Content) |
| 22 | } |
| 23 | if len(parts) != 2 || parts[0].Type != "text" || parts[1].Type != "image_url" { |
| 24 | t.Fatalf("parts = %+v, want [text, image_url]", parts) |
| 25 | } |
| 26 | if parts[1].ImageURL == nil || parts[1].ImageURL.URL != "data:image/png;base64,AAAA" { |
| 27 | t.Fatalf("image_url = %+v, want the data URL", parts[1].ImageURL) |
| 28 | } |
| 29 | body, _ := json.Marshal(req.Messages[0]) |
| 30 | if !strings.Contains(string(body), `"type":"image_url"`) { |
| 31 | t.Errorf("serialized content missing image_url part: %s", body) |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | func TestBuildRequestSkipsImagesWithoutVision(t *testing.T) { |
| 36 | c := &client{model: "deepseek-v4"} // vision unset |
| 37 | req := c.buildRequest(provider.Request{ |
| 38 | Messages: []provider.Message{ |
| 39 | {Role: provider.RoleUser, Content: "ignore the image", Images: []string{"data:image/png;base64,AAAA"}}, |
| 40 | }, |
| 41 | }) |
| 42 | if s, ok := req.Messages[0].Content.(string); !ok || s != "ignore the image" { |
| 43 | t.Fatalf("non-vision content = %#v, want plain string", req.Messages[0].Content) |
| 44 | } |
| 45 | } |
| 46 | |
| 47 | func TestOfficialDeepSeekProviderWideVisionInputMatchesTextOnlyRequest(t *testing.T) { |
| 48 | p, err := New(provider.Config{ |
| 49 | Name: "deepseek", |
| 50 | BaseURL: "https://api.deepseek.com", |
| 51 | Model: "deepseek-v4-pro", |
| 52 | Extra: map[string]any{"vision": true}, |
| 53 | }) |
| 54 | if err != nil { |
| 55 | t.Fatalf("New: %v", err) |
| 56 | } |
| 57 | c := p.(*client) |
| 58 | if c.vision { |
| 59 | t.Fatal("official DeepSeek endpoint must ignore stale vision=true config") |
| 60 | } |
| 61 | |
| 62 | textOnly := provider.Request{Messages: []provider.Message{{ |
| 63 | Role: provider.RoleUser, Content: "describe this image", |
| 64 | }}} |
| 65 | withImage := provider.Request{Messages: []provider.Message{{ |
| 66 | Role: provider.RoleUser, Content: "describe this image", |
| 67 | Images: []string{"data:image/png;base64," + strings.Repeat("QUFB", 20_000)}, |
| 68 | }}} |
| 69 | textBody, err := json.Marshal(c.buildRequest(textOnly)) |
| 70 | if err != nil { |
| 71 | t.Fatalf("marshal text request: %v", err) |
| 72 | } |
| 73 | imageBody, err := json.Marshal(c.buildRequest(withImage)) |
| 74 | if err != nil { |
| 75 | t.Fatalf("marshal image request: %v", err) |
| 76 | } |
| 77 | if !bytes.Equal(imageBody, textBody) { |
| 78 | t.Fatalf("official DeepSeek image request changed provider-visible bytes:\ntext: %s\nimage: %s", textBody, imageBody) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func TestOfficialDeepSeekExplicitFutureVisionInputUsesImageParts(t *testing.T) { |
| 83 | p, err := New(provider.Config{ |
| 84 | Name: "deepseek", |
| 85 | BaseURL: "https://api.deepseek.com", |
| 86 | Model: "deepseek-v5-vision", |
| 87 | Extra: map[string]any{ |
| 88 | "vision": true, |
| 89 | "vision_model_explicit": true, |
| 90 | }, |
| 91 | }) |
| 92 | if err != nil { |
| 93 | t.Fatalf("New: %v", err) |
| 94 | } |
| 95 | c := p.(*client) |
| 96 | if !c.vision { |
| 97 | t.Fatal("explicit model-scoped vision must remain enabled on the official DeepSeek endpoint") |
| 98 | } |
| 99 | |
| 100 | req := c.buildRequest(provider.Request{Messages: []provider.Message{{ |
| 101 | Role: provider.RoleUser, Content: "describe", |
| 102 | Images: []string{"data:image/png;base64,AAAA"}, |
| 103 | }}}) |
| 104 | parts, ok := req.Messages[0].Content.([]chatContentPart) |
| 105 | if !ok || len(parts) != 2 || parts[1].ImageURL == nil { |
| 106 | t.Fatalf("explicit future DeepSeek content = %#v, want [text, image_url]", req.Messages[0].Content) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | func TestOfficialDeepSeekDoesNotInjectToolResultImages(t *testing.T) { |
| 111 | p, err := New(provider.Config{ |
| 112 | Name: "deepseek", |
| 113 | BaseURL: "https://api.deepseek.com/v1", |
| 114 | Model: "deepseek-v4-pro", |
| 115 | Extra: map[string]any{"vision": true}, |
| 116 | }) |
| 117 | if err != nil { |
| 118 | t.Fatalf("New: %v", err) |
| 119 | } |
| 120 | req := p.(*client).buildRequest(provider.Request{Messages: []provider.Message{ |
| 121 | {Role: provider.RoleUser, Content: "take a screenshot"}, |
| 122 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ |
| 123 | ID: "c1", Name: "shot", Arguments: "{}", |
| 124 | }}}, |
| 125 | { |
| 126 | Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", |
| 127 | Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"}, |
| 128 | }, |
| 129 | }}) |
| 130 | if len(req.Messages) != 3 { |
| 131 | t.Fatalf("messages = %d, want 3 without an injected image message", len(req.Messages)) |
| 132 | } |
| 133 | body, err := json.Marshal(req) |
| 134 | if err != nil { |
| 135 | t.Fatalf("marshal request: %v", err) |
| 136 | } |
| 137 | if strings.Contains(string(body), "image_url") || strings.Contains(string(body), "base64,AAAA") { |
| 138 | t.Fatalf("official DeepSeek request leaked tool image payload: %s", body) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | func TestCustomDeepSeekProtocolGatewayPreservesExplicitVision(t *testing.T) { |
| 143 | p, err := New(provider.Config{ |
| 144 | Name: "deepseek-gateway", |
| 145 | BaseURL: "https://gateway.example/v1", |
| 146 | Model: "deepseek-v4-pro", |
| 147 | Extra: map[string]any{ |
| 148 | "reasoning_protocol": "deepseek", |
| 149 | "vision": true, |
| 150 | }, |
| 151 | }) |
| 152 | if err != nil { |
| 153 | t.Fatalf("New: %v", err) |
| 154 | } |
| 155 | c := p.(*client) |
| 156 | if !c.deepseek || !c.vision { |
| 157 | t.Fatalf("deepseek=%v vision=%v, want both enabled", c.deepseek, c.vision) |
| 158 | } |
| 159 | req := c.buildRequest(provider.Request{Messages: []provider.Message{{ |
| 160 | Role: provider.RoleUser, Content: "describe", |
| 161 | Images: []string{"data:image/png;base64,AAAA"}, |
| 162 | }}}) |
| 163 | parts, ok := req.Messages[0].Content.([]chatContentPart) |
| 164 | if !ok || len(parts) != 2 || parts[1].ImageURL == nil { |
| 165 | t.Fatalf("custom gateway content = %#v, want [text, image_url]", req.Messages[0].Content) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestImageURLDetailFromConfig(t *testing.T) { |
| 170 | c := &client{model: "gpt-4o", vision: true, visionDetail: "low"} |
| 171 | req := c.buildRequest(provider.Request{ |
| 172 | Messages: []provider.Message{ |
| 173 | {Role: provider.RoleUser, Content: "x", Images: []string{"data:image/png;base64,AAAA"}}, |
| 174 | }, |
| 175 | }) |
| 176 | parts := req.Messages[0].Content.([]chatContentPart) |
| 177 | if parts[1].ImageURL.Detail != "low" { |
| 178 | t.Fatalf("detail = %q, want low", parts[1].ImageURL.Detail) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func TestImageURLDetailOmittedByDefault(t *testing.T) { |
| 183 | c := &client{model: "gpt-4o", vision: true} |
| 184 | req := c.buildRequest(provider.Request{ |
| 185 | Messages: []provider.Message{ |
| 186 | {Role: provider.RoleUser, Content: "x", Images: []string{"data:image/png;base64,AAAA"}}, |
| 187 | }, |
| 188 | }) |
| 189 | body, _ := json.Marshal(req.Messages[0].Content.([]chatContentPart)[1]) |
| 190 | if strings.Contains(string(body), "detail") { |
| 191 | t.Errorf("detail must be omitted when unset: %s", body) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | // Tool-result images can't ride in the tool message itself (the OpenAI API |
| 196 | // accepts only text parts under role "tool"), so buildRequest injects them as |
| 197 | // a user message after the turn's full run of tool results. |
| 198 | func TestBuildRequestInjectsToolImagesAsUserMessage(t *testing.T) { |
| 199 | c := &client{model: "gpt-4o", vision: true} |
| 200 | req := c.buildRequest(provider.Request{ |
| 201 | Messages: []provider.Message{ |
| 202 | {Role: provider.RoleUser, Content: "screenshot please"}, |
| 203 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{ |
| 204 | {ID: "c1", Name: "shot", Arguments: "{}"}, |
| 205 | {ID: "c2", Name: "shot", Arguments: "{}"}, |
| 206 | }}, |
| 207 | {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"}}, |
| 208 | {Role: provider.RoleTool, ToolCallID: "c2", Name: "shot", Content: "no image"}, |
| 209 | {Role: provider.RoleUser, Content: "and?"}, |
| 210 | }, |
| 211 | }) |
| 212 | if len(req.Messages) != 6 { |
| 213 | t.Fatalf("got %d messages, want 6 (images injected after the tool run)", len(req.Messages)) |
| 214 | } |
| 215 | for i, m := range req.Messages[2:4] { |
| 216 | if _, ok := m.Content.(string); !ok || m.Role != "tool" { |
| 217 | t.Fatalf("message %d = %+v, want tool message with plain string content", i+2, m) |
| 218 | } |
| 219 | } |
| 220 | inj := req.Messages[4] |
| 221 | if inj.Role != "user" { |
| 222 | t.Fatalf("injected message role = %q, want user between tool run and next turn", inj.Role) |
| 223 | } |
| 224 | parts, ok := inj.Content.([]chatContentPart) |
| 225 | if !ok || len(parts) != 2 || parts[0].Type != "text" || parts[1].Type != "image_url" { |
| 226 | t.Fatalf("injected content = %#v, want [text, image_url]", inj.Content) |
| 227 | } |
| 228 | if parts[1].ImageURL == nil || parts[1].ImageURL.URL != "data:image/png;base64,AAAA" { |
| 229 | t.Fatalf("image_url = %+v, want the tool image data URL", parts[1].ImageURL) |
| 230 | } |
| 231 | if req.Messages[5].Content != "and?" { |
| 232 | t.Fatalf("trailing user message displaced: %+v", req.Messages[5]) |
| 233 | } |
| 234 | } |
| 235 | |
| 236 | func TestBuildRequestFlushesTrailingToolImages(t *testing.T) { |
| 237 | c := &client{model: "gpt-4o", vision: true} |
| 238 | req := c.buildRequest(provider.Request{ |
| 239 | Messages: []provider.Message{ |
| 240 | {Role: provider.RoleUser, Content: "go"}, |
| 241 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}}, |
| 242 | {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"}}, |
| 243 | }, |
| 244 | }) |
| 245 | last := req.Messages[len(req.Messages)-1] |
| 246 | if last.Role != "user" { |
| 247 | t.Fatalf("last message = %+v, want the injected image user message", last) |
| 248 | } |
| 249 | if _, ok := last.Content.([]chatContentPart); !ok { |
| 250 | t.Fatalf("last content = %#v, want content parts", last.Content) |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | func TestBuildRequestSkipsToolImagesWithoutVision(t *testing.T) { |
| 255 | c := &client{model: "deepseek-v4"} // vision unset |
| 256 | req := c.buildRequest(provider.Request{ |
| 257 | Messages: []provider.Message{ |
| 258 | {Role: provider.RoleUser, Content: "go"}, |
| 259 | {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}}, |
| 260 | {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"}}, |
| 261 | }, |
| 262 | }) |
| 263 | if len(req.Messages) != 3 { |
| 264 | t.Fatalf("got %d messages, want 3 (no injection without vision)", len(req.Messages)) |
| 265 | } |
| 266 | if s, ok := req.Messages[2].Content.(string); !ok || s != "[image: image/png]" { |
| 267 | t.Fatalf("tool content = %#v, want the plain placeholder string", req.Messages[2].Content) |
| 268 | } |
| 269 | } |
| 270 |