返回 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 TestModelInfoEnablesImageWireSerialization(t *testing.T) {
36 p, err := New(provider.Config{
37 Name: "catalog", BaseURL: "https://example.test/v1", Model: "kimi-k3",
38 ModelInfo: &provider.ModelInfo{ID: "kimi-k3", InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}},
39 })
40 if err != nil {
41 t.Fatalf("New: %v", err)
42 }
43 c := p.(*client)
44 if !c.vision {
45 t.Fatal("model metadata should enable image wire serialization")
46 }
47 req := c.buildRequest(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}})
48 if _, ok := req.Messages[0].Content.([]chatContentPart); !ok {
49 t.Fatalf("content = %#v, want image content parts", req.Messages[0].Content)
50 }
51 }
52
53 func TestBuildRequestEmbedsOfficialDeepSeekImageURLAndFileID(t *testing.T) {
54 c := &client{model: OfficialDeepSeekVisionModel, vision: true, deepseek: true}
55 req := c.buildRequest(provider.Request{
56 Messages: []provider.Message{{
57 Role: provider.RoleUser,
58 Content: "what is this",
59 Images: []string{
60 "https://cdn.example.com/cat.png",
61 "file-api-0a1b2c3d4e5f6071",
62 },
63 }},
64 })
65 parts, ok := req.Messages[0].Content.([]chatContentPart)
66 if !ok || len(parts) != 3 {
67 t.Fatalf("parts = %#v", req.Messages[0].Content)
68 }
69 if parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "https://cdn.example.com/cat.png" {
70 t.Fatalf("url part = %+v", parts[1])
71 }
72 if parts[2].Type != "file" || parts[2].FileID != "file-api-0a1b2c3d4e5f6071" {
73 t.Fatalf("file part = %+v", parts[2])
74 }
75 }
76
77 func TestBuildRequestSkipsImagesWithoutVision(t *testing.T) {
78 c := &client{model: "deepseek-v4"} // vision unset
79 req := c.buildRequest(provider.Request{
80 Messages: []provider.Message{
81 {Role: provider.RoleUser, Content: "ignore the image", Images: []string{"data:image/png;base64,AAAA"}},
82 },
83 })
84 if s, ok := req.Messages[0].Content.(string); !ok || s != "ignore the image" {
85 t.Fatalf("non-vision content = %#v, want plain string", req.Messages[0].Content)
86 }
87 }
88
89 func TestOfficialDeepSeekProviderWideVisionInputMatchesTextOnlyRequest(t *testing.T) {
90 p, err := New(provider.Config{
91 Name: "deepseek",
92 BaseURL: "https://api.deepseek.com",
93 Model: "deepseek-v4-pro",
94 Extra: map[string]any{"vision": true},
95 })
96 if err != nil {
97 t.Fatalf("New: %v", err)
98 }
99 c := p.(*client)
100 if c.vision {
101 t.Fatal("official DeepSeek endpoint must ignore stale vision=true config")
102 }
103
104 textOnly := provider.Request{Messages: []provider.Message{{
105 Role: provider.RoleUser, Content: "describe this image",
106 }}}
107 withImage := provider.Request{Messages: []provider.Message{{
108 Role: provider.RoleUser, Content: "describe this image",
109 Images: []string{"data:image/png;base64," + strings.Repeat("QUFB", 20_000)},
110 }}}
111 textBody, err := json.Marshal(c.buildRequest(textOnly))
112 if err != nil {
113 t.Fatalf("marshal text request: %v", err)
114 }
115 imageBody, err := json.Marshal(c.buildRequest(withImage))
116 if err != nil {
117 t.Fatalf("marshal image request: %v", err)
118 }
119 if !bytes.Equal(imageBody, textBody) {
120 t.Fatalf("official DeepSeek image request changed provider-visible bytes:\ntext: %s\nimage: %s", textBody, imageBody)
121 }
122 }
123
124 func TestOfficialDeepSeekUnknownModelExplicitVisionSerializesImages(t *testing.T) {
125 p, err := New(provider.Config{
126 Name: "deepseek",
127 BaseURL: "https://api.deepseek.com",
128 Model: "deepseek-v5-vision",
129 Extra: map[string]any{
130 "vision": true,
131 "vision_model_explicit": true,
132 },
133 })
134 if err != nil {
135 t.Fatalf("New: %v", err)
136 }
137 c := p.(*client)
138 if !c.vision {
139 t.Fatal("explicit vision must enable unknown DeepSeek models")
140 }
141
142 textOnly := provider.Request{Messages: []provider.Message{{
143 Role: provider.RoleUser, Content: "describe",
144 }}}
145 withImage := provider.Request{Messages: []provider.Message{{
146 Role: provider.RoleUser, Content: "describe",
147 Images: []string{"data:image/png;base64,AAAA"},
148 }}}
149 textBody, err := json.Marshal(c.buildRequest(textOnly))
150 if err != nil {
151 t.Fatalf("marshal text request: %v", err)
152 }
153 imageBody, err := json.Marshal(c.buildRequest(withImage))
154 if err != nil {
155 t.Fatalf("marshal image request: %v", err)
156 }
157 if bytes.Equal(imageBody, textBody) || !bytes.Contains(imageBody, []byte("data:image/png;base64,AAAA")) {
158 t.Fatalf("explicit unknown DeepSeek image missing from request: %s", imageBody)
159 }
160 }
161
162 func TestOfficialDeepSeekDoesNotInjectToolResultImages(t *testing.T) {
163 p, err := New(provider.Config{
164 Name: "deepseek",
165 BaseURL: "https://api.deepseek.com/v1",
166 Model: "deepseek-v4-pro",
167 Extra: map[string]any{
168 "vision": true,
169 "vision_model_explicit": true,
170 },
171 })
172 if err != nil {
173 t.Fatalf("New: %v", err)
174 }
175 plainMessages := []provider.Message{
176 {Role: provider.RoleUser, Content: "take a screenshot"},
177 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
178 ID: "c1", Name: "shot", Arguments: "{}",
179 }}},
180 {
181 Role: provider.RoleTool, ToolCallID: "c1", Name: "shot",
182 Content: "[image: image/png]",
183 },
184 }
185 imageMessages := append([]provider.Message(nil), plainMessages...)
186 imageMessages[2].Images = []string{"data:image/png;base64,AAAA"}
187 req := p.(*client).buildRequest(provider.Request{Messages: imageMessages})
188 if len(req.Messages) != 3 {
189 t.Fatalf("messages = %d, want 3 without an injected image message", len(req.Messages))
190 }
191 body, err := json.Marshal(req)
192 if err != nil {
193 t.Fatalf("marshal request: %v", err)
194 }
195 if strings.Contains(string(body), "image_url") || strings.Contains(string(body), "base64,AAAA") {
196 t.Fatalf("official DeepSeek request leaked tool image payload: %s", body)
197 }
198 plainBody, err := json.Marshal(p.(*client).buildRequest(provider.Request{Messages: plainMessages}))
199 if err != nil {
200 t.Fatalf("marshal plain request: %v", err)
201 }
202 if !bytes.Equal(body, plainBody) {
203 t.Fatalf("official DeepSeek tool image metadata changed provider-visible bytes:\nplain: %s\nimage: %s", plainBody, body)
204 }
205 }
206
207 func TestOfficialDeepSeekVisionSKUEmbedsUserImages(t *testing.T) {
208 p, err := New(provider.Config{
209 Name: "deepseek",
210 BaseURL: "https://api.deepseek.com",
211 Model: OfficialDeepSeekVisionModel,
212 })
213 if err != nil {
214 t.Fatalf("New: %v", err)
215 }
216 c := p.(*client)
217 if !c.vision {
218 t.Fatal("pinned official DeepSeek vision SKU must enable user image serialization")
219 }
220 req := c.buildRequest(provider.Request{Messages: []provider.Message{{
221 Role: provider.RoleUser, Content: "describe",
222 Images: []string{"data:image/png;base64,AAAA"},
223 }}})
224 parts, ok := req.Messages[0].Content.([]chatContentPart)
225 if !ok || len(parts) != 2 || parts[0].Type != "text" || parts[1].Type != "image_url" {
226 t.Fatalf("vision SKU user content = %#v, want [text, image_url]", req.Messages[0].Content)
227 }
228 if parts[1].ImageURL == nil || parts[1].ImageURL.URL != "data:image/png;base64,AAAA" {
229 t.Fatalf("image_url = %+v", parts[1].ImageURL)
230 }
231
232 textOnly := c.buildRequest(provider.Request{Messages: []provider.Message{{
233 Role: provider.RoleUser, Content: "hello",
234 }}})
235 if s, ok := textOnly.Messages[0].Content.(string); !ok || s != "hello" {
236 t.Fatalf("vision SKU text-only content = %#v, want a string", textOnly.Messages[0].Content)
237 }
238 }
239
240 // V4.1 Flash is natively multimodal, so the official endpoint must serialize
241 // images for it — and for the ids the vendor still aliases to it — without the
242 // caller first resolving capability metadata.
243 func TestOfficialDeepSeekMultimodalSKUsEmbedUserImages(t *testing.T) {
244 for _, model := range []string{"deepseek-flash", "deepseek-v4-flash", "deepseek-v4.1-flash-expires-on-0910"} {
245 p, err := New(provider.Config{Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: model})
246 if err != nil {
247 t.Fatalf("New(%s): %v", model, err)
248 }
249 c := p.(*client)
250 if !c.vision {
251 t.Fatalf("%s must serialize user images without resolved metadata", model)
252 }
253 req := c.buildRequest(provider.Request{Messages: []provider.Message{{
254 Role: provider.RoleUser, Content: "describe",
255 Images: []string{"data:image/png;base64,AAAA"},
256 }}})
257 parts, ok := req.Messages[0].Content.([]chatContentPart)
258 if !ok || len(parts) != 2 || parts[0].Type != "text" || parts[1].Type != "image_url" {
259 t.Fatalf("%s user content = %#v, want [text, image_url]", model, req.Messages[0].Content)
260 }
261 if parts[1].ImageURL == nil || parts[1].ImageURL.URL != "data:image/png;base64,AAAA" {
262 t.Fatalf("%s image_url = %+v", model, parts[1].ImageURL)
263 }
264 }
265 }
266
267 func TestOfficialRequestURLImageHardLimit(t *testing.T) {
268 p, err := New(provider.Config{BaseURL: "https://relay.test", Model: "deepseek-v4-pro", Extra: map[string]any{"request_url": "https://api.deepseek.com/v1/chat/completions", "vision": true}, ModelInfo: &provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}})
269 if err != nil {
270 t.Fatal(err)
271 }
272 body, err := json.Marshal(p.(*client).buildRequest(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}}))
273 if err != nil || strings.Contains(string(body), "AAAA") {
274 t.Fatalf("official request URL leaked image: %s %v", body, err)
275 }
276 }
277
278 func TestOfficialVisionExplicitOffRespectsResolvedMetadata(t *testing.T) {
279 p, err := New(provider.Config{BaseURL: "https://api.deepseek.com", Model: OfficialDeepSeekVisionModel, Extra: map[string]any{"vision": true}, ModelInfo: &provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText}}})
280 if err != nil {
281 t.Fatal(err)
282 }
283 body, err := json.Marshal(p.(*client).buildRequest(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}}))
284 if err != nil || strings.Contains(string(body), "AAAA") {
285 t.Fatalf("explicit off leaked image: %s %v", body, err)
286 }
287 if p.(provider.ModelInfoProvider).ModelInfo().SupportsInput(provider.ModalityImage) {
288 t.Fatal("metadata disagrees with serializer")
289 }
290 }
291
292 func TestOfficialDeepSeekVisionSKUEmbedsToolImages(t *testing.T) {
293 p, err := New(provider.Config{
294 Name: "deepseek",
295 BaseURL: "https://api.deepseek.com",
296 Model: OfficialDeepSeekVisionModel,
297 Extra: map[string]any{"vision": true},
298 })
299 if err != nil {
300 t.Fatalf("New: %v", err)
301 }
302 plain := []provider.Message{
303 {Role: provider.RoleUser, Content: "take a screenshot"},
304 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
305 ID: "c1", Name: "shot", Arguments: "{}",
306 }}},
307 {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]"},
308 }
309 withToolImage := append([]provider.Message(nil), plain...)
310 withToolImage[2].Images = []string{"data:image/png;base64,AAAA"}
311 c := p.(*client)
312 body, err := json.Marshal(c.buildRequest(provider.Request{Messages: withToolImage}))
313 if err != nil {
314 t.Fatalf("marshal: %v", err)
315 }
316 if !strings.Contains(string(body), "base64,AAAA") {
317 t.Fatalf("official DeepSeek vision SKU omitted tool image payload: %s", body)
318 }
319 plainBody, err := json.Marshal(c.buildRequest(provider.Request{Messages: plain}))
320 if err != nil {
321 t.Fatalf("marshal plain: %v", err)
322 }
323 if bytes.Equal(body, plainBody) {
324 t.Fatalf("tool images changed official DeepSeek vision SKU bytes:\nplain: %s\nimage: %s", plainBody, body)
325 }
326 }
327
328 func TestCustomDeepSeekProtocolGatewayPreservesExplicitVision(t *testing.T) {
329 p, err := New(provider.Config{
330 Name: "deepseek-gateway",
331 BaseURL: "https://gateway.example/v1",
332 Model: "deepseek-v4-pro",
333 Extra: map[string]any{
334 "reasoning_protocol": "deepseek",
335 "vision": true,
336 },
337 })
338 if err != nil {
339 t.Fatalf("New: %v", err)
340 }
341 c := p.(*client)
342 if !c.deepseek || !c.vision {
343 t.Fatalf("deepseek=%v vision=%v, want both enabled", c.deepseek, c.vision)
344 }
345 req := c.buildRequest(provider.Request{Messages: []provider.Message{{
346 Role: provider.RoleUser, Content: "describe",
347 Images: []string{"data:image/png;base64,AAAA"},
348 }}})
349 parts, ok := req.Messages[0].Content.([]chatContentPart)
350 if !ok || len(parts) != 2 || parts[1].ImageURL == nil {
351 t.Fatalf("custom gateway content = %#v, want [text, image_url]", req.Messages[0].Content)
352 }
353 }
354
355 func TestImageURLDetailFromConfig(t *testing.T) {
356 c := &client{model: "gpt-4o", vision: true, visionDetail: "low"}
357 req := c.buildRequest(provider.Request{
358 Messages: []provider.Message{
359 {Role: provider.RoleUser, Content: "x", Images: []string{"data:image/png;base64,AAAA"}},
360 },
361 })
362 parts := req.Messages[0].Content.([]chatContentPart)
363 if parts[1].ImageURL.Detail != "low" {
364 t.Fatalf("detail = %q, want low", parts[1].ImageURL.Detail)
365 }
366 }
367
368 func TestImageURLDetailOmittedByDefault(t *testing.T) {
369 c := &client{model: "gpt-4o", vision: true}
370 req := c.buildRequest(provider.Request{
371 Messages: []provider.Message{
372 {Role: provider.RoleUser, Content: "x", Images: []string{"data:image/png;base64,AAAA"}},
373 },
374 })
375 body, _ := json.Marshal(req.Messages[0].Content.([]chatContentPart)[1])
376 if strings.Contains(string(body), "detail") {
377 t.Errorf("detail must be omitted when unset: %s", body)
378 }
379 }
380
381 // Tool-result images can't ride in the tool message itself (the OpenAI API
382 // accepts only text parts under role "tool"), so buildRequest injects them as
383 // a user message after the turn's full run of tool results.
384 func TestBuildRequestInjectsToolImagesAsUserMessage(t *testing.T) {
385 c := &client{model: "gpt-4o", vision: true}
386 req := c.buildRequest(provider.Request{
387 Messages: []provider.Message{
388 {Role: provider.RoleUser, Content: "screenshot please"},
389 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
390 {ID: "c1", Name: "shot", Arguments: "{}"},
391 {ID: "c2", Name: "shot", Arguments: "{}"},
392 }},
393 {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"}},
394 {Role: provider.RoleTool, ToolCallID: "c2", Name: "shot", Content: "no image"},
395 {Role: provider.RoleUser, Content: "and?"},
396 },
397 })
398 if len(req.Messages) != 6 {
399 t.Fatalf("got %d messages, want 6 (images injected after the tool run)", len(req.Messages))
400 }
401 for i, m := range req.Messages[2:4] {
402 if _, ok := m.Content.(string); !ok || m.Role != "tool" {
403 t.Fatalf("message %d = %+v, want tool message with plain string content", i+2, m)
404 }
405 }
406 inj := req.Messages[4]
407 if inj.Role != "user" {
408 t.Fatalf("injected message role = %q, want user between tool run and next turn", inj.Role)
409 }
410 parts, ok := inj.Content.([]chatContentPart)
411 if !ok || len(parts) != 2 || parts[0].Type != "text" || parts[1].Type != "image_url" {
412 t.Fatalf("injected content = %#v, want [text, image_url]", inj.Content)
413 }
414 if parts[1].ImageURL == nil || parts[1].ImageURL.URL != "data:image/png;base64,AAAA" {
415 t.Fatalf("image_url = %+v, want the tool image data URL", parts[1].ImageURL)
416 }
417 if req.Messages[5].Content != "and?" {
418 t.Fatalf("trailing user message displaced: %+v", req.Messages[5])
419 }
420 }
421
422 func TestBuildRequestFlushesTrailingToolImages(t *testing.T) {
423 c := &client{model: "gpt-4o", vision: true}
424 req := c.buildRequest(provider.Request{
425 Messages: []provider.Message{
426 {Role: provider.RoleUser, Content: "go"},
427 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}},
428 {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"}},
429 },
430 })
431 last := req.Messages[len(req.Messages)-1]
432 if last.Role != "user" {
433 t.Fatalf("last message = %+v, want the injected image user message", last)
434 }
435 if _, ok := last.Content.([]chatContentPart); !ok {
436 t.Fatalf("last content = %#v, want content parts", last.Content)
437 }
438 }
439
440 func TestBuildRequestSkipsToolImagesWithoutVision(t *testing.T) {
441 c := &client{model: "deepseek-v4"} // vision unset
442 req := c.buildRequest(provider.Request{
443 Messages: []provider.Message{
444 {Role: provider.RoleUser, Content: "go"},
445 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}},
446 {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]", Images: []string{"data:image/png;base64,AAAA"}},
447 },
448 })
449 if len(req.Messages) != 3 {
450 t.Fatalf("got %d messages, want 3 (no injection without vision)", len(req.Messages))
451 }
452 if s, ok := req.Messages[2].Content.(string); !ok || s != "[image: image/png]" {
453 t.Fatalf("tool content = %#v, want the plain placeholder string", req.Messages[2].Content)
454 }
455 }
456
456 lines GO