返回 DeepSeek-Reasonix
image_test.go
根目录 / internal / provider / responses / image_test.go
1 package responses
2
3 import (
4 "bytes"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "reasonix/internal/provider"
10 )
11
12 func TestOfficialDeepSeekVisionSKUEmbedsUserImages(t *testing.T) {
13 c := New(Config{
14 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash-vision-exp",
15 }).(*client)
16 if !c.vision {
17 t.Fatal("pinned official DeepSeek vision SKU must enable user image serialization")
18 }
19 body, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{{
20 Role: provider.RoleUser, Content: "what is this",
21 Images: []string{"data:image/png;base64,AAAA"},
22 }}})
23 items := body["input"].([]map[string]any)
24 parts, ok := items[0]["content"].([]map[string]string)
25 if !ok || len(parts) != 2 || parts[0]["type"] != "input_text" || parts[1]["type"] != "input_image" {
26 t.Fatalf("vision SKU content = %#v, want input_text + input_image", items[0]["content"])
27 }
28 textOnly, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{{
29 Role: provider.RoleUser, Content: "hello",
30 }}})
31 if got, ok := textOnly["input"].([]map[string]any)[0]["content"].(string); !ok || got != "hello" {
32 t.Fatalf("vision SKU text-only content = %#v, want a string", textOnly["input"].([]map[string]any)[0]["content"])
33 }
34 }
35
36 func TestOfficialRequestURLImageHardLimit(t *testing.T) {
37 c := New(Config{BaseURL: "https://relay.test", RequestURL: "https://api.deepseek.com/responses", Model: "deepseek-v4-pro", Extra: map[string]any{"vision": true}, ModelInfo: &provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}}).(*client)
38 req, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}})
39 body, err := json.Marshal(req)
40 if err != nil || strings.Contains(string(body), "AAAA") {
41 t.Fatalf("official request URL leaked image: %s %v", body, err)
42 }
43 }
44
45 func TestOfficialVisionExplicitOffRespectsResolvedMetadata(t *testing.T) {
46 c := New(Config{BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash-vision-exp", Extra: map[string]any{"vision": true}, ModelInfo: &provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText}}}).(*client)
47 req, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}})
48 body, err := json.Marshal(req)
49 if err != nil || strings.Contains(string(body), "AAAA") {
50 t.Fatalf("explicit off leaked image: %s %v", body, err)
51 }
52 if c.ModelInfo().SupportsInput(provider.ModalityImage) {
53 t.Fatal("metadata disagrees with serializer")
54 }
55 }
56
57 func TestModelInfoEnablesImageWireSerialization(t *testing.T) {
58 c := New(Config{
59 Name: "catalog", BaseURL: "https://example.test/v1", Model: "vision",
60 ModelInfo: &provider.ModelInfo{ID: "vision", InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}},
61 }).(*client)
62 if !c.vision {
63 t.Fatal("model metadata should enable image wire serialization")
64 }
65 body, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}})
66 items := body["input"].([]map[string]any)
67 parts := items[0]["content"].([]map[string]string)
68 if len(parts) != 2 || parts[1]["type"] != "input_image" {
69 t.Fatalf("content = %#v, want text + input_image parts", parts)
70 }
71 }
72
73 func TestOfficialDeepSeekVisionSKUEmbedsURLAndFileID(t *testing.T) {
74 c := New(Config{
75 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash-vision-exp",
76 }).(*client)
77 body, _, _ := c.buildRequestBody(provider.Request{Messages: []provider.Message{{
78 Role: provider.RoleUser,
79 Content: "what is this",
80 Images: []string{
81 "https://cdn.example.com/cat.png",
82 "file-api-0a1b2c3d4e5f6071",
83 },
84 }}})
85 items := body["input"].([]map[string]any)
86 parts, ok := items[0]["content"].([]map[string]string)
87 if !ok || len(parts) != 3 {
88 t.Fatalf("content = %#v", items[0]["content"])
89 }
90 if parts[1]["type"] != "input_image" || parts[1]["image_url"] != "https://cdn.example.com/cat.png" {
91 t.Fatalf("url part = %#v", parts[1])
92 }
93 if parts[2]["type"] != "input_image" || parts[2]["file_id"] != "file-api-0a1b2c3d4e5f6071" {
94 t.Fatalf("file part = %#v", parts[2])
95 }
96 }
97
98 func TestStatefulContinuationDoesNotDropUserImages(t *testing.T) {
99 c := New(Config{
100 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash-vision-exp", Mode: "stateful",
101 }).(*client)
102 prefix := []provider.Message{
103 {Role: provider.RoleSystem, Content: "You inspect images."},
104 {Role: provider.RoleUser, Content: "first"},
105 {Role: provider.RoleAssistant, Content: "answer"},
106 }
107 c.lastResponseID = "resp_1"
108 c.expectedPrefixDigest = c.conversationDigest(prefix)
109 body, usedPrevious, _ := c.buildRequestBody(provider.Request{Messages: append(prefix, provider.Message{
110 Role: provider.RoleUser, Content: "second", Images: []string{"data:image/png;base64,AAAA"},
111 })})
112 if usedPrevious {
113 t.Fatal("stateful continuation must not use text-only previous_response_id fast path for image turns")
114 }
115 items, ok := body["input"].([]map[string]any)
116 if !ok {
117 t.Fatalf("input = %#v, want multimodal item list", body["input"])
118 }
119 found := false
120 for _, item := range items {
121 parts, ok := item["content"].([]map[string]string)
122 if !ok {
123 continue
124 }
125 for _, part := range parts {
126 if part["type"] == "input_image" && part["image_url"] == "data:image/png;base64,AAAA" {
127 found = true
128 }
129 }
130 }
131 if !found {
132 t.Fatalf("stateful continuation input = %#v, missing user image", items)
133 }
134 }
135
136 func TestOfficialDeepSeekVisionSKUEmbedsToolImages(t *testing.T) {
137 c := New(Config{
138 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash-vision-exp",
139 Extra: map[string]any{"vision": true},
140 }).(*client)
141 plain := []provider.Message{
142 {Role: provider.RoleUser, Content: "inspect"},
143 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}},
144 {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "vision result"},
145 }
146 withToolImage := append([]provider.Message(nil), plain...)
147 withToolImage[2].Images = []string{"data:image/png;base64,VE9PTA=="}
148 plainBody, err := json.Marshal(mustRequest(t, c, plain))
149 if err != nil {
150 t.Fatalf("marshal plain: %v", err)
151 }
152 imageBody, err := json.Marshal(mustRequest(t, c, withToolImage))
153 if err != nil {
154 t.Fatalf("marshal image: %v", err)
155 }
156 if !strings.Contains(string(imageBody), "VE9PTA") {
157 t.Fatalf("official DeepSeek vision SKU omitted tool image payload: %s", imageBody)
158 }
159 if bytes.Equal(imageBody, plainBody) {
160 t.Fatalf("tool images changed official DeepSeek vision SKU bytes:\nplain: %s\nimage: %s", plainBody, imageBody)
161 }
162 }
163
164 func mustRequest(t *testing.T, c *client, messages []provider.Message) map[string]any {
165 t.Helper()
166 body, _, _ := c.buildRequestBody(provider.Request{Messages: messages})
167 return body
168 }
169
170 func TestOfficialDeepSeekResponsesImageMetadataMatchesTextOnlyWireBytes(t *testing.T) {
171 c := New(Config{
172 Name: "deepseek", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro",
173 Extra: map[string]any{"vision": true},
174 }).(*client)
175 plain := []provider.Message{
176 {Role: provider.RoleUser, Content: "inspect"},
177 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}},
178 {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "vision result"},
179 }
180 withImages := append([]provider.Message(nil), plain...)
181 withImages[0].Images = []string{"data:image/png;base64," + strings.Repeat("QUFB", 20_000)}
182 withImages[2].Images = []string{"data:image/png;base64,VE9PTA=="}
183
184 plainRequest, _, _ := c.buildRequestBody(provider.Request{Messages: plain})
185 imageRequest, _, _ := c.buildRequestBody(provider.Request{Messages: withImages})
186 plainBody, err := json.Marshal(plainRequest)
187 if err != nil {
188 t.Fatalf("marshal plain request: %v", err)
189 }
190 imageBody, err := json.Marshal(imageRequest)
191 if err != nil {
192 t.Fatalf("marshal image request: %v", err)
193 }
194 if !bytes.Equal(imageBody, plainBody) {
195 t.Fatalf("official DeepSeek Responses image metadata changed provider-visible bytes:\nplain: %s\nimage: %s", plainBody, imageBody)
196 }
197 }
198
199 func TestToolImagesFollowCompleteToolRun(t *testing.T) {
200 for _, trailing := range []bool{false, true} {
201 for _, vision := range []bool{false, true} {
202 messages := []provider.Message{
203 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "a", Name: "view_image", Arguments: "{}"}, {ID: "b", Name: "other", Arguments: "{}"}}},
204 {Role: provider.RoleTool, ToolCallID: "a", Content: "picture", Images: []string{"data:image/png;base64,AAAA", "invalid"}},
205 {Role: provider.RoleTool, ToolCallID: "b", Content: "done"},
206 }
207 if trailing {
208 messages = append(messages, provider.Message{Role: provider.RoleUser, Content: "next"})
209 }
210 items := messagesToInput(messages, vision, false, false)
211 want := 4
212 if vision {
213 want++
214 }
215 if trailing {
216 want++
217 }
218 if len(items) != want {
219 t.Fatalf("vision=%v trailing=%v: %#v", vision, trailing, items)
220 }
221 if items[2]["type"] != "function_call_output" || items[3]["call_id"] != "b" {
222 t.Fatalf("tool ordering: %#v", items)
223 }
224 if vision {
225 parts := items[4]["content"].([]map[string]string)
226 if items[4]["role"] != "user" || len(parts) != 2 || parts[1]["image_url"] != "data:image/png;base64,AAAA" {
227 t.Fatalf("image injection: %#v", items[4])
228 }
229 } else {
230 encoded, _ := json.Marshal(items)
231 if bytes.Contains(encoded, []byte("AAAA")) {
232 t.Fatalf("image leaked to text model")
233 }
234 }
235 if trailing && items[len(items)-1]["content"] != "next" {
236 t.Fatal("displaced user message")
237 }
238 }
239 }
240 }
241
241 lines GO