返回 DeepSeek-Reasonix
image_test.go
根目录 / internal / provider / openai / image_test.go
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
270 lines GO