返回 DeepSeek-Reasonix
image_test.go
根目录 / internal / provider / anthropic / image_test.go
1 package anthropic
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "strings"
8 "testing"
9
10 "reasonix/internal/provider"
11 "reasonix/internal/provider/openai"
12 )
13
14 func TestBuildRequestEmbedsImageBlockForVisionModel(t *testing.T) {
15 c := &client{model: "claude-opus-4-8", vision: true}
16 req := c.buildRequest(context.Background(), provider.Request{
17 Messages: []provider.Message{
18 {Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/jpeg;base64,ZZZZ"}},
19 },
20 })
21 blocks := req.Messages[0].Content
22 if len(blocks) != 2 || blocks[0].Type != "text" || blocks[1].Type != "image" {
23 t.Fatalf("blocks = %+v, want [text, image]", blocks)
24 }
25 src := blocks[1].Source
26 if src == nil || src.Type != "base64" || src.MediaType != "image/jpeg" || src.Data != "ZZZZ" {
27 t.Fatalf("image source = %+v, want base64 / image/jpeg / ZZZZ", src)
28 }
29 }
30
31 func TestModelInfoEnablesImageWireSerialization(t *testing.T) {
32 p, err := New(provider.Config{
33 Name: "catalog", BaseURL: "https://example.test", Model: "vision",
34 ModelInfo: &provider.ModelInfo{ID: "vision", InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}},
35 })
36 if err != nil {
37 t.Fatalf("New: %v", err)
38 }
39 c := p.(*client)
40 if !c.vision {
41 t.Fatal("model metadata should enable image wire serialization")
42 }
43 req := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}})
44 if len(req.Messages[0].Content) != 2 || req.Messages[0].Content[1].Type != "image" {
45 t.Fatalf("content = %#v, want text + image blocks", req.Messages[0].Content)
46 }
47 }
48
49 func TestBuildRequestSkipsImageBlockWithoutVision(t *testing.T) {
50 c := &client{model: "claude-opus-4-8"} // vision unset
51 req := c.buildRequest(context.Background(), provider.Request{
52 Messages: []provider.Message{
53 {Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/jpeg;base64,ZZZZ"}},
54 },
55 })
56 blocks := req.Messages[0].Content
57 if len(blocks) != 1 || blocks[0].Type != "text" {
58 t.Fatalf("blocks = %+v, want [text] only when vision is off", blocks)
59 }
60 }
61
62 func TestOfficialDeepSeekVisionSKUEmbedsUserImages(t *testing.T) {
63 p, err := New(provider.Config{
64 Name: "deepseek-anthropic",
65 BaseURL: "https://api.deepseek.com/anthropic",
66 Model: openai.OfficialDeepSeekVisionModel,
67 })
68 if err != nil {
69 t.Fatalf("New: %v", err)
70 }
71 c := p.(*client)
72 if !c.vision {
73 t.Fatal("pinned official DeepSeek vision SKU must enable user image serialization")
74 }
75 req := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{
76 Role: provider.RoleUser, Content: "describe",
77 Images: []string{"data:image/jpeg;base64,ZZZZ"},
78 }}})
79 blocks := req.Messages[0].Content
80 if len(blocks) != 2 || blocks[0].Type != "text" || blocks[1].Type != "image" {
81 t.Fatalf("blocks = %+v, want [text, image]", blocks)
82 }
83 src := blocks[1].Source
84 if src == nil || src.Type != "base64" || src.MediaType != "image/jpeg" || src.Data != "ZZZZ" {
85 t.Fatalf("image source = %+v", src)
86 }
87 }
88
89 func TestOfficialRequestURLImageHardLimit(t *testing.T) {
90 p, err := New(provider.Config{BaseURL: "https://relay.test", Model: "deepseek-v4-pro", Extra: map[string]any{"request_url": "https://api.deepseek.com/anthropic/v1/messages", "vision": true}, ModelInfo: &provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText, provider.ModalityImage}}})
91 if err != nil {
92 t.Fatal(err)
93 }
94 body, err := json.Marshal(p.(*client).buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}}))
95 if err != nil || strings.Contains(string(body), "AAAA") {
96 t.Fatalf("official request URL leaked image: %s %v", body, err)
97 }
98 }
99
100 func TestOfficialVisionExplicitOffRespectsResolvedMetadata(t *testing.T) {
101 p, err := New(provider.Config{BaseURL: "https://api.deepseek.com/anthropic", Model: openai.OfficialDeepSeekVisionModel, Extra: map[string]any{"vision": true}, ModelInfo: &provider.ModelInfo{InputModalities: []provider.ModelModality{provider.ModalityText}}})
102 if err != nil {
103 t.Fatal(err)
104 }
105 body, err := json.Marshal(p.(*client).buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: "describe", Images: []string{"data:image/png;base64,AAAA"}}}}))
106 if err != nil || strings.Contains(string(body), "AAAA") {
107 t.Fatalf("explicit off leaked image: %s %v", body, err)
108 }
109 if p.(provider.ModelInfoProvider).ModelInfo().SupportsInput(provider.ModalityImage) {
110 t.Fatal("metadata disagrees with serializer")
111 }
112 }
113
114 func TestOfficialDeepSeekVisionSKUEmbedsURLAndFileID(t *testing.T) {
115 p, err := New(provider.Config{
116 Name: "deepseek-anthropic",
117 BaseURL: "https://api.deepseek.com/anthropic",
118 Model: openai.OfficialDeepSeekVisionModel,
119 })
120 if err != nil {
121 t.Fatalf("New: %v", err)
122 }
123 c := p.(*client)
124 req := c.buildRequest(context.Background(), provider.Request{Messages: []provider.Message{{
125 Role: provider.RoleUser,
126 Content: "describe",
127 Images: []string{
128 "https://cdn.example.com/cat.png",
129 "file-api-0a1b2c3d4e5f6071",
130 },
131 }}})
132 blocks := req.Messages[0].Content
133 if len(blocks) != 3 || blocks[1].Type != "image" || blocks[2].Type != "image" {
134 t.Fatalf("blocks = %+v", blocks)
135 }
136 if blocks[1].Source == nil || blocks[1].Source.Type != "url" || blocks[1].Source.URL != "https://cdn.example.com/cat.png" {
137 t.Fatalf("url source = %+v", blocks[1].Source)
138 }
139 if blocks[2].Source == nil || blocks[2].Source.Type != "file" || blocks[2].Source.FileID != "file-api-0a1b2c3d4e5f6071" {
140 t.Fatalf("file source = %+v", blocks[2].Source)
141 }
142 }
143
144 func TestOfficialDeepSeekVisionSKUEmbedsToolImages(t *testing.T) {
145 p, err := New(provider.Config{
146 Name: "deepseek-anthropic",
147 BaseURL: "https://api.deepseek.com/anthropic",
148 Model: openai.OfficialDeepSeekVisionModel,
149 Extra: map[string]any{"vision": true},
150 })
151 if err != nil {
152 t.Fatalf("New: %v", err)
153 }
154 c := p.(*client)
155 messages := toolMessages([]string{"data:image/png;base64,QUFB"})
156 messages[1].ReasoningContent = "Inspect the requested image."
157 req := c.buildRequest(context.Background(), provider.Request{Messages: messages})
158 body, err := json.Marshal(req)
159 if err != nil {
160 t.Fatalf("marshal: %v", err)
161 }
162 if !strings.Contains(string(body), `"type":"image"`) || !strings.Contains(string(body), "QUFB") {
163 t.Fatalf("official DeepSeek vision SKU omitted tool image payload: %s", body)
164 }
165 last := req.Messages[len(req.Messages)-1]
166 if len(last.Content) != 2 || last.Content[0].Type != "tool_result" || last.Content[1].Type != "image" {
167 t.Fatalf("expected tool result then top-level image: %+v", last)
168 }
169 }
170
171 func TestOfficialDeepSeekIgnoresVisionMetadata(t *testing.T) {
172 p, err := New(provider.Config{
173 Name: "deepseek-anthropic",
174 BaseURL: "https://api.deepseek.com/anthropic",
175 Model: "deepseek-v4-pro",
176 Extra: map[string]any{"vision": true},
177 })
178 if err != nil {
179 t.Fatalf("New: %v", err)
180 }
181 c := p.(*client)
182 if c.vision {
183 t.Fatal("official DeepSeek Anthropic endpoint must ignore vision metadata")
184 }
185 req := c.buildRequest(context.Background(), provider.Request{Messages: append(
186 []provider.Message{{
187 Role: provider.RoleUser, Content: "describe",
188 Images: []string{"data:image/jpeg;base64,ZZZZ"},
189 }},
190 toolMessages([]string{"data:image/png;base64,QUFB"})...,
191 )})
192 body, err := json.Marshal(req)
193 if err != nil {
194 t.Fatalf("marshal request: %v", err)
195 }
196 if strings.Contains(string(body), `"type":"image"`) || strings.Contains(string(body), "ZZZZ") || strings.Contains(string(body), "QUFB") {
197 t.Fatalf("official DeepSeek Anthropic request leaked image payload: %s", body)
198 }
199 }
200
201 func TestOfficialDeepSeekImageMetadataMatchesTextOnlyWireBytes(t *testing.T) {
202 p, err := New(provider.Config{
203 Name: "deepseek-anthropic",
204 BaseURL: "https://api.deepseek.com/anthropic",
205 Model: "deepseek-v4-pro",
206 Extra: map[string]any{"vision": true},
207 })
208 if err != nil {
209 t.Fatalf("New: %v", err)
210 }
211 c := p.(*client)
212 plain := []provider.Message{
213 {Role: provider.RoleUser, Content: "inspect"},
214 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}},
215 {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "vision result"},
216 }
217 withImages := append([]provider.Message(nil), plain...)
218 withImages[0].Images = []string{"data:image/png;base64," + strings.Repeat("QUFB", 20_000)}
219 withImages[2].Images = []string{"data:image/png;base64,VE9PTA=="}
220
221 plainBody, err := json.Marshal(c.buildRequest(context.Background(), provider.Request{Messages: plain}))
222 if err != nil {
223 t.Fatalf("marshal plain request: %v", err)
224 }
225 imageBody, err := json.Marshal(c.buildRequest(context.Background(), provider.Request{Messages: withImages}))
226 if err != nil {
227 t.Fatalf("marshal image request: %v", err)
228 }
229 if !bytes.Equal(imageBody, plainBody) {
230 t.Fatalf("official DeepSeek Anthropic image metadata changed provider-visible bytes:\nplain: %s\nimage: %s", plainBody, imageBody)
231 }
232 }
233
234 // toolMessages is a paired history whose tool result carries an image: the
235 // shape parseToolResult produces for an MCP screenshot tool.
236 func toolMessages(images []string) []provider.Message {
237 return []provider.Message{
238 {Role: provider.RoleUser, Content: "screenshot please"},
239 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "shot", Arguments: "{}"}}},
240 {Role: provider.RoleTool, ToolCallID: "c1", Name: "shot", Content: "[image: image/png]", Images: images},
241 }
242 }
243
244 func TestBuildRequestEmbedsToolResultImagesForVisionModel(t *testing.T) {
245 c := &client{model: "claude-opus-4-8", vision: true}
246 req := c.buildRequest(context.Background(), provider.Request{Messages: toolMessages([]string{"data:image/png;base64,QUFB"})})
247 last := req.Messages[len(req.Messages)-1]
248 if last.Role != "user" || len(last.Content) != 1 || last.Content[0].Type != "tool_result" {
249 t.Fatalf("last message = %+v, want a single tool_result block", last)
250 }
251 blocks, ok := last.Content[0].Content.([]contentBlock)
252 if !ok {
253 t.Fatalf("tool_result content = %T, want []contentBlock", last.Content[0].Content)
254 }
255 if len(blocks) != 2 || blocks[0].Type != "text" || blocks[1].Type != "image" {
256 t.Fatalf("tool_result blocks = %+v, want [text, image]", blocks)
257 }
258 if blocks[0].Text != "[image: image/png]" {
259 t.Fatalf("text block = %q, want the placeholder text", blocks[0].Text)
260 }
261 src := blocks[1].Source
262 if src == nil || src.Type != "base64" || src.MediaType != "image/png" || src.Data != "QUFB" {
263 t.Fatalf("image source = %+v, want base64 / image/png / QUFB", src)
264 }
265 }
266
267 func TestBuildRequestDropsToolResultImagesWithoutVision(t *testing.T) {
268 c := &client{model: "claude-opus-4-8"} // vision unset
269 req := c.buildRequest(context.Background(), provider.Request{Messages: toolMessages([]string{"data:image/png;base64,QUFB"})})
270 last := req.Messages[len(req.Messages)-1]
271 if s, ok := last.Content[0].Content.(string); !ok || s != "[image: image/png]" {
272 t.Fatalf("non-vision tool_result content = %#v, want the plain placeholder string", last.Content[0].Content)
273 }
274 }
275
276 // A text-only tool result must keep serializing exactly as before the image
277 // channel existed: plain string content, no array — the prompt-cache prefix of
278 // existing sessions depends on those bytes.
279 func TestBuildRequestToolResultTextOnlyKeepsStringContent(t *testing.T) {
280 c := &client{model: "claude-opus-4-8", vision: true}
281 msgs := toolMessages(nil)
282 msgs[2].Content = "plain output"
283 // Trailing user turn merges after the tool_result in the same user message
284 // and takes the cache breakpoint, so the tool_result block keeps its
285 // pre-image-channel bytes.
286 msgs = append(msgs, provider.Message{Role: provider.RoleUser, Content: "next"})
287 req := c.buildRequest(context.Background(), provider.Request{Messages: msgs})
288 last := req.Messages[len(req.Messages)-1]
289 if len(last.Content) != 2 || last.Content[0].Type != "tool_result" {
290 t.Fatalf("last message blocks = %+v, want [tool_result, text]", last.Content)
291 }
292 if s, ok := last.Content[0].Content.(string); !ok || s != "plain output" {
293 t.Fatalf("tool_result content = %#v, want plain string", last.Content[0].Content)
294 }
295 body, err := json.Marshal(last.Content[0])
296 if err != nil {
297 t.Fatalf("marshal: %v", err)
298 }
299 want := `{"type":"tool_result","tool_use_id":"c1","content":"plain output"}`
300 if string(body) != want {
301 t.Fatalf("serialized tool_result = %s, want %s", body, want)
302 }
303 }
304
304 lines GO